Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!samples", "!.local-registry", "!packages/protocol/spec"]
"includes": [
"**",
"!samples",
"!.local-registry",
"!packages/protocol/spec",
"!docs/design/async-origin-capture/**"
]
},
"formatter": {
"enabled": true,
Expand Down
589 changes: 589 additions & 0 deletions docs/design/async-origin-capture.md

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions docs/design/async-origin-capture/bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createRequire } from 'node:module';
import http from 'node:http';
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));
const PAGE = async () => {
const N = 20000, ROUNDS = 5;
const data = new Uint8Array(16);
const native = (...a) => crypto.subtle.digest('SHA-1', data); // cheap real async platform call
const origins = new WeakMap();
const isThenable = (p) => p !== null && typeof p === 'object' && typeof p.then === 'function';
// ONE generic factory: every patched method goes through it
const make = (original, { capture = true } = {}) => function (...args) {
const origin = capture ? new Error() : undefined;
const p = original.apply(this, args);
if (!isThenable(p)) return p;
return p.then(undefined, (r) => { if (origin && r !== null && typeof r === 'object') origins.set(r, origin); throw r; });
};
const orig = (a) => crypto.subtle.digest('SHA-1', a);
const variants = {
'native call': () => orig(data),
'wrapper: apply+then, NO Error': make(orig, { capture: false }),
'wrapper: apply+then+new Error() (proposed)': make(orig),
};
// hand-specialised (no rest args, no factory) to show the factory itself costs nothing
const special = function (a) { const o = new Error(); const p = orig(a); return p.then(undefined, (r) => { origins.set(r, o); throw r; }); };
variants['hand-written specialised wrapper (+Error)'] = () => special(data);
const res = {};
for (const [name, fn] of Object.entries(variants)) {
const times = [];
for (let k = 0; k < ROUNDS; k++) {
const t0 = performance.now();
const ps = new Array(N);
for (let i = 0; i < N; i++) ps[i] = name === 'native call' ? orig(data) : fn(data);
await Promise.all(ps);
times.push((performance.now() - t0) / N * 1000);
}
times.sort((a, b) => a - b);
res[name] = +times[Math.floor(ROUNDS / 2)].toFixed(2) + ' us/call (median of ' + ROUNDS + ')';
}
// cost of Error capture alone, and of reading .stack (only paid on rejection)
let t0 = performance.now(); let keep; for (let i = 0; i < 200000; i++) keep = new Error(); res['new Error() alone'] = ((performance.now() - t0) / 200000 * 1000).toFixed(2) + ' us';
t0 = performance.now(); let n = 0; for (let i = 0; i < 20000; i++) n += new Error().stack.length; res['new Error().stack (format) alone'] = ((performance.now() - t0) / 20000 * 1000).toFixed(2) + ' us';
return res;
};
for (const [name, exe] of [['chromium', null], ['firefox', process.env.PW_firefox]]) {
let b; try {
b = await pw[name].launch({ headless: true, ...(exe ? { executablePath: exe } : {}) });
const p = await b.newPage(); await p.goto('http://localhost:' + srv.address().port + '/');
console.log('=== ' + name + ' ' + b.version()); console.log(JSON.stringify(await p.evaluate(`(${PAGE.toString()})()`), null, 1));
} catch (e) { console.log(name, 'FAILED', String(e).slice(0, 150)); } finally { await b?.close(); }
}
srv.close();
90 changes: 90 additions & 0 deletions docs/design/async-origin-capture/probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createRequire } from 'node:module';
import http from 'node:http';
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));
const base='http://localhost:'+srv.address().port;
const PAGE = () => {
const out = [];
const pend = new Map();
window.addEventListener('unhandledrejection', (ev) => {
const id = pend.get(ev.promise); if (!id) return; pend.delete(ev.promise);
const r = ev.reason; const st = r && typeof r === 'object' ? r.stack : undefined;
const frames = typeof st === 'string' ? st.split('\n').filter(l => /^\s*at\s|@|:\d+:\d+/.test(l)).length : 0;
out.push({ id, kind: r === null ? 'null' : typeof r, name: r && r.name, msg: String(r && r.message !== undefined ? r.message : r).slice(0, 70), hasStackProp: typeof st === 'string', frames, mentionsCallsite: typeof st === 'string' && st.includes('callsite') , dom: typeof DOMException !== 'undefined' && r instanceof DOMException});
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cases = {
'user: async fn throws Error': async function callsite() { throw new Error('x'); },
'user: Promise.reject(Error)': function callsite() { return Promise.reject(new Error('x')); },
'user: new Promise rej Error': function callsite() { return new Promise((_, rej) => setTimeout(() => rej(new Error('x')), 5)); },
'user: Promise.reject("str")': function callsite() { return Promise.reject('str'); },
'user: Promise.reject(undefined)': function callsite() { return Promise.reject(); },
'user: Promise.reject({obj})': function callsite() { return Promise.reject({ code: 1 }); },
'user: await then throw': async function callsite() { await sleep(5); throw new TypeError('late'); },
'media.play (bad src)': async function callsite() { const v = document.createElement('video'); v.src = 'data:video/mp4;base64,AAAA'; await new Promise((r) => { v.addEventListener('error', r, { once: true }); setTimeout(r, 1500); }); return v.play(); },
'clipboard.readText': function callsite() { return navigator.clipboard.readText(); },
'clipboard.writeText': function callsite() { return navigator.clipboard.writeText('x'); },
'requestFullscreen': function callsite() { return document.documentElement.requestFullscreen(); },
'getUserMedia(bad constraints)': function callsite() { return navigator.mediaDevices.getUserMedia({}); },
'getUserMedia(video)': function callsite() { return navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: 'nope' } } }); },
'permissions.query(bogus)': function callsite() { return navigator.permissions.query({ name: 'bogus' }); },
'fetch(refused)': function callsite() { return fetch('http://127.0.0.1:1/x'); },
'fetch(bad url)': function callsite() { return fetch('http://[bad'); },
'fetch(cors)': function callsite() { return fetch('http://127.0.0.1:1/x', { mode: 'cors' }); },
'response.json(bad)': async function callsite() { const r = await fetch('/bad.json'); return r.json(); },
'Image.decode(bad)': function callsite() { const i = new Image(); i.src = 'data:image/png;base64,AAAA'; return i.decode(); },
'createImageBitmap(bad)': function callsite() { return createImageBitmap(new Blob(['x'])); },
'subtle.digest(bogus)': function callsite() { return crypto.subtle.digest('bogus', new Uint8Array(1)); },
'subtle.importKey(bad)': function callsite() { return crypto.subtle.importKey('raw', new Uint8Array(1), 'AES-GCM', false, ['encrypt']); },
'serviceWorker.register(404)': function callsite() { return navigator.serviceWorker.register('/nope.js'); },
'navigator.share({})': function callsite() { return navigator.share({}); },
'orientation.lock': function callsite() { return screen.orientation.lock('portrait'); },
'wakeLock.request': function callsite() { return navigator.wakeLock.request('screen'); },
'FontFace.load(bad)': function callsite() { return new FontFace('x', 'url(/nope.woff)').load(); },
'decodeAudioData(bad)': function callsite() { return new AudioContext().decodeAudioData(new ArrayBuffer(8)); },
'RTC.setRemoteDescription(bad)': function callsite() { return new RTCPeerConnection().setRemoteDescription({ type: 'answer', sdp: 'x' }); },
'RTC.addIceCandidate(bad)': function callsite() { return new RTCPeerConnection().addIceCandidate({ candidate: 'x', sdpMid: 'zz' }); },
'import(404)': function callsite() { return import('/nope.mjs'); },
'import(syntax err)': function callsite() { return import('/bad.js'); },
'WebAssembly.compile(bad)': function callsite() { return WebAssembly.compile(new Uint8Array([0, 1, 2])); },
'WebAssembly.instantiate(bad)': function callsite() { return WebAssembly.instantiate(new Uint8Array([0, 97, 115, 109, 9, 0, 0, 0])); },
'OffscreenCanvas.convertToBlob(0)': function callsite() { return new OffscreenCanvas(0, 0).convertToBlob(); },
'storage.getDirectory+bad': async function callsite() { const d = await navigator.storage.getDirectory(); return d.getFileHandle('a/b'); },
'caches.open("")+match': function callsite() { return caches.match(new Request('/x'), { cacheName: 1n }); },
'document.fonts.load(bad)': function callsite() { return document.fonts.load('???'); },
'Notification.requestPermission': function callsite() { return Notification.requestPermission().then(() => { throw new Error('after'); }); },
'ReadableStream cancel err': function callsite() { return new ReadableStream({ cancel() { throw new Error('c'); } }).cancel(); },
'Blob.text on revoked? stream': function callsite() { const rs = new ReadableStream({ start(c) { c.error(new Error('e')); } }); return rs.getReader().read(); },
'IDB promisified (event err)': function callsite() { return new Promise((_, rej) => { const q = indexedDB.open('x', 0); q.onerror = () => rej(q.error); }); },
'CSS paintWorklet.addModule(404)': function callsite() { return CSS.paintWorklet.addModule('/nope.js'); },
'structuredClone via postMessage': function callsite() { return Promise.resolve().then(() => structuredClone(() => {})); },
};
return new Promise(async (resolve) => {
const started = [];
for (const [id, fn] of Object.entries(cases)) {
try { const p = fn(); pend.set(p, id); started.push(id); } catch (e) { out.push({ id, syncThrow: true, name: e.name, frames: (e.stack||'').split('\n').length }); }
}
await sleep(5000);
const seen = new Set(out.map((o) => o.id));
for (const id of started) if (!seen.has(id)) out.push({ id, noEventWithin5s: true });
resolve(out);
});
};
const only = process.argv[2];
for (const name of ['chromium', 'firefox', 'webkit']) {
if (only && only !== name) continue;
let b;
try {
b = await pw[name].launch({ headless: true, ...(process.env['PW_'+name] ? { executablePath: process.env['PW_'+name] } : {}) });
const ctx = await b.newContext({ permissions: [] });
const p = await ctx.newPage();
await p.goto(base + '/');
const res = await p.evaluate(`(${PAGE.toString()})()`);
console.log('=== ' + name + ' ' + b.version());
for (const r of res) console.log(JSON.stringify(r));
} catch (e) { console.log('=== ' + name + ' FAILED ' + String(e).slice(0, 200)); }
finally { await b?.close(); }
}
srv.close();
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 });
});
});
Loading
Loading