Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c904631
wip(trackers): feat/environment-tracker — unfinished, no mutator loop…
krassx Oct 20, 2026
33dcf28
test(trackers,browser): EnvironmentTracker (TR7) — gates green, mutat…
claude Oct 20, 2026
cfe6abf
fix(trackers,browser): EnvironmentTracker review fixes — rebind/seam …
claude Oct 20, 2026
52228d5
fix(trackers,browser,webview): EnvironmentTracker round-2 review — fa…
claude Oct 20, 2026
b27c104
fix(trackers): EnvironmentTracker round-3 review — field-wise retenti…
claude Oct 20, 2026
08820eb
fix(trackers): EnvironmentTracker round-4 review — failed permission …
claude Oct 20, 2026
0b782ed
fix(trackers,browser,webview): EnvironmentTracker round-5 review — si…
claude Oct 20, 2026
9b90260
fix(trackers): emit privateMode only on change, skip re-probing a ret…
claude Oct 20, 2026
f46ed15
test(bugsee): classify captureEnvironmentHints in option-directions (…
claude Oct 20, 2026
9d82b23
fix(trackers,browser): explicit detectPrivateMode in events source, c…
claude Oct 20, 2026
d276966
Merge origin/main into feat/environment-tracker
claude Oct 20, 2026
7b5b0a4
feat(browser): private-mode detection is opt-out; hints/permissions r…
krassx Oct 20, 2026
71357e7
fix(browser): enforce private-mode opt-out at the event sink; fix sta…
krassx Oct 20, 2026
1aaf5b6
docs(browser,protocol): describe the captureEnvironmentHints default …
claude Oct 20, 2026
08509d3
fix(browser): re-deliver a retained private-mode yes to each new session
krassx Oct 20, 2026
794573a
Merge remote-tracking branch 'origin/feat/environment-tracker' into f…
krassx Oct 20, 2026
7b01256
docs(browser): put the injected-globals JSDoc on its interface
krassx Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/browser-environment-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@bugsee/browser': minor
'@bugsee/webview': minor
'@bugsee/protocol': minor
'@bugsee/trackers': minor
---

Browser and WebView `events.system` gain `language_changed` (the page language changed) and `timezone_changed` (the time zone or UTC offset changed, so a DST shift counts). The Browser launch (not WebView, which keeps private-mode detection off) also gains `private_mode_detected`, from a private-browsing heuristic that is ON by default (opt-out): turn it off with `captureEnvironmentHints: false` (canonical option `com.bugsee.option.capture.environment-hints`, a non-boolean value is treated as off and reported). It reports only a `yes` answer, never as a certainty (constrained storage can false-positive), and an opted-out launch never forwards it even if another consumer enabled detection on the shared tracker. All of these come from the new per-window singleton `EnvironmentTracker` in `@bugsee/trackers/browser`, which can also observe environment hints (cookies, secure context, cross-origin isolation, service-worker control, automation flag, Do-Not-Track and GPC) and Permissions API states, read without ever prompting; unknown values are omitted. Those two stages are read only when a consumer sets `observeHints` / `queryPermissions`, and the browser launch does not, so it issues no `permissions.query()` call.
1 change: 1 addition & 0 deletions docs/PROGRESS.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export {
type ApplicationState,
ApplicationTracker,
applicationTrackerFor,
type EnvironmentEnv,
type EnvironmentEvents,
EnvironmentTracker,
environmentTrackerFor,
} from '@bugsee/trackers/browser';
export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name';
export {
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/launch-options.invalid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const INVALID: unknown[] = [null, 0, '', 'false', 'true', 1];
const failClosed: Array<[string, string]> = [
['captureInteractions', BugseeOption.CaptureInteractions],
['captureViewHierarchy', BugseeOption.CaptureViewHierarchy],
// private-mode detection: opt-out (owner decision, 2026-10-20); an invalid value is OFF, never consent
['captureEnvironmentHints', BugseeOption.CaptureEnvironmentHints],
];
const defaultOff: Array<[string, string]> = [
['persistUserIdentifier', BugseeOption.PersistUserIdentifier],
Expand Down
146 changes: 146 additions & 0 deletions packages/browser/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ import {
import {
applicationTrackerFor,
connectivityTrackerFor,
environmentTrackerFor,
processTrackerFor,
resetEnvironmentTracker,
resetProcessTracker,
resetStorageTracker,
resetUiTracker,
Expand Down Expand Up @@ -1514,6 +1516,150 @@ describe('launch', () => {
});
});

it('options.scheduler drives the environment tracker slow timer: a time-zone shift becomes a timezone_changed event', async () => {
stubSensors();
vi.stubGlobal('navigator', { language: 'en-US' });
const w = fakeWindow();
const ticks: Array<{ cb: () => void; ms: number }> = [];
try {
const store = memStore();
await launchTracked(
'tok',
noSampler({
window: w.win,
captureStore: store,
scheduler: {
setInterval: (cb, ms) => {
ticks.push({ cb, ms });
return `h${ticks.length}`;
},
clearInterval: () => {},
},
}),
);
await new Promise((r) => setTimeout(r, 0));
const slow = ticks.filter((t) => t.ms === 60_000); // the tracker's default cadence
expect(slow).toHaveLength(1);
const real = new Date().getTimezoneOffset();
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 120);
slow[0]?.cb();
const events = ((await drain(store, 'events.system')) ?? []).map(
(e) => e.data as { name: string; params?: { utcOffsetMinutes?: number } },
);
expect(
events
.filter((e) => e.name === 'timezone_changed')
.map((e) => e.params?.utcOffsetMinutes),
).toEqual([-(real - 120) + 0]);
} finally {
vi.restoreAllMocks();
resetEnvironmentTracker({ window: w.win });
}
});

it('the launch document reaches the environment tracker: its visibility gates the slow tick and a return to visible re-reads', async () => {
stubSensors();
vi.stubGlobal('navigator', { language: 'en-US' });
const w = fakeWindow();
const d = fakeWindow();
const vis = { state: 'visible' };
const document = Object.defineProperty({ ...d.win, body: {} }, 'visibilityState', {
get: () => vis.state,
}) as unknown as Document;
const ticks: Array<{ cb: () => void; ms: number }> = [];
try {
await launchTracked(
'tok',
noSampler({
window: w.win,
document,
captureStore: memStore(),
scheduler: {
setInterval: (cb, ms) => {
ticks.push({ cb, ms });
return `h${ticks.length}`;
},
clearInterval: () => {},
},
}),
);
const tracker = environmentTrackerFor({ window: w.win });
const slow = ticks.filter((t) => t.ms === 60_000);
expect(slow).toHaveLength(1);
const real = new Date().getTimezoneOffset();
expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0);
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(real - 45);
vis.state = 'hidden';
slow[0]?.cb();
d.emit('visibilitychange', {});
expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-real + 0); // hidden: nothing re-read
vis.state = 'visible';
d.emit('visibilitychange', {});
expect(tracker.current().timeZone?.utcOffsetMinutes).toBe(-(real - 45) + 0);
} finally {
vi.restoreAllMocks();
environmentTrackerFor({ window: w.win }).reset();
}
});

