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
5 changes: 5 additions & 0 deletions .changeset/browser-capture-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/observability': minor
---

Browser capture MVP. Wires up `window.onerror` + `unhandledrejection` global handlers, optional `console.error` tap, `fetch` + navigation breadcrumb wrappers, batched `fetch` transport with `navigator.sendBeacon` flush on `pagehide`/`visibilitychange`, PII scrubbing (Bearer tokens, password/token/api-key params, OpenAI-style `sk-...` keys, sensitive headers), and an engine-agnostic V8 + Spidermonkey stack parser. `Client.init` now auto-installs everything when called from the browser entry. SDK-internal frames are stripped from captured stacks. `Error.cause` chains are walked into the exception envelope.
23 changes: 23 additions & 0 deletions packages/core/src/__tests__/pii.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { scrubHeaders, scrubString } from '../pii';

describe('scrubString', () => {
it('redacts Bearer tokens', () => {
expect(scrubString('Authorization: Bearer abc.def.ghi')).toBe('Authorization: Bearer [redacted]');
});
it('redacts password=', () => {
expect(scrubString('?password=hunter2&x=1')).toBe('?password=[redacted]&x=1');
});
it('redacts sk-... API keys', () => {
expect(scrubString('key=sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toContain('sk-[redacted]');
});
});

describe('scrubHeaders', () => {
it('redacts known sensitive headers', () => {
const out = scrubHeaders({ authorization: 'Bearer abc', 'x-api-key': '12345', accept: 'application/json' })!;
expect(out.authorization).toBe('[redacted]');
expect(out['x-api-key']).toBe('[redacted]');
expect(out.accept).toBe('application/json');
});
});
40 changes: 40 additions & 0 deletions packages/core/src/__tests__/stack-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { parseStack } from '../stack-parser';

describe('parseStack', () => {
it('parses a V8 (Chrome) stack', () => {
const stack = [
"TypeError: Cannot read properties of undefined (reading 'call')",
' at s (webpack-766ccbbf0ad1fc08.js:1:161)',
' at c (90656-ecba1b78b94a6f78.js:32:21693)',
' at L (90656-ecba1b78b94a6f78.js:32:25222)',
].join('\n');
const frames = parseStack(stack);
expect(frames).toHaveLength(3);
expect(frames[0]).toMatchObject({ function: 's', module: 'webpack-766ccbbf0ad1fc08.js', lineno: 1, colno: 161, inApp: true });
});

it('parses a Spidermonkey (Firefox) stack', () => {
const stack = ['fn@http://localhost/app.js:42:7', 'doWork@http://localhost/app.js:100:1'].join('\n');
const frames = parseStack(stack);
expect(frames).toHaveLength(2);
expect(frames[0]).toMatchObject({ function: 'fn' });
});

it('returns empty for missing stack', () => {
expect(parseStack(undefined)).toEqual([]);
expect(parseStack('')).toEqual([]);
});

it('flags node_modules frames as non-app', () => {
const stack = ' at Object.foo (/app/node_modules/react/index.js:1:2)';
const frames = parseStack(stack);
expect(frames[0]).toMatchObject({ inApp: false });
});

it('skips leading "Error: ..." header', () => {
const stack = ['Error: boom', ' at fn (file.js:1:1)'].join('\n');
const frames = parseStack(stack);
expect(frames).toHaveLength(1);
});
});
68 changes: 68 additions & 0 deletions packages/core/src/__tests__/transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Transport } from '../transport';
import type { ObservabilityEvent } from '../types';

function evt(id: string): ObservabilityEvent {
return {
eventId: id,
timestamp: Date.now(),
level: 'error',
sdk: { name: '@smooai/observability', version: '0.1.0', runtime: 'browser' },
};
}

describe('Transport', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
fetchMock.mockReset();
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.useRealTimers();
});

it('batches up to maxBatchSize and flushes immediately when full', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 202 });
const t = new Transport({ dsn: 'https://example.com/ingest', maxBatchSize: 3, flushIntervalMs: 1000 }, { canBeacon: false });
t.enqueue(evt('a'));
t.enqueue(evt('b'));
expect(fetchMock).not.toHaveBeenCalled();
t.enqueue(evt('c'));
// Triggered immediately by max-batch
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://example.com/ingest');
const body = JSON.parse(init.body);
expect(body.type).toBe('error');
expect(body.events).toHaveLength(3);
});

