diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e0d2f3ef..b0be2f4a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,6 +2,12 @@ // Browser platform: fetch transport, IndexedDB storage, window error handlers, stack-trace parser, // and the launch() composition root. Tier 2. See docs/design/sdk-design.md §5. +// Lifecycle events: the event-name constants + listener type for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name'; export { createUnhandledRejectionProvider, diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 15caa2e2..1e1908d0 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -746,6 +746,7 @@ export async function launchCore( setUserIdentifier: (email) => client.setUserIdentifier(email), clearUserIdentifier: () => client.clearUserIdentifier(), sdkVersion: SDK_VERSION, + emitLifecycleEvent: (eventType, data) => client.emitLifecycleEvent(eventType, data), ui: { summaryRequired: reportUi.get(BugseeOption.ReportingUiSummaryRequired, false), descriptionRequired: reportUi.get(BugseeOption.ReportingUiDescriptionRequired, false), diff --git a/packages/browser/src/report-dialog.launch.test.ts b/packages/browser/src/report-dialog.launch.test.ts index 911d8cb8..b5209946 100644 --- a/packages/browser/src/report-dialog.launch.test.ts +++ b/packages/browser/src/report-dialog.launch.test.ts @@ -120,6 +120,29 @@ describe('client.showReportDialog (launched browser client)', () => { expect(request.type).toBe('bug'); }); + it('drives the lifecycle listener end to end: dialog shown, report assembled, uploaded, dialog gone', async () => { + const { client } = await start(); + const seen: string[] = []; + client.setLifecycleEventsListener((type) => + seen.push(type.replace('com.bugsee.lifecycle.', '')), + ); + const pending = client.showReportDialog(); + fill('summary', 'x'); + dialog()?.querySelector('[data-action="send"]')?.click(); + await pending; + + expect(seen).toEqual([ + 'BeforeReportShown', + 'BeforeReportAssembled', + 'AfterReportAssembled', + 'BeforeReportUploaded', + 'AfterReportUploaded', + 'AfterReportShown', + ]); + await client.stop(); + expect(seen.slice(-2)).toEqual(['Stopping', 'Stopped']); + }); + it('honours the reporting-UI launch options', async () => { const { client, http } = await start({ reportSummaryRequired: true, diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 6cae73ed..24d0e76b 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -1,5 +1,10 @@ // @vitest-environment jsdom -import type { PreparedUpload, UploadOptions, UploadResult } from '@bugsee/core'; +import { + LifecycleEvents, + type PreparedUpload, + type UploadOptions, + type UploadResult, +} from '@bugsee/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createReportDialogLauncher, @@ -1079,3 +1084,46 @@ describe('strings overrides', () => { expect(field('summary').placeholder).toBe('Short summary'); }); }); + +describe('lifecycle events', () => { + it('announces BeforeReportShown when the dialog opens and AfterReportShown once it is gone', async () => { + const emit = vi.fn(); + const { show } = harness({ emitLifecycleEvent: emit }); + const pending = show(); + + expect(emit.mock.calls).toEqual([[LifecycleEvents.BeforeReportShown]]); + expect(host()).not.toBeNull(); // Before fired as the dialog went up, not after it was dismissed + + send(); + await pending; + expect(emit.mock.calls).toEqual([ + [LifecycleEvents.BeforeReportShown], + [LifecycleEvents.AfterReportShown, { sent: true }], + ]); + }); + + it('announces AfterReportShown with sent:false when the user discards', async () => { + const emit = vi.fn(); + const { show } = harness({ emitLifecycleEvent: emit }); + const pending = show(); + click('[data-action="cancel"]'); + await pending; + + expect(emit).toHaveBeenLastCalledWith(LifecycleEvents.AfterReportShown, { sent: false }); + expect(emit).toHaveBeenCalledTimes(2); + }); + + it('announces nothing when no dialog is shown (cannot prepare, or SDK not running)', async () => { + const emit = vi.fn(); + await harness({ emitLifecycleEvent: emit, prepare: () => null }).show(); + await harness({ emitLifecycleEvent: emit, isLaunched: () => false }).show(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('works without a lifecycle hook', async () => { + const { show } = harness(); + const pending = show(); + send(); + await expect(pending).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index c3573498..c8d11d53 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -1,4 +1,9 @@ -import type { PreparedUpload, UploadResult } from '@bugsee/core'; +import { + LifecycleEvents, + type LifecycleEventType, + type PreparedUpload, + type UploadResult, +} from '@bugsee/core'; import { DEFAULT_REPORT_DIALOG_STRINGS, type ReportDialogStrings } from './report-dialog-strings'; import { REPORT_DIALOG_CLASS_PREFIX, REPORT_DIALOG_CSS } from './report-dialog-styles'; @@ -90,6 +95,11 @@ export interface ReportDialogDeps { /** Shown in the footer as `v`. */ sdkVersion: string; ui: ReportDialogUiOptions; + /** + * Lifecycle hook (`client.emitLifecycleEvent`): told BeforeReportShown just before the dialog is put on + * the page and AfterReportShown once it is gone, however it closed. Never expected to throw. + */ + emitLifecycleEvent?: (eventType: LifecycleEventType, data?: unknown) => void; } const SVG_NS = 'http://www.w3.org/2000/svg'; @@ -194,6 +204,7 @@ function showDialog( const previousOverflow = doc.documentElement.style.overflow; let busy = false; let closed = false; + let announced = false; const fieldValue = (name: FieldName): string => refs.fields[name]?.value.trim() ?? ''; const severityValue = (): PickerSeverity => @@ -215,6 +226,9 @@ function showDialog( return; } closed = true; + if (announced) { + deps.emitLifecycleEvent?.(LifecycleEvents.AfterReportShown, { sent: result !== null }); + } if (result === null) { prepared.discard(); } @@ -405,6 +419,8 @@ function showDialog( } try { + announced = true; + deps.emitLifecycleEvent?.(LifecycleEvents.BeforeReportShown); doc.documentElement.append(refs.host); doc.documentElement.style.overflow = 'hidden'; const dialog = refs.dialog as HTMLDialogElement; diff --git a/packages/bugsee/src/index.bun.ts b/packages/bugsee/src/index.bun.ts index a3d7cf68..db9c59e0 100644 --- a/packages/bugsee/src/index.bun.ts +++ b/packages/bugsee/src/index.bun.ts @@ -12,5 +12,11 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; export { type BugseeBunLaunchOptions, launch } from './bun'; diff --git a/packages/bugsee/src/index.deno.ts b/packages/bugsee/src/index.deno.ts index e4b8dad9..b05e77b4 100644 --- a/packages/bugsee/src/index.deno.ts +++ b/packages/bugsee/src/index.deno.ts @@ -11,6 +11,12 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; export type { Bugsee } from '@bugsee/deno'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; export { type BugseeDenoLaunchOptions, launch } from './deno'; diff --git a/packages/bugsee/src/index.edge-light.ts b/packages/bugsee/src/index.edge-light.ts index cc642641..edb28195 100644 --- a/packages/bugsee/src/index.edge-light.ts +++ b/packages/bugsee/src/index.edge-light.ts @@ -11,5 +11,11 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; export * from '@bugsee/vercel-edge'; export { launchEdge as launch } from '@bugsee/vercel-edge'; diff --git a/packages/bugsee/src/index.node.ts b/packages/bugsee/src/index.node.ts index e5205bb4..56bec7ea 100644 --- a/packages/bugsee/src/index.node.ts +++ b/packages/bugsee/src/index.node.ts @@ -10,6 +10,12 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; // F-6: the `HttpTransport` primitive (+ its request/response option shapes), so a custom transport (a // proxy, a queueing shim, a test double) passed to the `transport` launch option types without an // unsafe cast. diff --git a/packages/bugsee/src/index.ts b/packages/bugsee/src/index.ts index 029a896e..2506ca4a 100644 --- a/packages/bugsee/src/index.ts +++ b/packages/bugsee/src/index.ts @@ -12,6 +12,12 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; // The SpanProcessor type users receive via `onOtelSpanProcessor` to register on their TracerProvider. export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; // The performance extension's public surface. Re-exported for a reason beyond convenience: diff --git a/packages/bugsee/src/index.worker.ts b/packages/bugsee/src/index.worker.ts index b46088ce..f5d191a6 100644 --- a/packages/bugsee/src/index.worker.ts +++ b/packages/bugsee/src/index.worker.ts @@ -12,4 +12,10 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; export * from '@bugsee/webworker'; diff --git a/packages/bugsee/src/index.workerd.ts b/packages/bugsee/src/index.workerd.ts index 15f11020..faa0ca25 100644 --- a/packages/bugsee/src/index.workerd.ts +++ b/packages/bugsee/src/index.workerd.ts @@ -14,3 +14,9 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; +// Lifecycle events: the listener type + the event-name constants for `setLifecycleEventsListener`. +export { + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from '@bugsee/core'; diff --git a/packages/bugsee/src/lifecycle-exports.test.ts b/packages/bugsee/src/lifecycle-exports.test.ts new file mode 100644 index 00000000..1c012e57 --- /dev/null +++ b/packages/bugsee/src/lifecycle-exports.test.ts @@ -0,0 +1,28 @@ +import { LifecycleEvents } from '@bugsee/core'; +import { describe, expect, it } from 'vitest'; +import * as browserEntry from './index'; +import * as bunEntry from './index.bun'; +import * as denoEntry from './index.deno'; +import * as edgeEntry from './index.edge-light'; +import * as nodeEntry from './index.node'; +import * as workerEntry from './index.worker'; +import * as workerdEntry from './index.workerd'; + +// Every umbrella entry re-exports the lifecycle constants (the same object as core's), so +// `setLifecycleEventsListener` is usable on every runtime without reaching into @bugsee/core. +describe('LifecycleEvents is re-exported from every umbrella entry', () => { + const entries = { + browser: browserEntry, + node: nodeEntry, + bun: bunEntry, + deno: denoEntry, + 'edge-light': edgeEntry, + worker: workerEntry, + workerd: workerdEntry, + }; + for (const [name, entry] of Object.entries(entries)) { + it(name, () => { + expect(entry.LifecycleEvents).toBe(LifecycleEvents); + }); + } +}); diff --git a/packages/bugsee/src/public-api.test.ts b/packages/bugsee/src/public-api.test.ts index b9ef810f..e3e5275a 100644 --- a/packages/bugsee/src/public-api.test.ts +++ b/packages/bugsee/src/public-api.test.ts @@ -6,6 +6,8 @@ import type { AttributeValue, Breadcrumb, BreadcrumbInput, + LifecycleEventsListener, + LifecycleEventType, LogExceptionOptions, ReportDialogOptions, ReportDialogStrings, @@ -33,4 +35,14 @@ describe('@bugsee/bugsee public API surface', () => { const strings = {} as ReportDialogStrings; expect([upload, dialog, strings]).toHaveLength(3); }); + + it('re-exports the lifecycle event constants and listener types', () => { + const listener: LifecycleEventsListener = () => {}; + const type: LifecycleEventType = publicApi.LifecycleEvents.Launched; + expect(type).toBe('com.bugsee.lifecycle.Launched'); + expect(publicApi.LifecycleEvents.AfterReportUploaded).toBe( + 'com.bugsee.lifecycle.AfterReportUploaded', + ); + expect(typeof listener).toBe('function'); + }); }); diff --git a/packages/core/src/client-lifecycle.test.ts b/packages/core/src/client-lifecycle.test.ts new file mode 100644 index 00000000..7728602b --- /dev/null +++ b/packages/core/src/client-lifecycle.test.ts @@ -0,0 +1,336 @@ +import type { EnvironmentEnvelope } from '@bugsee/protocol'; +import { describe, expect, it, vi } from 'vitest'; +import { createClient } from './client'; +import { BugseeError } from './errors'; +import { LifecycleEvents } from './lifecycle-events'; +import type { UploadPipeline, UploadResult } from './transport'; +import type { TriggerPipeline } from './trigger-pipeline'; + +const getEnvironment = (): EnvironmentEnvelope => ({ + platform: { type: 'web', version: '1' }, + runtime: { type: 'web', version: '' }, + sdk: { version: '0', type: 'javascript' }, +}); + +const setup = ( + enqueue: UploadPipeline['enqueue'] = async () => ({ ok: true }), + extra: { onError?: (e: unknown) => void } = {}, +) => { + const uploadPipeline: UploadPipeline = { enqueue, flush: async () => true, drop: vi.fn() }; + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment, ...extra }); + const events: Array<[string, unknown]> = []; + client.setLifecycleEventsListener((type, data) => events.push([type, data])); + const types = (): string[] => events.map(([type]) => type); + return { client, events, types }; +}; + +describe('lifecycle events — launch and stop', () => { + it('emits Launching then Launched around launch()', () => { + const { client, types } = setup(); + client.launch(); + expect(types()).toEqual([LifecycleEvents.Launching, LifecycleEvents.Launched]); + }); + + it('emits Launching BEFORE providers start and Launched AFTER', () => { + const order: string[] = []; + const client = createClient(); + client.setLifecycleEventsListener((type) => order.push(type)); + client.addCaptureProvider({ + name: 'p', + init: () => {}, + start: () => order.push('provider-start'), + stop: () => {}, + }); + client.launch(); + expect(order).toEqual([LifecycleEvents.Launching, 'provider-start', LifecycleEvents.Launched]); + }); + + it('does not emit again for an ignored second launch()', () => { + const { client, types } = setup(); + client.launch(); + client.launch(); + expect(types()).toHaveLength(2); + }); + + it('emits Stopping then Stopped around stop(), Stopped after the drain', async () => { + const { client, types, events } = setup(); + client.launch(); + events.length = 0; + const done = client.stop(); + expect(types()).toEqual([LifecycleEvents.Stopping]); + await done; + expect(types()).toEqual([LifecycleEvents.Stopping, LifecycleEvents.Stopped]); + }); + + it('emits nothing for stop() on a client that is not launched', async () => { + const { client, types } = setup(); + await client.stop(); + expect(types()).toEqual([]); + }); + + it('a throwing listener never breaks launch/stop and is reported to onError', async () => { + const onError = vi.fn(); + const client = createClient({ onError }); + const boom = new Error('listener'); + client.setLifecycleEventsListener(() => { + throw boom; + }); + expect(() => client.launch()).not.toThrow(); + expect(client.isLaunched()).toBe(true); + await expect(client.stop()).resolves.toBe(true); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('null clears the listener', () => { + const { client, types } = setup(); + client.setLifecycleEventsListener(null); + client.launch(); + expect(types()).toEqual([]); + }); +}); + +describe('lifecycle events — report pipeline', () => { + it('emits assembly then upload events with the report id, in order, on success', async () => { + const { client, events, types } = setup(); + await client.logException(new Error('boom')); + expect(types()).toEqual([ + LifecycleEvents.BeforeReportAssembled, + LifecycleEvents.AfterReportAssembled, + LifecycleEvents.BeforeReportUploaded, + LifecycleEvents.AfterReportUploaded, + ]); + const ids = new Set(events.map(([, data]) => (data as { reportId: string }).reportId)); + expect(ids.size).toBe(1); + expect([...ids][0]).toEqual(expect.any(String)); + }); + + it('classifies a permanent refusal as ReportUploadFailed', async () => { + const { client, types } = setup(async () => ({ ok: false, permanent: true })); + await client.logException(new Error('boom')); + expect(types().slice(-2)).toEqual([ + LifecycleEvents.BeforeReportUploaded, + LifecycleEvents.ReportUploadFailed, + ]); + }); + + it('classifies a retained retryable failure as ReportUploadFailedWithFutureRetry', async () => { + const { client, types } = setup(async () => ({ ok: false, retained: true })); + await client.logException(new Error('boom')); + expect(types().at(-1)).toBe(LifecycleEvents.ReportUploadFailedWithFutureRetry); + }); + + it('a refusal is ReportUploadFailed even if the queue also flagged it retained', async () => { + const { client, types } = setup(async () => ({ ok: false, permanent: true, retained: true })); + await client.logException(new Error('boom')); + expect(types().at(-1)).toBe(LifecycleEvents.ReportUploadFailed); + }); + + it('a failure nothing will retry (not retained, not permanent) is ReportUploadFailed', async () => { + const { client, types } = setup(async () => ({ ok: false })); + await client.logException(new Error('boom')); + expect(types().at(-1)).toBe(LifecycleEvents.ReportUploadFailed); + }); + + it('a failed assembly emits ReportAssemblyFailed (with the error) and no upload events', async () => { + // Breaking the environment builder makes assembly throw. + const uploadPipeline: UploadPipeline = { + enqueue: async () => ({ ok: true }), + flush: async () => true, + drop: vi.fn(), + }; + const failing = createClient({ + uploadPipeline, + appToken: 'tok', + getEnvironment: () => { + throw new Error('env'); + }, + }); + const seen: Array<[string, unknown]> = []; + failing.setLifecycleEventsListener((t, d) => seen.push([t, d])); + const result = await failing.logException(new Error('boom')); + expect(result.ok).toBe(false); + expect(seen.map(([t]) => t)).toEqual([ + LifecycleEvents.BeforeReportAssembled, + LifecycleEvents.ReportAssemblyFailed, + ]); + const data = seen[1]?.[1] as { reportId: string; error: unknown }; + expect(data.error).toBeInstanceOf(Error); + expect((data.error as Error).message).toBe('env'); + expect(data.reportId).toEqual(expect.any(String)); + }); + + it('a non-conforming pipeline result is isolated: after still runs, onError hears of it', async () => { + const onError = vi.fn(); + const after = vi.fn(); + const { client } = setup((async () => null) as unknown as UploadPipeline['enqueue'], { + onError, + }); + client.setReportHandler({ after }); + await client.logException(new Error('boom')); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(onError).toHaveBeenCalled(); + expect(after).toHaveBeenCalledTimes(1); + }); + + it('emits no report events for a vetoed report', async () => { + const { client, types } = setup(); + client.setReportHandler({ before: () => null }); + await client.logException(new Error('boom')); + expect(types()).toEqual([]); + }); + + it('emits no upload events when an injected trigger pipeline never assembles', async () => { + const triggerPipeline: TriggerPipeline = { report: async () => ({ ok: true }) }; + const client = createClient({ triggerPipeline }); + const listener = vi.fn(); + client.setLifecycleEventsListener(listener); + await client.logException(new Error('boom')); + expect(listener).not.toHaveBeenCalled(); + }); + + it('a throwing listener cannot break the report, and the result is unchanged', async () => { + const onError = vi.fn(); + const { client } = setup(async () => ({ ok: true }), { onError }); + client.setLifecycleEventsListener(() => { + throw new Error('listener'); + }); + await expect(client.logException(new Error('boom'))).resolves.toEqual({ ok: true }); + expect(onError).toHaveBeenCalled(); + }); + + it('upload() reports the same events', async () => { + const { client, types } = setup(); + await client.upload({ summary: 's' }); + expect(types()).toContain(LifecycleEvents.AfterReportUploaded); + }); + + it('an enqueue that reports a BugseeError result still classifies by result flags', async () => { + const { client, types } = setup(async () => ({ + ok: false, + error: new BugseeError('x', 503), + retained: true, + })); + await client.logException(new Error('boom')); + expect(types().at(-1)).toBe(LifecycleEvents.ReportUploadFailedWithFutureRetry); + }); +}); + +describe('lifecycle events — emitLifecycleEvent hook', () => { + it('forwards a hook-emitted event with its data to the listener', () => { + const { client, events } = setup(); + client.emitLifecycleEvent(LifecycleEvents.BeforeReportShown, { x: 1 }); + expect(events).toEqual([[LifecycleEvents.BeforeReportShown, { x: 1 }]]); + }); +}); + +describe('ReportHandler.after', () => { + const settle = async (): Promise => { + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + }; + + it('runs after the report settles, with the (post-before) request', async () => { + const calls: string[] = []; + let release: (r: UploadResult) => void = () => {}; + const { client } = setup( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + client.setReportHandler({ + before: (r) => { + r.report.summary = 'masked'; + return r; + }, + after: (r) => { + calls.push(r.report.summary ?? ''); + }, + }); + const pending = client.logException(new Error('boom')); + await settle(); + expect(calls).toEqual([]); // not before the hand-off completes + release({ ok: true }); + await pending; + await settle(); + expect(calls).toEqual(['masked']); + }); + + it('runs for upload() and also after a failed upload', async () => { + const after = vi.fn(); + const { client } = setup(async () => ({ ok: false })); + client.setReportHandler({ after }); + await client.upload({ summary: 's' }); + await settle(); + expect(after).toHaveBeenCalledTimes(1); + }); + + it('is not run for a vetoed report', async () => { + const after = vi.fn(); + const { client } = setup(); + client.setReportHandler({ before: () => null, after }); + await client.logException(new Error('boom')); + await settle(); + expect(after).not.toHaveBeenCalled(); + }); + + it('a throwing after never breaks reporting and goes to onError', async () => { + const onError = vi.fn(); + const { client } = setup(async () => ({ ok: true }), { onError }); + const boom = new Error('after'); + client.setReportHandler({ + after: () => { + throw boom; + }, + }); + await expect(client.logException(new Error('x'))).resolves.toEqual({ ok: true }); + await settle(); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('runs even when the pipeline rejects', async () => { + const after = vi.fn(); + const triggerPipeline: TriggerPipeline = { + report: () => Promise.reject(new Error('pipeline')), + }; + const client = createClient({ triggerPipeline }); + client.setReportHandler({ after }); + await client.logException(new Error('boom')).catch(() => undefined); + await settle(); + expect(after).toHaveBeenCalledTimes(1); + }); + + it('is skipped when the handler has no after', async () => { + const { client } = setup(); + client.setReportHandler({ before: (r) => r }); + await expect(client.logException(new Error('x'))).resolves.toEqual({ ok: true }); + }); + + it('runs for detection-triggered reports too', async () => { + const after = vi.fn(); + const uploadPipeline: UploadPipeline = { + enqueue: async () => ({ ok: true }), + flush: async () => true, + drop: vi.fn(), + }; + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment }); + client.setReportHandler({ after }); + let trigger: (request: Parameters[0]) => void = () => {}; + client.addDetectionProvider({ + name: 'd', + start: (_ctx: unknown, report: typeof trigger) => { + trigger = report; + }, + stop: () => {}, + } as never); + client.launch(); + const { createReportingRequest } = await import('./reporting'); + trigger( + createReportingRequest({ + source: { type: 'error', mechanism: 'programmatic' }, + summary: 's', + }), + ); + await settle(); + expect(after).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..50667500 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -57,6 +57,12 @@ import { callSiteFrames, parseV8Stack, type StackFrame } from './stack'; export type { Breadcrumb, BreadcrumbInput } from './events'; import { createExtensionRegistry } from './extension-registry'; +import { + createLifecycleEmitter, + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from './lifecycle-events'; import { createMemoryCaptureStore } from './memory-capture-store'; import { createOperationDispatcher } from './operation-dispatcher'; import { createOptionsContainer } from './options'; @@ -212,9 +218,25 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar * intact, so `db.statement` reaches the SDK as raw SQL with literals unless something scrubs it. */ setSpanFilter(filter: SpanFilter | null): void; - /** `before` mutates/vetoes (return null) the report before assembly. `after` is accepted but deferred. */ + /** + * `before` mutates/vetoes (return null) the report before assembly. `after` runs once the report has + * been handed off and the pipeline has settled (delivered, refused or queued for retry), with the + * request that was sent; a throwing handler is routed to `onError` and never breaks reporting. + */ setReportHandler(handler: ReportHandler | null): void; + /** + * Observe SDK lifecycle milestones (Android `setLifecycleEventsListener`). One listener at a time; + * `null` clears it. See {@link LifecycleEvents} for the event names. A throwing listener is routed to + * `onError` and never affects the SDK. + */ + setLifecycleEventsListener(listener: LifecycleEventsListener | null): void; + /** + * Emit a lifecycle event from a platform tier that owns the milestone (the browser report dialog + * emits BeforeReportShown / AfterReportShown through this). Never throws. + */ + emitLifecycleEvent(eventType: LifecycleEventType, data?: unknown): void; + // Manual capture entry points (Android parity, §7.1). Each pushes a CaptureDataEntry. addBreadcrumb(breadcrumb: BreadcrumbInput): void; log(message: string, level?: LogLevel | LogLevelName, timestamp?: number): void; @@ -379,6 +401,10 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { // capture pipeline reads the same instance via the container (getFilters). Registered eagerly so a // pipeline resolve always finds it once a client exists. const filters = createFilterStore(onError); + const lifecycle = createLifecycleEmitter(onError); + // Requests whose bundle was assembled: only these have an upload to report on (a vetoed report, a + // failed assembly or an injected trigger pipeline that never assembles must not emit upload events). + const assembledReports = new WeakSet(); services.addService(defineService(FiltersToken, () => filters)); const rateLimiter = createRateLimiter(clock, options.captureRateLimit); const environment = createEnvironment(); @@ -495,6 +521,21 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { return capturedByType; }; const assemble = async (request: ReportingRequest): Promise => { + lifecycle.emit(LifecycleEvents.BeforeReportAssembled, { reportId: request.id }); + let bundle: Bundle; + try { + bundle = await assembleReport(request); + } catch (error) { + lifecycle.emit(LifecycleEvents.ReportAssemblyFailed, { reportId: request.id, error }); + throw error; + } + assembledReports.add(request); + lifecycle.emit(LifecycleEvents.AfterReportAssembled, { reportId: request.id }); + // The assembled bundle goes straight to the upload pipeline from here. + lifecycle.emit(LifecycleEvents.BeforeReportUploaded, { reportId: request.id }); + return bundle; + }; + const assembleReport = async (request: ReportingRequest): Promise => { // Scope the drain to the TENANT that faulted. On a multi-tenant isolate (Cloudflare Durable // Objects) an unscoped drain would put every other tenant's capture into this bundle — the leak // proven on real workerd (docs/review/cloudflare.md SEV1 #2). `owner` is undefined everywhere else, @@ -680,6 +721,31 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { } } const result = track(triggerPipeline?.report(handled) ?? Promise.resolve({ ok: false })); + // Once the pipeline settles: tell the lifecycle listener how the upload went, then run the report + // handler's `after`. Both are guarded — observing a report must never be able to break it. + const onSettled = (settled: UploadResult | undefined): void => { + try { + if (settled !== undefined && assembledReports.has(handled)) { + const data = { reportId: handled.id, result: settled }; + if (settled.ok) { + lifecycle.emit(LifecycleEvents.AfterReportUploaded, data); + } else if (settled.permanent !== true && settled.retained === true) { + // Not refused, and durably staged: the next launch will retry it. + lifecycle.emit(LifecycleEvents.ReportUploadFailedWithFutureRetry, data); + } else { + lifecycle.emit(LifecycleEvents.ReportUploadFailed, data); + } + } + } catch (error) { + onErrorSafe(error); + } + try { + filters.report?.after?.(handled); + } catch (error) { + onErrorSafe(error); + } + }; + result.then(onSettled, () => onSettled(undefined)); if (reportMarkers !== undefined) { // WHEN MAY THIS MARKER BE RETIRED? // @@ -792,6 +858,8 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { setReportHandler(handler: ReportHandler | null): void { filters.report = handler; }, + setLifecycleEventsListener: lifecycle.set, + emitLifecycleEvent: lifecycle.emit, addBreadcrumb(breadcrumb: BreadcrumbInput): void { if (killed) { @@ -956,6 +1024,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { if (killed || launched) { return; } + lifecycle.emit(LifecycleEvents.Launching); launched = true; stopped = false; // launch() must never throw (§15.1): a throwing provider.start is isolated per coordinator @@ -985,16 +1054,21 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { onError(error); } }, tickIntervalMs); + lifecycle.emit(LifecycleEvents.Launched); }, stop(timeout?: number): Promise { if (!launched) { return Promise.resolve(true); } + lifecycle.emit(LifecycleEvents.Stopping); launched = false; stopped = true; haltCapture(); // launch() always sets tickTimer first, so it is set here (stop runs only if launched) - return drainPending(timeout); + return drainPending(timeout).then((drained) => { + lifecycle.emit(LifecycleEvents.Stopped); + return drained; + }); }, flush(timeout?: number): Promise { diff --git a/packages/core/src/filters.ts b/packages/core/src/filters.ts index 94f39d62..a69a4369 100644 --- a/packages/core/src/filters.ts +++ b/packages/core/src/filters.ts @@ -49,7 +49,11 @@ export type SpanFilter = (span: FilterableSpan) => FilterableSpan | null; /** Report handler (design §7.1): `before` mutates/returns a new request, or null to veto the report. */ export interface ReportHandler { before?: (request: ReportingRequest) => ReportingRequest | null; - /** Accepted for forward-compat; not yet invoked (deferred to a later slice). */ + /** + * Runs once the report has been handed off and the pipeline has settled (Android + * `ReportHandler.onAfterReportCreated`), with the request that was sent. Not run for a vetoed report. + * A throwing handler goes to `onError` and never breaks reporting. + */ after?: (request: ReportingRequest) => void; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..e51b5c9d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,6 +158,13 @@ export { type ReconcileOwnQueue, runLaunchRecovery, } from './launch-recovery'; +export { + createLifecycleEmitter, + type LifecycleEmitter, + LifecycleEvents, + type LifecycleEventsListener, + type LifecycleEventType, +} from './lifecycle-events'; export { createMemoryCaptureStore, type MemoryCaptureStoreOptions, diff --git a/packages/core/src/lifecycle-events.test.ts b/packages/core/src/lifecycle-events.test.ts new file mode 100644 index 00000000..d74a851b --- /dev/null +++ b/packages/core/src/lifecycle-events.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLifecycleEmitter, LifecycleEvents } from './lifecycle-events'; + +describe('LifecycleEvents', () => { + it('uses the Android wire names, prefixed com.bugsee.lifecycle.', () => { + expect(LifecycleEvents).toEqual({ + Launching: 'com.bugsee.lifecycle.Launching', + Launched: 'com.bugsee.lifecycle.Launched', + Stopping: 'com.bugsee.lifecycle.Stopping', + Stopped: 'com.bugsee.lifecycle.Stopped', + BlackoutStarted: 'com.bugsee.lifecycle.BlackoutStarted', + BlackoutEnded: 'com.bugsee.lifecycle.BlackoutEnded', + RelaunchedAfterCrash: 'com.bugsee.lifecycle.RelaunchedAfterCrash', + BeforeReportShown: 'com.bugsee.lifecycle.BeforeReportShown', + AfterReportShown: 'com.bugsee.lifecycle.AfterReportShown', + BeforeReportUploaded: 'com.bugsee.lifecycle.BeforeReportUploaded', + AfterReportUploaded: 'com.bugsee.lifecycle.AfterReportUploaded', + ReportUploadFailed: 'com.bugsee.lifecycle.ReportUploadFailed', + ReportUploadFailedWithFutureRetry: 'com.bugsee.lifecycle.ReportUploadFailedWithFutureRetry', + BeforeReportAssembled: 'com.bugsee.lifecycle.BeforeReportAssembled', + AfterReportAssembled: 'com.bugsee.lifecycle.AfterReportAssembled', + ReportAssemblyFailed: 'com.bugsee.lifecycle.ReportAssemblyFailed', + }); + }); +}); + +describe('createLifecycleEmitter', () => { + it('delivers type and data to the listener', () => { + const emitter = createLifecycleEmitter(() => {}); + const listener = vi.fn(); + emitter.set(listener); + emitter.emit(LifecycleEvents.Launched, { a: 1 }); + expect(listener).toHaveBeenCalledWith(LifecycleEvents.Launched, { a: 1 }); + }); + + it('delivers falsy and primitive data unchanged', () => { + const emitter = createLifecycleEmitter(() => {}); + const listener = vi.fn(); + emitter.set(listener); + emitter.emit(LifecycleEvents.Launched, 1); + emitter.emit(LifecycleEvents.Launched, 0); + expect(listener.mock.calls).toEqual([ + [LifecycleEvents.Launched, 1], + [LifecycleEvents.Launched, 0], + ]); + }); + + it('passes undefined data when none is given', () => { + const emitter = createLifecycleEmitter(() => {}); + const listener = vi.fn(); + emitter.set(listener); + emitter.emit(LifecycleEvents.Stopped); + expect(listener).toHaveBeenCalledWith(LifecycleEvents.Stopped, undefined); + }); + + it('is a no-op with no listener, and null clears a previous one', () => { + const onError = vi.fn(); + const emitter = createLifecycleEmitter(onError); + emitter.emit(LifecycleEvents.Launched); + const listener = vi.fn(); + emitter.set(listener); + emitter.set(null); + emitter.emit(LifecycleEvents.Launched); + expect(listener).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('replaces the previous listener', () => { + const emitter = createLifecycleEmitter(() => {}); + const first = vi.fn(); + const second = vi.fn(); + emitter.set(first); + emitter.set(second); + emitter.emit(LifecycleEvents.Launched); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('routes a throwing listener to onError and never throws', () => { + const onError = vi.fn(); + const emitter = createLifecycleEmitter(onError); + const boom = new Error('boom'); + emitter.set(() => { + throw boom; + }); + expect(() => emitter.emit(LifecycleEvents.Launched)).not.toThrow(); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('survives a throwing onError too', () => { + const emitter = createLifecycleEmitter(() => { + throw new Error('sink'); + }); + emitter.set(() => { + throw new Error('listener'); + }); + expect(() => emitter.emit(LifecycleEvents.Launched)).not.toThrow(); + }); +}); diff --git a/packages/core/src/lifecycle-events.ts b/packages/core/src/lifecycle-events.ts new file mode 100644 index 00000000..5576f609 --- /dev/null +++ b/packages/core/src/lifecycle-events.ts @@ -0,0 +1,60 @@ +// Lifecycle events (Android `LifecycleEvents`, iOS `BugseeLifecycleEventType`): coarse SDK milestones an +// application can observe through ONE listener. The wire names are the Android ones so a cross-platform +// app can share its handler. Observe-only: a listener can neither veto nor alter anything, and one that +// throws is routed to `onError` and otherwise ignored. + +const PREFIX = 'com.bugsee.lifecycle.'; + +export const LifecycleEvents = { + Launching: `${PREFIX}Launching`, + Launched: `${PREFIX}Launched`, + Stopping: `${PREFIX}Stopping`, + Stopped: `${PREFIX}Stopped`, + BlackoutStarted: `${PREFIX}BlackoutStarted`, + BlackoutEnded: `${PREFIX}BlackoutEnded`, + RelaunchedAfterCrash: `${PREFIX}RelaunchedAfterCrash`, + BeforeReportShown: `${PREFIX}BeforeReportShown`, + AfterReportShown: `${PREFIX}AfterReportShown`, + BeforeReportUploaded: `${PREFIX}BeforeReportUploaded`, + AfterReportUploaded: `${PREFIX}AfterReportUploaded`, + ReportUploadFailed: `${PREFIX}ReportUploadFailed`, + ReportUploadFailedWithFutureRetry: `${PREFIX}ReportUploadFailedWithFutureRetry`, + BeforeReportAssembled: `${PREFIX}BeforeReportAssembled`, + AfterReportAssembled: `${PREFIX}AfterReportAssembled`, + ReportAssemblyFailed: `${PREFIX}ReportAssemblyFailed`, +} as const; + +export type LifecycleEventType = (typeof LifecycleEvents)[keyof typeof LifecycleEvents]; + +/** The application's observer. `data` is event-specific (report events carry `{ reportId }`). */ +export type LifecycleEventsListener = (eventType: string, data?: unknown) => void; + +export interface LifecycleEmitter { + /** Install the listener (replacing any previous one); `null` clears it. */ + set(listener: LifecycleEventsListener | null): void; + /** Deliver an event. Never throws. */ + emit(eventType: LifecycleEventType, data?: unknown): void; +} + +export function createLifecycleEmitter(onError: (error: unknown) => void): LifecycleEmitter { + let listener: LifecycleEventsListener | null = null; + return { + set(next: LifecycleEventsListener | null): void { + listener = next; + }, + emit(eventType: LifecycleEventType, data?: unknown): void { + if (listener === null) { + return; + } + try { + listener(eventType, data); + } catch (error) { + try { + onError(error); + } catch { + // a throwing sink must not defeat the guard either + } + } + }, + }; +}