it('launch goes through the per-window EnvironmentTracker singleton (language/timezone and private mode on by default, no hints/permissions)', async () => {
stubSensors();
const estimate = vi.fn(() => Promise.resolve({ usage: 1, quota: 10 * 1024 * 1024 }));
const query = vi.fn(() => Promise.resolve({ state: 'granted' }));
vi.stubGlobal('navigator', {
language: 'en-US',
languages: ['en-US', 'en'],
cookieEnabled: true,
permissions: { query },
storage: { estimate },
});
const w = fakeWindow();
try {
const store = memStore();
await launchTracked('tok', noSampler({ window: w.win, captureStore: store }));
await new Promise((r) => setTimeout(r, 0));
const snapshot = environmentTrackerFor({ window: w.win }).current();
expect(snapshot.language).toEqual({ language: 'en-US', languages: ['en-US', 'en'] });
expect(snapshot.timeZone?.utcOffsetMinutes).toBeTypeOf('number');
expect(snapshot.privateMode).toEqual({ private: 'yes', method: 'storage-quota' }); // on by default
expect(snapshot.hints).toBeUndefined(); // nothing consumes hints / permissions: never read
expect(snapshot.permissions).toBeUndefined();
expect(query).not.toHaveBeenCalled();
expect(w.count('languagechange')).toBe(1);
const events = ((await drain(store, 'events.system')) ?? []).map(
(e) => e.data as { name: string; params?: unknown },
);
expect(events.find((e) => e.name === 'private_mode_detected')?.params).toEqual({
method: 'storage-quota',
});
} finally {
resetEnvironmentTracker({ window: w.win });
}
});