it('flushes on timer when batch is not yet full', async () => {
fetchMock.mockResolvedValue({ ok: true });
const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 10, flushIntervalMs: 500 }, { canBeacon: false });
t.enqueue(evt('a'));
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(fetchMock).toHaveBeenCalledOnce();
});

it('drops oldest events when queue overflows', () => {
const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 100, flushIntervalMs: 1000, maxQueueSize: 2 }, { canBeacon: false });
t.enqueue(evt('a'));
t.enqueue(evt('b'));
t.enqueue(evt('c'));
expect(t._queueSize()).toBe(2);
});

it('flushBeacon uses navigator.sendBeacon when available', () => {
const beacon = vi.fn().mockReturnValue(true);
const t = new Transport({ dsn: 'https://example.com', maxBatchSize: 100, flushIntervalMs: 1000 }, { canBeacon: true, beacon });
t.enqueue(evt('a'));
t.flushBeacon();
expect(beacon).toHaveBeenCalledOnce();
expect(t._queueSize()).toBe(0);
});
});
73 changes: 73 additions & 0 deletions packages/core/src/browser/breadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Client } from '../client';
import { scrubString } from '../pii';

/**
* Install passive breadcrumb wrappers for fetch + navigation. They never throw
* into user code; failures are swallowed.
*/
let fetchInstalled = false;
let navInstalled = false;

export function installFetchBreadcrumbs(): void {
if (fetchInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
fetchInstalled = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async function smooFetch(input: RequestInfo | URL, init?: RequestInit) {
const started = Date.now();
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
try {
const res = await originalFetch(input as RequestInfo, init);
Client.addBreadcrumb(
'fetch',
`${method} ${scrubString(url)} ${res.status}`,
{
method,
url: scrubString(url),
status: res.status,
duration_ms: Date.now() - started,
},
res.ok ? 'info' : 'warning',
);
return res;
} catch (err) {
Client.addBreadcrumb(
'fetch',
`${method} ${scrubString(url)} threw`,
{
method,
url: scrubString(url),
error: err instanceof Error ? err.message : String(err),
duration_ms: Date.now() - started,
},
'error',
);
throw err;
}
} as typeof window.fetch;
}

export function installNavigationBreadcrumbs(): void {
if (navInstalled || typeof window === 'undefined') return;
navInstalled = true;

const pushBreadcrumb = (kind: string, to: string) => {
Client.addBreadcrumb('navigation', `${kind} → ${to}`, { kind, to });
};

// history.pushState / replaceState
const wrap = <K extends 'pushState' | 'replaceState'>(name: K) => {
const original = history[name];
history[name] = function smooHistory(this: History, ...args: Parameters<History[K]>) {
const result = (original as (...a: unknown[]) => unknown).apply(this, args);
const url = String(args[2] ?? window.location.href);
pushBreadcrumb(name, url);
return result;
} as History[K];
};
wrap('pushState');
wrap('replaceState');

window.addEventListener('popstate', () => pushBreadcrumb('popstate', window.location.href));
window.addEventListener('hashchange', () => pushBreadcrumb('hashchange', window.location.href));
}
27 changes: 27 additions & 0 deletions packages/core/src/browser/console-tap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Client } from '../client';

let installed = false;

/**
* Optional tap on `console.error`. Captures the first argument (string or
* Error) as a level=error event. Disabled by default — turn on with
* `Client.init({ autoInstrumentation: true })`.
*/
export function installConsoleErrorTap(): void {
if (installed || typeof console === 'undefined') return;
installed = true;
const original = console.error.bind(console);
console.error = (...args: unknown[]) => {
try {
const first = args[0];
if (first instanceof Error) {
Client.captureException(first, { tags: { source: 'console.error' } });
} else if (typeof first === 'string') {
Client.captureMessage(first, 'error');
}
} catch {
/* swallow */
}
return original(...args);
};
}
50 changes: 50 additions & 0 deletions packages/core/src/browser/global-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Client } from '../client';

/**
* Register browser-side global error capture: `window.onerror` and
* `window.onunhandledrejection`. Composes with any existing handlers so we
* don't clobber app code or other SDKs.
*
* Idempotent — calling twice is a no-op.
*/
let installed = false;

