Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f52f9db
feat(core): optional frame.origin = 'call' marks a call-site frame on…
krassx Nov 2, 2026
5aef99e
feat(core): describeThrown — summarise non-Error values by class and …
krassx Nov 2, 2026
3e5a899
feat(capture): async-origin patch table, generator and drift test
krassx Nov 2, 2026
5021031
feat(capture): installAsyncOrigins — call-site recorder for stackless…
krassx Nov 2, 2026
55b22ce
feat(capture): async-origin enricher and target describers
krassx Nov 2, 2026
7feda84
feat(browser): report stackless async rejections with their call site
krassx Nov 2, 2026
fca6922
fix(capture,core): review round 1 - call-site skip, target-line priva…
krassx Nov 2, 2026
a180d1b
fix(capture,core): review round 2 - typeTag never throws, wider one-l…
krassx Nov 2, 2026
db6be29
fix(capture): reading a reason's stack can never change what the call…
krassx Nov 2, 2026
465bc6b
docs(design): step 6 spec proposal (resource_load_failed)
krassx Nov 3, 2026
e3c4a5c
test(e2e): async origin capture in real Chromium and Firefox, with an…
krassx Nov 3, 2026
5e3753c
docs(design): drop step 6 (document error listener)
krassx Nov 3, 2026
ec53291
test(e2e): async origin through real React and Vue adapters in Chromi…
krassx Nov 3, 2026
cfac5c0
fix(capture): core-js disguise, header-only stacks, SDK wrapper frame…
krassx Nov 3, 2026
f6955b1
feat(astro): report astro:hydration-error; document the Vue errorHand…
krassx Nov 3, 2026
fec4393
test(capture): satisfy biome in the core-js disguise test
krassx Nov 3, 2026
7b76020
test(e2e): install WebKit in CI, harden the harness, loosen engine-de…
krassx Nov 3, 2026
cb1bed6
fix(capture): find the wrapper site by position; anchor hasFrames
krassx Nov 3, 2026
b4862b2
fix(core): stamp debug_id on enricher-supplied frames so the call sit…
krassx Nov 3, 2026
0c84050
fix: address review findings on the async-origin PR
krassx Nov 3, 2026
3b53f98
fix: address the remaining review threads on the async-origin PR
krassx Nov 3, 2026
a79bd34
test(browser): a rejected non-Error object still arms the recorder
krassx Nov 3, 2026
e5b79f3
fix: address the last review threads on the async-origin PR
krassx Nov 3, 2026
92e0257
fix(astro): the hydration queue covers only the launch window
krassx Nov 3, 2026
795182d
docs(design): correct the stale and contradictory statements the desi…
krassx Nov 3, 2026
6e1b891
docs(design): fix the leftover contradictions (Astro row, WebKit, pro…
krassx Nov 3, 2026
bafd953
fix: address the newest review findings (astro windows, describers, s…
krassx Nov 3, 2026
ef03270
fix(astro): relaunch() shares the hydration window; a custom carrier …
krassx Nov 3, 2026
a234b83
fix(astro): a re-registration keeps the earlier carrier and onError, …
krassx Nov 3, 2026
7e73c92
fix: hydration target and single notification; describeTarget failure…
krassx Nov 3, 2026
57bbfb3
fix(astro): a plain relaunch() keeps the earlier document, carrier an…
krassx Nov 3, 2026
4446468
test(astro): a plain relaunch() after a bound window keeps that windo…
krassx Nov 3, 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: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ jobs:

# Playwright ships a downloader, not the browsers: `pnpm install` does not fetch them, so
# `browser.e2e.ts` fails with "Executable doesn't exist at .../chrome-headless-shell".
# Scoped to chromium — it is the only engine the browser harness drives.
# Chromium drives the browser harness; Firefox and WebKit join it for the async-origin e2e (the engines differ in WHEN the media error
# event fires, in the stack format, and WebKit eliminates tail calls, which is exactly what that feature depends on). WebKit needs
# its system libraries, hence --with-deps.
# On a fresh hosted VM this always downloads; it is a few seconds, not a failure.
- name: Install Playwright's Chromium
run: pnpm --filter @bugsee/instrumentation-tests exec playwright install chromium
- name: Install Playwright's browsers (Chromium, Firefox, WebKit)
run: pnpm --filter @bugsee/instrumentation-tests exec playwright install --with-deps chromium firefox webkit

# The nuxt/sveltekit/astro harnesses each boot a real dev server on a FIXED port. A fresh hosted VM
# starts clean, so this only guards a hung harness within one run (and a developer's machine, where
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"!samples",
"!.local-registry",
"!packages/protocol/spec",
"!docs/design/async-origin-capture"
"!docs/design/async-origin-capture/**"
]
},
"formatter": {
Expand Down
231 changes: 175 additions & 56 deletions docs/design/async-origin-capture.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/design/async-origin-capture/bench.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRequire } from 'node:module';
import http from 'node:http';
const require = createRequire('/Volumes/External2TB/Projects/Bugsee/javascript/packages/instrumentation-tests/package.json');
const require = createRequire(new URL('../../../packages/instrumentation-tests/package.json', import.meta.url));
const pw = require('playwright');
const srv = http.createServer((q,s)=>{s.setHeader('content-type','text/html');s.end('<!doctype html><body></body>');});
await new Promise(r=>srv.listen(0,'127.0.0.1',r));
Expand Down
4 changes: 2 additions & 2 deletions docs/design/async-origin-capture/probe.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRequire } from 'node:module';
import http from 'node:http';
const require = createRequire('/Volumes/External2TB/Projects/Bugsee/javascript/packages/instrumentation-tests/package.json');
const require = createRequire(new URL('../../../packages/instrumentation-tests/package.json', import.meta.url));
const pw = require('playwright');
const srv = http.createServer((q,s)=>{ if(q.url==='/'){s.setHeader('content-type','text/html');s.end('<!doctype html><body></body>');} else if(q.url==='/bad.js'){s.setHeader('content-type','text/javascript');s.end('syntax error here(');} else if(q.url==='/bad.json'){s.setHeader('content-type','application/json');s.end('{oops');} else {s.statusCode=404;s.end('nf');}});
await new Promise(r=>srv.listen(0,'127.0.0.1',r));
Expand Down Expand Up @@ -68,7 +68,7 @@ const PAGE = () => {
}
await sleep(5000);
const seen = new Set(out.map((o) => o.id));
for (const id of started) if (!seen.has(id)) out.push({ id, noUnhandledRejection: true });
for (const id of started) if (!seen.has(id)) out.push({ id, noEventWithin5s: true });
resolve(out);
});
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:mutation": "node scripts/mutation.mjs",
"ci:local": "bash scripts/ci-local.sh",
"spec:refresh": "node scripts/spec-snapshot/refresh.mjs",
"async-origin:refresh": "node scripts/async-origin/refresh.mjs",
"spec:check": "node scripts/spec-snapshot/refresh.mjs --check"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ bugsee({
});
```

## Island hydration errors

Astro catches an island's hydration failure itself and re-dispatches it as a cancelable `astro:hydration-error` event (plus a `console.error`); nothing is rethrown, so the page's error handlers never see it. The browser launch listens for that event and reports the error, without cancelling the event, so Astro's own console output is unchanged. For component errors during rendering, also add the matching island adapter (`@bugsee/react`, `@bugsee/vue`, ...).

## Links

- Repository: https://github.com/bugsee/bugsee-javascript/tree/main/packages/astro
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,11 @@
},
"dependencies": {
"@bugsee/adapter-kit": "workspace:*",
"@bugsee/bugsee": "workspace:*",
"@bugsee/core": "workspace:*",
"@bugsee/protocol": "workspace:*",
"@bugsee/vercel-edge": "workspace:*",
"@bugsee/bugsee": "workspace:*"
"@bugsee/web-adapter": "workspace:*"
},
"devDependencies": {
"@bugsee/node": "workspace:*",
Expand Down
127 changes: 122 additions & 5 deletions packages/astro/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { launch } = vi.hoisted(() => ({ launch: vi.fn() }));
vi.mock('@bugsee/bugsee', () => ({ launch }));
const { launch, relaunchUmbrella, listen, flush, order } = vi.hoisted(() => ({
launch: vi.fn(),
relaunchUmbrella: vi.fn(),
listen: vi.fn(),
flush: vi.fn(),
order: [] as string[],
}));
vi.mock('@bugsee/bugsee', () => ({ launch, relaunch: relaunchUmbrella }));
vi.mock('./hydration', () => ({
listenForAstroHydrationErrors: listen,
flushQueuedAstroHydrationErrors: flush,
}));

import { registerClient } from './client';
// The client module remembers the last registration's hydration options (like core's relaunch keeps them), so each test gets a fresh one.
let registerClient: typeof import('./client').registerClient;
let relaunch: typeof import('./client').relaunch;

describe('registerClient', () => {
afterEach(() => launch.mockReset());
beforeEach(async () => {
vi.resetModules();
({ registerClient, relaunch } = await import('./client'));
});

afterEach(() => {
launch.mockReset();
relaunchUmbrella.mockReset();
listen.mockReset();
flush.mockReset();
order.length = 0;
});

it('launches the browser SDK with the appToken + options, returns the client', async () => {
const client = { id: 'browser' };
Expand All @@ -19,4 +42,98 @@ describe('registerClient', () => {
await registerClient('tok');
expect(launch).toHaveBeenCalledWith('tok', {});
});

it('listens BEFORE the launch (an island error during launch is queued) and flushes the queue once the client exists', async () => {
listen.mockImplementation(() => order.push('listen'));
launch.mockImplementation(async () => {
order.push('launch');
return {};
});
flush.mockImplementation(() => order.push('flush'));
await registerClient('tok');
expect(order).toEqual(['listen', 'launch', 'flush']);
});

it('relaunch() is wrapped in the same window: listen first, flush in a finally (the carrier client is cleared during the restart)', async () => {
listen.mockImplementation(() => order.push('listen'));
relaunchUmbrella.mockImplementation(async () => {
order.push('relaunch');
return {};
});
flush.mockImplementation(() => order.push('flush'));
await relaunch({ replay: false });
expect(order).toEqual(['listen', 'relaunch', 'flush']);
expect(relaunchUmbrella).toHaveBeenCalledWith({ replay: false });
relaunchUmbrella.mockRejectedValue(new Error('relaunch failed'));
order.length = 0;
await expect(relaunch()).rejects.toThrow('relaunch failed');
expect(order).toEqual(['listen', 'flush']);
});

it("hands the page document the launch uses (an explicit one, else the bound window's) to the hydration listener as its target", async () => {
launch.mockResolvedValue({});
const doc = {} as Document;
await registerClient('tok', { document: doc } as never);
expect(listen).toHaveBeenLastCalledWith({ target: doc });
const windowDoc = {} as Document;
await registerClient('tok', { window: { document: windowDoc } } as never);
expect(listen).toHaveBeenLastCalledWith({ target: windowDoc });
const hostile = {
get document(): never {
throw new Error('sandboxed');
},
};
await registerClient('tok', { window: hostile } as never);
// nothing selectable this time: the earlier registration's document is KEPT (core's relaunch keeps `document` and `window` too)
expect(listen).toHaveBeenLastCalledWith({ target: windowDoc });
});

it('a plain relaunch() keeps the earlier document, carrier and onError (core keeps them), and an explicit one wins', async () => {
const doc = {} as Document;
const carrier = {};
const onError = vi.fn();
launch.mockResolvedValue({});
relaunchUmbrella.mockResolvedValue({});
await registerClient('tok', { document: doc, carrier, onError } as never);
await relaunch();
expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier, onError });
expect(flush).toHaveBeenLastCalledWith({ target: doc, carrier, onError });
const other = {} as Document;
await relaunch({ document: other } as never);
expect(listen).toHaveBeenLastCalledWith({ target: other, carrier, onError });
});

it("a plain relaunch() after a bound WINDOW listens and flushes on that window's document (the same EventTarget as registerClient)", async () => {
const windowDoc = {} as Document;
launch.mockResolvedValue({});
relaunchUmbrella.mockResolvedValue({});
await registerClient('tok', { window: { document: windowDoc } } as never);
await relaunch({ replay: false });
expect(listen).toHaveBeenLastCalledWith({ target: windowDoc });
expect(flush).toHaveBeenLastCalledWith({ target: windowDoc });
});

it('hands a custom carrier to the hydration listener, so it looks where the client lives', async () => {
const carrier = {};
launch.mockResolvedValue({});
await registerClient('tok', { carrier } as never);
expect(listen).toHaveBeenCalledWith({ carrier });
expect(flush).toHaveBeenCalledWith({ carrier });
});

it('ends the queueing window even when the launch fails, and still rejects with the launch error', async () => {
const failure = new Error('launch failed');
launch.mockRejectedValue(failure);
await expect(registerClient('tok')).rejects.toBe(failure);
expect(flush).toHaveBeenCalledTimes(1);
});

it('passes the onError sink to the hydration listener, and keeps it for a later call that gives none (core keeps onError)', async () => {
const onError = vi.fn();
await registerClient('tok', { onError });
expect(listen).toHaveBeenCalledTimes(1);
expect(listen).toHaveBeenCalledWith({ onError });
await registerClient('tok');
expect(listen).toHaveBeenLastCalledWith({ onError });
});
});
79 changes: 76 additions & 3 deletions packages/astro/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,77 @@
// a UI renderer). The Integration injects `injectScript('page', "import { registerClient } … registerClient(…)")`.
//
// Browser-only → behind the `@bugsee/astro/client` subpath, never the portable `.`/`./middleware` entries.
import { type Bugsee, type BugseeLaunchOptionsWithPerformance, launch } from '@bugsee/bugsee';
import {
type Bugsee,
type BugseeLaunchOptionsWithPerformance,
launch,
relaunch as umbrellaRelaunch,
} from '@bugsee/bugsee';
import { flushQueuedAstroHydrationErrors, listenForAstroHydrationErrors } from './hydration';

/** Options for the Astro client (browser) composition — the batteries-included browser umbrella options. */
export interface AstroClientOptions extends BugseeLaunchOptionsWithPerformance {}

// The hydration listener's options: where to report a failing sink, and which carrier the client lives on (a custom one is not on `globalThis`).
const hydrationOptions = (
options:
| {
onError?: (error: unknown) => void;
carrier?: object;
document?: Document;
window?: unknown;
}
| undefined,
) => {
// The page document the launch itself uses: an explicit `document`, else the bound window's. Omitted when neither yields one, which
// keeps the listener's default (`globalThis.document`). A hostile `window.document` getter (a sandboxed frame) counts as none.
let target: Document | undefined = options?.document;
if (target === undefined) {
try {
target = (options?.window as { document?: Document } | undefined)?.document;
} catch {
target = undefined;
}
}
return {
...(options?.onError !== undefined ? { onError: options.onError } : {}),
...(options?.carrier !== undefined ? { carrier: options.carrier } : {}),
...(target !== undefined ? { target } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
};
};

// The hydration options of the last registration (see `withHydrationWindow`).
let lastHydration: ReturnType<typeof hydrationOptions> = {};

/**
* Open the hydration listener's launch window, run `start`, and close the window in a `finally`: an error dispatched while the client is
* (re)starting is queued, and the flush runs whether the start succeeded or failed.
*/
async function withHydrationWindow<T>(
options:
| {
onError?: (error: unknown) => void;
carrier?: object;
document?: Document;
window?: unknown;
}
| undefined,
start: () => Promise<T>,
): Promise<T> {
// Like core's `relaunchWith`, which keeps `document`, `window`, `carrier` and `onError` from the previous launch and lets an explicit one
// win: a plain `relaunch()` must watch the same document, on the same carrier, with the same sink.
const hydration = { ...lastHydration, ...hydrationOptions(options) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 Low

Problem: The merge happens on the resolved target, after document and window have already been reduced to one value. Core merges the raw document and window keys first (both are in RELAUNCH_KEPT_OPTIONS). Browser launch.ts then picks options.document ?? window.document, so a kept document beats a new window. Here a new window replaces the kept target.

Scenario: registerClient(t, { document: A }) then relaunch({ window: W }).

  • Core relaunches with { document: A, window: W }, so the client uses document A.
  • hydrationOptions({ window: W }) returns { target: W.document }, which overrides lastHydration.target = A.
  • The listener for the new launch window is installed on W.document. The A listener is not reopened (its pending stays 0), so a hydration error on A during the relaunch is dropped. W.document gets a second listener.

Impact: This is the same class as the case 57bbfb3b fixes, but narrower: it needs an explicit document first and an explicit window on the relaunch. It loses only the errors raised during the relaunch window.

Fix: Remember the raw { document, window, carrier, onError } from the last registration. Merge the new options over them, then call hydrationOptions on the merged object, so the precedence matches launch.ts. Add a test: registerClient({ document: A }) then relaunch({ window: { document: B } }) must listen on A.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and deferred as a follow-up: the scenario needs an explicit document on the registration and an explicit window on a later relaunch, and it loses only hydration errors raised during that one relaunch window. The fix is as described (remember the raw { document, window, carrier, onError }, merge, then resolve the target with launch's precedence). The PR was merged without it at the owner's request.

lastHydration = hydration;
// Astro swallows an island's hydration failure into an `astro:hydration-error` event; nothing else would see it. Listen BEFORE the
// launch resolves (an error dispatched meanwhile is queued) and flush once the client exists.
listenForAstroHydrationErrors(hydration);
try {
return await start();
} finally {
flushQueuedAstroHydrationErrors(hydration); // success: report what queued; failure: end the window, nothing to report to
}
}

/**
* Start Bugsee for the Astro client (browser). Returns the started client (a per-isolate singleton — a
* repeat call returns the existing client).
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Expand All @@ -17,7 +83,15 @@ export async function registerClient(
appToken: string,
options: AstroClientOptions = {},
): Promise<Bugsee> {
return await launch(appToken, options);
return await withHydrationWindow(options, () => launch(appToken, options));
}

/**
* `relaunch` from the umbrella, inside the same window: it `stop()`s the running client (clearing the carrier slot) and waits out the
* drain before the new one is set, and an island that fails to hydrate meanwhile must not be lost.
*/
export async function relaunch(options?: Parameters<typeof umbrellaRelaunch>[0]): Promise<Bugsee> {
return await withHydrationWindow(options, () => umbrellaRelaunch(options));
Comment thread
krassx marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
krassx marked this conversation as resolved.
Resolved
}

// API parity with the umbrella entries: the same VALUE surface, thin re-exports of the same functions.
Expand All @@ -30,5 +104,4 @@ export {
type LifecycleEventsListener,
type LifecycleEventType,
type ReportAttachmentInput,
relaunch,
} from '@bugsee/bugsee';
Loading
Loading