it('captureEnvironmentHints: false opts out of the private-mode heuristic (no probe, no event)', async () => {
stubSensors();
const estimate = vi.fn(() => Promise.resolve({ usage: 1, quota: 10 }));
vi.stubGlobal('navigator', { language: 'en-US', storage: { estimate } });
const w = fakeWindow();
try {
const store = memStore();
await launchTracked(
'tok',
noSampler({ window: w.win, captureStore: store, captureEnvironmentHints: false }),
);
await new Promise((r) => setTimeout(r, 0));
// (the storage trace tracker calls estimate() too, so the proof is the missing answer and event)
expect(environmentTrackerFor({ window: w.win }).current().privateMode).toBeUndefined();
const names = ((await drain(store, 'events.system')) ?? []).map(
(e) => (e.data as { name: string }).name,
);
expect(names).not.toContain('private_mode_detected');
} finally {
resetEnvironmentTracker({ window: w.win });
}
});

it('a rejected storage.estimate() reaches options.onError and never fails launch', async () => {
stubSensors();
const boom = new Error('estimate refused');
Expand Down
22 changes: 22 additions & 0 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [
// Safe side is ON: an invalid value (`'true'`, `1`, `null`) means DARK, never "not dark".
...FAIL_ON_BOOLEAN_OPTION,
},
// Environment hints that read as sensitive (private-browsing detection): the browser launch default is true, so
// `false` disables. An invalid value is OFF and reported (never read as consent to detect).
{
friendly: 'captureEnvironmentHints',
key: BugseeOption.CaptureEnvironmentHints,
default: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...FAIL_CLOSED_BOOLEAN_OPTION,
},
// Session replay: on by default, `false` opts out, an object customises it. It COLLECTS data, so an invalid
// scalar (`0`, `null`, `''`, `'false'`, ...) is OFF and reported; it must never throw.
// Masking is fail-closed regardless.
Expand Down Expand Up @@ -371,6 +379,15 @@ export interface BugseeLaunchOptions {
* the clear applies to reports created afterwards.
*/
persistUserIdentifier?: boolean;
/**
* Observe environment hints that read as sensitive. Today: the private-browsing HEURISTIC (a storage-quota probe)
* behind a `private_mode_detected` system event, emitted only for a `yes` answer and never as a certainty
* (constrained storage can false-positive).
* Default in the browser launch: `true` (the protocol layer defines the option key and its boolean type, not a
* default); pass `false` to turn it off. A non-boolean value is treated as off and reported. Language and time-zone changes (`language_changed`, `timezone_changed`) are always observed. Hint and
* permission states are not read at all.
*/
captureEnvironmentHints?: boolean;
/**
* Record the `user_idle_state` system trace (`active` / `idle` / `locked`, from the Idle Detection API). Default
* FALSE: it is behavioural data, and a page's own `idle-detection` grant is not consent to upload it. Even when
Expand Down Expand Up @@ -931,6 +948,11 @@ async function buildLaunch(
createBrowserSystemEventsSource({
window: win,
lifecycle,
// The window's environment singleton (EnvironmentTracker): language/timezone events, and the private-mode
// heuristic (on by default, `captureEnvironmentHints: false` opts out).
...(domDocument !== undefined ? { document: domDocument } : {}),
detectPrivateMode: resolved.options.get(BugseeOption.CaptureEnvironmentHints, true),
...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}),
// from the INJECTED window (bound to it); never the host global
...(matchMedia !== undefined ? { matchMedia } : {}),
...(options.onError !== undefined ? { onError: options.onError } : {}),
Expand Down
Loading
Loading