export function registerBrowserGlobalHandlers(): void {
if (installed || typeof window === 'undefined') return;
installed = true;

const prevOnError = window.onerror;
window.onerror = function smooErrorHandler(message, source, lineno, colno, error) {
try {
const err = error instanceof Error ? error : new Error(typeof message === 'string' ? message : 'window.onerror');
Client.captureException(err, {
tags: {
source: 'window.onerror',
...(source ? { file: String(source) } : {}),
...(lineno ? { lineno: String(lineno) } : {}),
...(colno ? { colno: String(colno) } : {}),
},
});
} catch {
/* swallow — observability must not throw */
}
if (typeof prevOnError === 'function') {
return (prevOnError as OnErrorEventHandlerNonNull).call(window, message, source, lineno, colno, error);
}
return false;
};

const prevOnUnhandled = window.onunhandledrejection;
window.onunhandledrejection = function smooRejectionHandler(event) {
try {
const reason = (event as PromiseRejectionEvent).reason;
const err = reason instanceof Error ? reason : new Error(typeof reason === 'string' ? reason : 'unhandledrejection');
Client.captureException(err, { tags: { source: 'unhandledrejection' } });
} catch {
/* swallow */
}
if (typeof prevOnUnhandled === 'function') {
return (prevOnUnhandled as (this: Window, ev: PromiseRejectionEvent) => unknown).call(window, event as PromiseRejectionEvent);
}
};
}
45 changes: 32 additions & 13 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
/**
* Browser entry — registers global capture handlers and a beacon-aware
* batched HTTP transport.
*
* This file is the integration surface. The capture handlers themselves and
* the breadcrumb wrappers live in sibling files and are wired here.
* Browser entry point. Side-effect: when consumed via the package `exports`
* map browser condition, the universal `Client.init` resolves the browser
* runtime (we wire that here lazily, NOT at import time, to keep tree-shaking
* intact for users who import only types).
*/
import { Client } from '../client';
import { installFetchBreadcrumbs, installNavigationBreadcrumbs } from './breadcrumbs';
import { installConsoleErrorTap } from './console-tap';
import { registerBrowserGlobalHandlers } from './global-handlers';
import { makeBrowserTransport } from './transport';

export { Client, Scope, withScope, getCurrentScope } from '../index';
export * from '../types';
export { parseStack } from '../stack-parser';
export { registerBrowserGlobalHandlers } from './global-handlers';
export { installFetchBreadcrumbs, installNavigationBreadcrumbs } from './breadcrumbs';
export { installConsoleErrorTap } from './console-tap';
export { makeBrowserTransport } from './transport';

// TODO (SMOODEV-1067 follow-ups):
// - registerBrowserGlobalHandlers() → window.onerror, unhandledrejection, console.error tap
// - installFetchBreadcrumbs() → wrap window.fetch + XHR
// - installNavigationBreadcrumbs() → history.pushState, popstate, hashchange
// - installClickBreadcrumbs() → document.addEventListener('click', ...)
// - browserStackParser() → Chrome/Firefox/Safari format normalization
// - browserTransport() → batched fetch + sendBeacon on pagehide
// - registerOfflineQueue() → IndexedDB-backed queue with retry on focus
// Auto-wire on init. The Client.init implementation lives in core/client.ts
// and stores options; we hook into it here by re-defining the init behavior
// to register integrations after the first call.
const originalInit = Client.init.bind(Client);
Client.init = (options) => {
originalInit(options);
if (options.autoInstrumentation !== false) {
registerBrowserGlobalHandlers();
installFetchBreadcrumbs();
installNavigationBreadcrumbs();
// Console tap is opt-in via explicit autoInstrumentation flag.
}
const transport = makeBrowserTransport(options);
Client._registerTransport(async (batch) => {
for (const evt of batch) transport.enqueue(evt);
});
};
30 changes: 30 additions & 0 deletions packages/core/src/browser/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Transport } from '../transport';
import type { ClientOptions } from '../types';

/**
* Build a browser-flavored Transport: keepalive fetch, `sendBeacon` on
* `pagehide`, bound to `visibilitychange` for the modern browser-lifecycle path.
*/
export function makeBrowserTransport(opts: ClientOptions): Transport {
const adapter = {
canBeacon: typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function',
beacon: typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ? navigator.sendBeacon.bind(navigator) : undefined,
bindLifecycle: (onPageHide: () => void) => {
if (typeof window === 'undefined') return;
// `pagehide` is the modern unload event; visibilitychange is the bfcache-friendly path.
window.addEventListener('pagehide', onPageHide, { capture: true });
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') onPageHide();
});
},
};
return new Transport(
{
dsn: opts.dsn,
flushIntervalMs: opts.flushIntervalMs,
maxBatchSize: opts.maxBatchSize,
maxQueueSize: opts.maxQueueSize,
},
adapter,
);
}
Loading
Loading