diff --git a/packages/adapter-kit/src/report-server-error.ts b/packages/adapter-kit/src/report-server-error.ts index b392e6be..14f330cf 100644 --- a/packages/adapter-kit/src/report-server-error.ts +++ b/packages/adapter-kit/src/report-server-error.ts @@ -5,7 +5,12 @@ // // RUNTIME-PORTABLE (node + edge): uses only @bugsee/core APIs. Correlation to the session is automatic — // `logException` snapshots the active per-request context. Fully defensive: never throws out of the hook. -import { type BugseeClient, getCarrierClient, type LogExceptionOptions } from '@bugsee/core'; +import { + asInternalClient, + type BugseeClient, + getCarrierClient, + type InternalLogExceptionOptions, +} from '@bugsee/core'; export interface ReportServerErrorOptions { /** Resolve the Bugsee client. Default: the process/isolate carrier singleton. */ @@ -13,7 +18,7 @@ export interface ReportServerErrorOptions { /** A capture event stamping the framework's route attribution (name + params) onto the recording. */ event?: { name: string; params?: Record }; /** `logException` mechanism. Default `'http-error'`. */ - mechanism?: NonNullable; + mechanism?: NonNullable; } /** @@ -35,7 +40,9 @@ export function reportServerError(error: unknown, options: ReportServerErrorOpti // Attribution is best-effort; the report below must still happen. } } - void client.logException(error, { mechanism: options.mechanism ?? 'http-error' }); + void asInternalClient(client).logException(error, { + mechanism: options.mechanism ?? 'http-error', + }); } catch { // Never replace / disrupt the framework's own error handling. } diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 15caa2e2..01c508f1 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -18,6 +18,7 @@ import { type TraceSample, } from '@bugsee/capture'; import { + asInternalClient, BUGSEE_SDK_VERSION, type BugseeApi, type BugseeClient, @@ -740,7 +741,7 @@ export async function launchCore( const showReportDialog = createReportDialogLauncher({ // No DOM (a worker, SSR) → there is nothing to draw on; the launcher then resolves null. document: domDocument as Document, - prepare: () => client.prepareUpload(), + prepare: () => asInternalClient(client).prepareUpload(), isLaunched: () => domDocument !== undefined && client.isLaunched(), getUserIdentifier: () => client.getUserIdentifier(), setUserIdentifier: (email) => client.setUserIdentifier(email), diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 6cae73ed..84c17d58 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import type { PreparedUpload, UploadOptions, UploadResult } from '@bugsee/core'; +import type { InternalPreparedUpload, InternalUploadOptions, UploadResult } from '@bugsee/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createReportDialogLauncher, @@ -21,10 +21,10 @@ const UI: ReportDialogUiOptions = { interface Harness { deps: ReportDialogDeps; - /** `PreparedUpload.send` — what the dialog submits. */ - upload: ReturnType Promise>>; + /** `InternalPreparedUpload.send` — what the dialog submits. */ + upload: ReturnType Promise>>; discard: ReturnType void>>; - prepare: ReturnType PreparedUpload | null>>; + prepare: ReturnType InternalPreparedUpload | null>>; setUserIdentifier: ReturnType void>>; clearUserIdentifier: ReturnType void>>; show: ReturnType; @@ -34,11 +34,11 @@ const harness = ( overrides: Partial = {}, ui: Partial = {}, ): Harness => { - const upload = vi.fn<(options: UploadOptions) => Promise>(async () => ({ + const upload = vi.fn<(options: InternalUploadOptions) => Promise>(async () => ({ ok: true, })); const discard = vi.fn<() => void>(); - const prepare = vi.fn<() => PreparedUpload | null>(() => ({ send: upload, discard })); + const prepare = vi.fn<() => InternalPreparedUpload | null>(() => ({ send: upload, discard })); const setUserIdentifier = vi.fn<(email: string) => void>(); const clearUserIdentifier = vi.fn<() => void>(); const deps: ReportDialogDeps = { diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index c3573498..ddde6394 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -1,4 +1,4 @@ -import type { PreparedUpload, UploadResult } from '@bugsee/core'; +import type { InternalPreparedUpload, 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'; @@ -81,7 +81,7 @@ export interface ReportDialogUiOptions { export interface ReportDialogDeps { document: Document; /** Freeze the capture and return the report-in-waiting (`client.prepareUpload`); `null` = cannot. */ - prepare(): PreparedUpload | null; + prepare(): InternalPreparedUpload | null; /** The dialog does nothing, as on mobile, while the SDK is not running. */ isLaunched(): boolean; getUserIdentifier(): string | null; diff --git a/packages/bugsee/src/bun.ts b/packages/bugsee/src/bun.ts index 72fd6099..4f1de945 100644 --- a/packages/bugsee/src/bun.ts +++ b/packages/bugsee/src/bun.ts @@ -1,6 +1,5 @@ import { launchCore } from '@bugsee/bun'; -import type { Bugsee } from '@bugsee/node'; -import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; +import { type Bugsee, type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; // The `bugsee` umbrella launch() for BUN — selected via the package `exports` "bun" condition, which must // be listed BEFORE "node" because Bun sets both. diff --git a/packages/bugsee/src/deno.ts b/packages/bugsee/src/deno.ts index 69370681..13fde550 100644 --- a/packages/bugsee/src/deno.ts +++ b/packages/bugsee/src/deno.ts @@ -1,6 +1,5 @@ import { launchCore } from '@bugsee/deno'; -import type { Bugsee } from '@bugsee/node'; -import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; +import { type Bugsee, type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; // The `bugsee` umbrella launch() for DENO — selected via the package `exports` "deno" condition, which must // be listed BEFORE "node" because Deno sets both under node compatibility. diff --git a/packages/bugsee/src/index.bun.ts b/packages/bugsee/src/index.bun.ts index a3d7cf68..9a9f548a 100644 --- a/packages/bugsee/src/index.bun.ts +++ b/packages/bugsee/src/index.bun.ts @@ -2,7 +2,6 @@ // because Bun sets both). Re-exports the server SDK surface + the umbrella launch() bound to // @bugsee/bun's composition root, so the Bun-specific defaults are not silently lost. See ./bun. -export type { Bugsee } from '@bugsee/bun'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { @@ -14,3 +13,4 @@ export type { } from '@bugsee/core'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; export { type BugseeBunLaunchOptions, launch } from './bun'; +export type { Bugsee } from './server-launch'; diff --git a/packages/bugsee/src/index.deno.ts b/packages/bugsee/src/index.deno.ts index e4b8dad9..23908b32 100644 --- a/packages/bugsee/src/index.deno.ts +++ b/packages/bugsee/src/index.deno.ts @@ -11,6 +11,6 @@ export type { LogExceptionOptions, UploadOptions, } from '@bugsee/core'; -export type { Bugsee } from '@bugsee/deno'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; export { type BugseeDenoLaunchOptions, launch } from './deno'; +export type { Bugsee } from './server-launch'; diff --git a/packages/bugsee/src/index.edge-light.ts b/packages/bugsee/src/index.edge-light.ts index cc642641..e49df0e9 100644 --- a/packages/bugsee/src/index.edge-light.ts +++ b/packages/bugsee/src/index.edge-light.ts @@ -12,4 +12,18 @@ export type { UploadOptions, } from '@bugsee/core'; export * from '@bugsee/vercel-edge'; -export { launchEdge as launch } from '@bugsee/vercel-edge'; + +import type { PerformanceFacade } from '@bugsee/performance'; +import { + type BugseeEdgeLaunchOptions, + type Bugsee as EdgeBugsee, + launchEdge, +} from '@bugsee/vercel-edge'; +import { withPerformanceFacade } from './performance-facade'; + +// The edge runtime collects no performance data, so the top-level performance calls are no-ops here (as +// on the mobile SDKs with performance off). Explicit exports win over the `export *` above. +export type Bugsee = EdgeBugsee & PerformanceFacade; +export function launch(appToken: string, options?: BugseeEdgeLaunchOptions): Bugsee { + return withPerformanceFacade(launchEdge(appToken, options)); +} diff --git a/packages/bugsee/src/index.node.ts b/packages/bugsee/src/index.node.ts index e5205bb4..7097f381 100644 --- a/packages/bugsee/src/index.node.ts +++ b/packages/bugsee/src/index.node.ts @@ -13,7 +13,7 @@ export type { // 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. -export type { Bugsee, HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/node'; +export type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/node'; // F-5: the per-request context store — needed by app code that wants to call `store.setAttribute()` // directly (e.g. the per-request-attribute concurrency pattern) without reaching into @bugsee/node's // internal DI container (`client.getServiceProvider(...)`) or taking a direct dependency on it purely @@ -21,3 +21,4 @@ export type { Bugsee, HttpRequestOptions, HttpResponse, HttpTransport } from '@b export { type RequestContextStore, RequestContextStoreToken } from '@bugsee/node'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; export { type BugseeNodeLaunchOptions, launch } from './node'; +export type { Bugsee } from './server-launch'; diff --git a/packages/bugsee/src/index.ts b/packages/bugsee/src/index.ts index 029a896e..39fc16b6 100644 --- a/packages/bugsee/src/index.ts +++ b/packages/bugsee/src/index.ts @@ -2,7 +2,7 @@ // Umbrella package; the batteries-included entry (Tier 5). It re-exports the platform SDK surface and // adds launch() = the platform composition root + the on-by-default extensions (@bugsee/performance: // web-vitals + transactions + http spans) wired in. See docs/design/sdk-design.md §5. -export type { Bugsee, ReportDialogOptions, ReportDialogStrings } from '@bugsee/browser'; +export type { ReportDialogOptions, ReportDialogStrings } from '@bugsee/browser'; // Manual-instrumentation argument types — the curated public surface every adapter re-exports so users // can type their own event()/addBreadcrumb()/logException() calls without reaching into @bugsee/core. export type { @@ -20,12 +20,14 @@ export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; // package. Nothing else here referenced it, so the published .d.ts never mentioned it and // `client.ext('performance')` had NO usable type for anyone installing from npm — it worked inside // this monorepo only because source-based `exports` make the whole graph visible. +// The top-level performance calls (mobile parity) and the no-op-safe facade type. export type { PerformanceApi, + PerformanceFacade, Span, SpanStatus, StartTransactionOptions, Transaction, TransactionNameSource, } from '@bugsee/performance'; -export { type BugseeLaunchOptionsWithPerformance, launch } from './launch'; +export { type Bugsee, type BugseeLaunchOptionsWithPerformance, launch } from './launch'; diff --git a/packages/bugsee/src/index.worker.ts b/packages/bugsee/src/index.worker.ts index b46088ce..3c4a7f1a 100644 --- a/packages/bugsee/src/index.worker.ts +++ b/packages/bugsee/src/index.worker.ts @@ -13,3 +13,19 @@ export type { UploadOptions, } from '@bugsee/core'; export * from '@bugsee/webworker'; + +import type { PerformanceFacade } from '@bugsee/performance'; +import { + type BugseeWorkerLaunchOptions, + launch as launchWorker, + type Bugsee as WorkerBugsee, +} from '@bugsee/webworker'; +import { withPerformanceFacade } from './performance-facade'; + +// Workers collect no performance data, so the top-level performance calls are no-ops here (as on the +// mobile SDKs with performance off). They exist so one code base can call them on every runtime. +// Explicit exports win over the `export *` above. +export type Bugsee = WorkerBugsee & PerformanceFacade; +export function launch(appToken: string, options?: BugseeWorkerLaunchOptions): Bugsee { + return withPerformanceFacade(launchWorker(appToken, options)); +} diff --git a/packages/bugsee/src/index.workerd.ts b/packages/bugsee/src/index.workerd.ts index 15f11020..6d76533d 100644 --- a/packages/bugsee/src/index.workerd.ts +++ b/packages/bugsee/src/index.workerd.ts @@ -7,6 +7,21 @@ // no pageload or startup transaction to wire. export * from '@bugsee/cloudflare'; + +import { + type BugseeEdgeLaunchOptions, + type Bugsee as CloudflareBugsee, + launch as launchCloudflare, +} from '@bugsee/cloudflare'; +import type { PerformanceFacade } from '@bugsee/performance'; +import { withPerformanceFacade } from './performance-facade'; + +// The workerd runtime collects no performance data, so the top-level performance calls are no-ops here (as +// on the mobile SDKs with performance off). Explicit exports win over the `export *` above. +export type Bugsee = CloudflareBugsee & PerformanceFacade; +export function launch(appToken: string, options?: BugseeEdgeLaunchOptions): Bugsee { + return withPerformanceFacade(launchCloudflare(appToken, options)); +} export type { AttributeValue, Breadcrumb, diff --git a/packages/bugsee/src/launch.test.ts b/packages/bugsee/src/launch.test.ts index 50dd9e43..c7886f52 100644 --- a/packages/bugsee/src/launch.test.ts +++ b/packages/bugsee/src/launch.test.ts @@ -1,10 +1,10 @@ -import type { BrowserProbe, Bugsee, DeviceIdLocalStorage } from '@bugsee/browser'; +import type { BrowserProbe, DeviceIdLocalStorage } from '@bugsee/browser'; import { launchCore, resetBrowserDeviceIdCache } from '@bugsee/browser'; import type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/core'; import type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; import { serializeTransaction, type Transaction } from '@bugsee/performance'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type BugseeLaunchOptionsWithPerformance, launch } from './launch'; +import { type Bugsee, type BugseeLaunchOptionsWithPerformance, launch } from './launch'; // --- fakes --------------------------------------------------------------------------------------- @@ -174,6 +174,32 @@ describe('bugsee umbrella launch', () => { expect(client.ext('performance').getActiveSpan()).toBeDefined(); // the pageload transaction is active }); + it('exposes startTransaction / startSpan / getActiveSpan straight on the launched client', async () => { + const client = await track(await launch('tok', base({ carrier: {} }))); + const txn = client.startTransaction('Checkout', 'ui.load', { cart: 2 }); + expect(txn.getName()).toBe('Checkout'); + expect(txn.getAttributes()).toMatchObject({ cart: 2 }); + expect(client.getActiveSpan()).toBe(txn); + expect(client.ext('performance').getActiveSpan()).toBe(txn); + expect(client.startSpan('db.query', 'select').getTraceId()).toBe(txn.getTraceId()); + }); + + it('the calls are harmless no-ops when performanceMonitoring is false', async () => { + const client = await track( + await launch('tok', base({ carrier: {}, performanceMonitoring: false })), + ); + expect(client.startTransaction('Checkout', 'ui.load').isSampled()).toBe(false); + expect(client.startSpan('db').getOperation()).toBe('db'); + expect(client.getActiveSpan()).toBeUndefined(); + }); + + it('a repeat launch still returns a client carrying the calls', async () => { + const carrier = {}; + await track(await launch('tok', base({ carrier }))); + const second = await launch('tok', base({ carrier })); + expect(typeof second.startTransaction).toBe('function'); + }); + it('does NOT wire performance when performanceMonitoring is false', async () => { const client = await track( await launch('tok', base({ carrier: {}, performanceMonitoring: false })), diff --git a/packages/bugsee/src/launch.ts b/packages/bugsee/src/launch.ts index 2b64590a..cd94545e 100644 --- a/packages/bugsee/src/launch.ts +++ b/packages/bugsee/src/launch.ts @@ -1,13 +1,18 @@ import { - type Bugsee, + type Bugsee as BrowserBugsee, type BugseeLaunchOptions, createBrowserInteractionSource, createBrowserNavigationSource, launchCore, readMetaTraceContinuation, } from '@bugsee/browser'; +import type { PerformanceFacade } from '@bugsee/performance'; +import { withPerformanceFacade } from './performance-facade'; import { type UmbrellaExtensionOptions, wireUmbrella } from './wire'; +/** The launched client in the browser: the platform client plus the top-level performance calls. */ +export type Bugsee = BrowserBugsee & PerformanceFacade; + // The `bugsee` umbrella launch() for the BROWSER — the batteries-included entry. It runs the browser // composition root (@bugsee/browser's launchCore, which returns the client PLUS its internal wiring) and // then wires the on-by-default extensions (@bugsee/performance + @bugsee/opentelemetry) via the shared, @@ -27,7 +32,7 @@ export async function launch( // Browser: pageload transaction. The browser-only capture sources are injected here (as factories) so // the shared wireUmbrella — and therefore the node umbrella entry — never imports @bugsee/browser. return internals === undefined - ? client + ? withPerformanceFacade(client) : wireUmbrella( client, internals, diff --git a/packages/bugsee/src/node.test.ts b/packages/bugsee/src/node.test.ts index 29aa4d9a..f4b3079d 100644 --- a/packages/bugsee/src/node.test.ts +++ b/packages/bugsee/src/node.test.ts @@ -1,6 +1,5 @@ import type { Clock, HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/core'; import { - type Bugsee, type LaunchResult, launchCore, type NodeRuntime, @@ -11,6 +10,7 @@ import type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; import type { ActiveSpanStore } from '@bugsee/performance'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { type BugseeNodeLaunchOptions, launch } from './node'; +import type { Bugsee } from './server-launch'; // --- node fakes (no real process / network / perf_hooks) ---------------------------------------- @@ -247,6 +247,36 @@ describe('bugsee node umbrella launch', () => { expect(body.resourceSpans[0].scopeSpans[0].scope.name).toBe('com.bugsee.nodejs/performance'); }); + it('exposes startTransaction / startSpan / getActiveSpan straight on the launched client', () => { + const client = track(launch('tok', base({ carrier: {}, appStartTimeMs: 1000 }))); + const txn = client.startTransaction('Checkout', 'ui.load', { cart: 2 }); + expect(txn.isSampled()).toBe(true); + expect(txn.getAttributes()).toMatchObject({ cart: 2 }); + expect(client.getActiveSpan()).toBe(client.ext('performance').getActiveSpan()); + expect(client.getActiveSpan()).toBe(txn); + const child = client.startSpan('db.query', 'select'); + expect(child.getTraceId()).toBe(txn.getTraceId()); + txn.finish(); + }); + + it('the calls are harmless no-ops when performanceMonitoring is off', () => { + const client = track( + launch('tok', base({ carrier: {}, appStartTimeMs: 1000, performanceMonitoring: false })), + ); + const txn = client.startTransaction('Checkout', 'ui.load'); + expect(txn.isSampled()).toBe(false); + expect(txn.getName()).toBe('Checkout'); + expect(client.startSpan('db').getOperation()).toBe('db'); + expect(client.getActiveSpan()).toBeUndefined(); + }); + + it('a repeat launch still returns a client carrying the calls', () => { + const carrier = {}; + track(launch('tok', base({ carrier, appStartTimeMs: 1000 }))); + const second = launch('tok', base({ carrier, appStartTimeMs: 1000 })); + expect(typeof second.startTransaction).toBe('function'); + }); + it('does not mint a device id on node launch', () => { const { internals } = launchCore('tok-nomint', base({ carrier: {} })); expect((internals?.getEnvironment().hardware as { device_id: unknown }).device_id).toBeNull(); diff --git a/packages/bugsee/src/node.ts b/packages/bugsee/src/node.ts index 242f5c3d..2472a6d1 100644 --- a/packages/bugsee/src/node.ts +++ b/packages/bugsee/src/node.ts @@ -1,6 +1,5 @@ -import type { Bugsee } from '@bugsee/node'; import { launchCore } from '@bugsee/node'; -import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; +import { type Bugsee, type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; // The `bugsee` umbrella launch() for NODE — the batteries-included server entry, selected via the package // `exports` "node" condition. The composition itself lives in ./server-launch, shared with the bun and deno diff --git a/packages/bugsee/src/performance-facade.test.ts b/packages/bugsee/src/performance-facade.test.ts new file mode 100644 index 00000000..7d63fb3a --- /dev/null +++ b/packages/bugsee/src/performance-facade.test.ts @@ -0,0 +1,45 @@ +import { createClient } from '@bugsee/core'; +import { createPerformanceController, createTransactionStore } from '@bugsee/performance'; +import { describe, expect, it } from 'vitest'; +import { withPerformanceFacade } from './performance-facade'; + +const clock = { wallNow: () => 1000, monotonicNow: () => 0 }; + +describe('withPerformanceFacade', () => { + it('returns the same client object with the three calls installed on it', () => { + const client = createClient(); + const facade = withPerformanceFacade(client); + expect(facade).toBe(client); + expect(typeof facade.startTransaction).toBe('function'); + expect(typeof facade.startSpan).toBe('function'); + expect(typeof facade.getActiveSpan).toBe('function'); + }); + + it('is a harmless no-op while no performance extension is registered', () => { + const client = withPerformanceFacade(createClient()); + const txn = client.startTransaction('Login', 'ui.load', { a: 1 }); + expect(txn.getName()).toBe('Login'); + expect(txn.isSampled()).toBe(false); + expect(client.startSpan('db', 'q').getOperation()).toBe('db'); + expect(client.getActiveSpan()).toBeUndefined(); + }); + + it('passes through to ext("performance") once it is registered, even after installation', () => { + const client = withPerformanceFacade(createClient()); + const api = createPerformanceController({ clock, store: createTransactionStore() }); + client.registerExt('performance', api); + const txn = client.startTransaction('Checkout', 'ui.load', { cart: 2 }); + expect(txn.isSampled()).toBe(true); + expect(txn.getAttributes()).toMatchObject({ cart: 2 }); + expect(client.getActiveSpan()).toBe(txn); + const child = client.startSpan('db.query', 'select'); + expect(child.getTraceId()).toBe(txn.getTraceId()); + expect(api.getActiveSpan()).toBe(txn); + }); + + it('is idempotent: a second install keeps the first methods', () => { + const client = createClient(); + const first = withPerformanceFacade(client).startTransaction; + expect(withPerformanceFacade(client).startTransaction).toBe(first); + }); +}); diff --git a/packages/bugsee/src/performance-facade.ts b/packages/bugsee/src/performance-facade.ts new file mode 100644 index 00000000..5645ab7e --- /dev/null +++ b/packages/bugsee/src/performance-facade.ts @@ -0,0 +1,36 @@ +import type { BugseeClient } from '@bugsee/core'; +import { + createPerformanceFacade, + type PerformanceApi, + type PerformanceFacade, +} from '@bugsee/performance'; + +// The mobile SDKs expose `Bugsee.startTransaction` / `startSpan` / `getActiveSpan` as top-level calls. +// In JS the performance API lives behind `client.ext('performance')`; this installs thin pass-throughs on +// the launched client so the same calls work directly. Entries that never wire performance (workers, +// edge) get the same methods, backed by no-ops, exactly as the mobile SDKs behave with performance off. + +/** `ext('performance')` throws when nothing registered it (performance off, or a runtime without it). */ +function performanceApiOf(client: BugseeClient): PerformanceApi | undefined { + try { + return client.ext('performance'); + } catch { + return undefined; + } +} + +/** + * Install the performance pass-throughs on `client` IN PLACE (the client is the process singleton, so a + * repeat launch sees the same methods). Idempotent. Resolves the performance API on every call, so it + * works whether the extension was wired before or after this runs. + */ +export function withPerformanceFacade(client: C): C & PerformanceFacade { + const target = client as C & Partial; + if (target.startTransaction === undefined) { + const facade = createPerformanceFacade(() => performanceApiOf(client)); + target.startTransaction = facade.startTransaction; + target.startSpan = facade.startSpan; + target.getActiveSpan = facade.getActiveSpan; + } + return client as C & PerformanceFacade; +} diff --git a/packages/bugsee/src/public-surface.test-d.ts b/packages/bugsee/src/public-surface.test-d.ts new file mode 100644 index 00000000..1b74d6f3 --- /dev/null +++ b/packages/bugsee/src/public-surface.test-d.ts @@ -0,0 +1,43 @@ +// Type-level tests for the umbrella's public surface, checked by `tsc --noEmit` (the typecheck gate). +// A `@ts-expect-error` line turns into an error the moment the thing it guards becomes legal, so each +// negative below pins a wire internal that must NOT be public (the mobile SDKs expose none of them). + +import type { Span, Transaction } from '@bugsee/performance'; +import type { + Bugsee, + BugseeLaunchOptionsWithPerformance, + LogExceptionOptions, + UploadOptions, +} from './index'; +import type { Bugsee as NodeBugsee } from './index.node'; +import type { Bugsee as WorkerdBugsee } from './index.workerd'; + +declare const client: Bugsee; +declare const nodeClient: NodeBugsee; +declare const workerdClient: WorkerdBugsee; + +// `performanceUploadMode` is a typed launch option: both modes accepted, no cast. +export const batched: BugseeLaunchOptionsWithPerformance = { performanceUploadMode: 'batched' }; +export const realtime: BugseeLaunchOptionsWithPerformance = { performanceUploadMode: 'realtime' }; +// @ts-expect-error only 'batched' | 'realtime' are valid upload modes. +export const badMode: BugseeLaunchOptionsWithPerformance = { performanceUploadMode: 'instant' }; + +// The three performance calls sit directly on the launched client, on every entry. +export const txn: Transaction = client.startTransaction('Checkout', 'ui.load', { cart: 1 }); +export const txnNoAttrs: Transaction = client.startTransaction('Checkout', 'ui.load'); +export const span: Span = client.startSpan('db.query', 'select'); +export const spanNoDesc: Span = client.startSpan('db.query'); +export const active: Span | undefined = client.getActiveSpan(); +export const nodeTxn: Transaction = nodeClient.startTransaction('n', 'op'); +export const workerdTxn: Transaction = workerdClient.startTransaction('n', 'op'); + +// The wire internals are not part of the public options... +// @ts-expect-error `trigger` (the wire source type) is not public. +export const badUpload: UploadOptions = { trigger: 'code_dialog' }; +// @ts-expect-error `mechanism` (the wire source mechanism) is not public. +export const badLog: LogExceptionOptions = { mechanism: 'uncaught' }; +// ...and a public caller cannot slip them through the client either. +// @ts-expect-error a public caller cannot pass `trigger` to `upload`. +void client.upload({ trigger: 'code_dialog' }); +// @ts-expect-error a public caller cannot pass `mechanism` to `logException`. +void client.logException(new Error('x'), { mechanism: 'uncaught' }); diff --git a/packages/bugsee/src/server-launch.ts b/packages/bugsee/src/server-launch.ts index 9ab430b3..78b4148b 100644 --- a/packages/bugsee/src/server-launch.ts +++ b/packages/bugsee/src/server-launch.ts @@ -1,4 +1,6 @@ -import type { Bugsee, BugseeLaunchOptions, LaunchResult } from '@bugsee/node'; +import type { BugseeLaunchOptions, LaunchResult, Bugsee as NodeBugsee } from '@bugsee/node'; +import type { PerformanceFacade } from '@bugsee/performance'; +import { withPerformanceFacade } from './performance-facade'; import { type UmbrellaExtensionOptions, wireUmbrella } from './wire'; // The shared umbrella launch() for every SERVER runtime — node, bun and deno (Wave 3b.1 / 4.1). @@ -13,6 +15,9 @@ import { type UmbrellaExtensionOptions, wireUmbrella } from './wire'; // A server runtime has no pageload lifecycle, so instead of a pageload transaction it records a startup // transaction (process-start → launch, `app.start`); web-vitals and finish-on-hidden are browser-only. +/** The launched client on a server runtime: the platform client plus the top-level performance calls. */ +export type Bugsee = NodeBugsee & PerformanceFacade; + export interface BugseeServerLaunchOptions extends BugseeLaunchOptions, UmbrellaExtensionOptions { /** Process start time (ms) for the `app.start` startup transaction. Default `Date.now() - * process.uptime()*1000`; override for determinism / a custom start mark. */ @@ -26,7 +31,7 @@ export function createServerLaunch( return (appToken: string, options: BugseeServerLaunchOptions = {}): Bugsee => { const { client, internals } = launchCore(appToken, options); // No internals → a prior launch already owns the process singleton (and already wired the extensions). - if (internals === undefined) return client; + if (internals === undefined) return withPerformanceFacade(client); // Process start = now − uptime. Reached via globalThis (no node:process import → no node types needed); // these entries only run on server runtimes, where process.uptime always exists (Bun and Deno both // provide it through their node-compat layers). diff --git a/packages/bugsee/src/wire.ts b/packages/bugsee/src/wire.ts index aa02d364..91eab2da 100644 --- a/packages/bugsee/src/wire.ts +++ b/packages/bugsee/src/wire.ts @@ -19,11 +19,14 @@ import { defaultTraceId, hasDeviceIdAttribute, PERFORMANCE_OPTION_DEFINITIONS, + type PerformanceFacade, PerformanceOption, + type PerformanceUploadMode, type TransactionWire, wirePerformance, } from '@bugsee/performance'; import type { EnvironmentEnvelope } from '@bugsee/protocol'; +import { withPerformanceFacade } from './performance-facade'; /** * Per-runtime root-transaction policy. The browser collects a pageload transaction (+ web-vitals); Node @@ -89,6 +92,8 @@ export interface UmbrellaExtensionOptions { performanceMonitoring?: boolean; /** Head sampling rate for performance transactions, 0..1. Default 1 (keep all). */ performanceSampleRate?: number; + /** Upload cadence: `batched` (every flush interval, the default) or `realtime` (per transaction). */ + performanceUploadMode?: PerformanceUploadMode; /** Batched-upload flush interval in milliseconds. Default 30000. */ performanceFlushIntervalMs?: number; /** The pageload transaction name. Default the current path (`location.pathname`) or `pageload`. */ @@ -170,7 +175,10 @@ export function wireUmbrella( options: UmbrellaExtensionOptions, platform: UmbrellaPlatform, browserSources: UmbrellaBrowserSources = {}, -): C { +): C & PerformanceFacade { + // `startTransaction` / `startSpan` / `getActiveSpan` straight on the client (mobile parity). Installed + // first so they exist even when performance is switched off, where they are no-ops. + const facade = withPerformanceFacade(client); // Resolve the performance.* options the extension owns (friendly → canonical, defaults applied). const perf = resolveLaunchOptions( options as unknown as Record, @@ -249,7 +257,7 @@ export function wireUmbrella( }); // monitoring off → wirePerformance installed nothing; return the client as-is (no teardown to compose). - if (wired === undefined) return client; + if (wired === undefined) return facade; // Node startup transaction (the pageload analog): record an already-finished `app.start` transaction // spanning process-start → launch, so it uploads immediately (no pageload/hidden lifecycle on Node). @@ -329,5 +337,5 @@ export function wireUmbrella( wired.stop(); return stopClient(timeout); }; - return client; + return facade; } diff --git a/packages/bugsee/src/worker-entries.test.ts b/packages/bugsee/src/worker-entries.test.ts new file mode 100644 index 00000000..2b31f21d --- /dev/null +++ b/packages/bugsee/src/worker-entries.test.ts @@ -0,0 +1,47 @@ +import type { HttpTransport } from '@bugsee/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { launch as launchEdgeLight } from './index.edge-light'; +import { launch as launchWorker } from './index.worker'; +import { launch as launchWorkerd } from './index.workerd'; + +// The umbrella's worker / edge entries are thin re-exports of the incident-driven platform SDKs, which wire +// no performance extension. They must still expose the top-level `startTransaction` / `startSpan` / +// `getActiveSpan` (mobile parity), as harmless no-ops. Only the runtime that resolves a given entry ever +// loads it, so these run each entry's `launch` directly under Node. + +const transport: HttpTransport = async () => ({ status: 200, headers: {}, body: new Uint8Array() }); +const scope = { addEventListener: () => {}, removeEventListener: () => {} }; + +type Launched = { + stop(): Promise; + startTransaction(name: string, operation: string): { isSampled(): boolean; getName(): string }; + startSpan(operation: string, description?: string): { getOperation(): string }; + getActiveSpan(): unknown; +}; +const launched: Launched[] = []; +const track = (c: Launched): Launched => { + launched.push(c); + return c; +}; +afterEach(async () => { + await Promise.all(launched.splice(0).map((c) => c.stop())); +}); + +describe.each([ + [ + 'worker', + () => + launchWorker('tok', { carrier: {}, transport, captureNetwork: false, globalScope: scope }), + ], + ['edge-light', () => launchEdgeLight('tok', { carrier: {}, transport, captureNetwork: false })], + ['workerd', () => launchWorkerd('tok', { carrier: {}, transport, captureNetwork: false })], +])('%s entry launch()', (_name, start) => { + it('returns a client whose performance calls are no-ops', () => { + const client = track(start() as unknown as Launched); + const txn = client.startTransaction('Checkout', 'ui.load'); + expect(txn.getName()).toBe('Checkout'); + expect(txn.isSampled()).toBe(false); + expect(client.startSpan('db.query', 'select').getOperation()).toBe('db.query'); + expect(client.getActiveSpan()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 411f7b90..1660381e 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -4,7 +4,13 @@ import { strFromU8, unzipSync } from '@bugsee/util'; import { describe, expect, it, vi } from 'vitest'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; -import { createClient, type Scheduler, SchedulerToken } from './client'; +import { + asInternalClient, + type BugseeClient, + createClient, + type Scheduler, + SchedulerToken, +} from './client'; import { type Clock, ClockToken } from './clock'; import { type CaptureProvider, @@ -2136,6 +2142,16 @@ describe('createClient — upload (manual report)', () => { expect(sent().source.type).toBe('code_dialog'); }); + it('asInternalClient hands back the SAME client, reaching the wire-level trigger through the public type', async () => { + const { client, sent } = upload(); + const publicClient: BugseeClient = client; + + expect(asInternalClient(publicClient)).toBe(client); + await asInternalClient(publicClient).upload({ trigger: 'code_dialog', summary: 's' }); + + expect(sent().source.type).toBe('code_dialog'); + }); + it('is not instance-deduped or rate-limited: every explicit request is a report', async () => { const { client, enqueue } = upload(); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..dd319583 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -143,14 +143,23 @@ function describeError(error: unknown): string | undefined { /** Options for logException (a focused core subset of Android ExceptionOptions). */ export interface LogExceptionOptions { - /** Capture mechanism for the wire source (default 'programmatic'). */ - mechanism?: Mechanism; /** Issue severity (default derived from the error issue type). */ severity?: SeverityName; /** Extra labels for the issue. */ labels?: string[]; } +/** + * {@link LogExceptionOptions} plus the wire `source.mechanism`. SDK-internal: the mobile SDKs expose no + * such option, so the public `BugseeClient` does not accept it. Adapters and platform packages reach it + * through {@link asInternalClient}. + * @internal + */ +export interface InternalLogExceptionOptions extends LogExceptionOptions { + /** Capture mechanism for the wire source (default 'programmatic'). */ + mechanism?: Mechanism; +} + /** * Options for {@link BugseeClient.upload} — a report the application (or the report dialog) asks for * explicitly, as opposed to one the SDK detects. Mirrors Android's `Bugsee.upload` / @@ -164,6 +173,13 @@ export interface UploadOptions { labels?: string[]; /** Reporter email; when absent the global user identifier is used, as for any report. */ email?: string; +} + +/** + * {@link UploadOptions} plus the wire `source.type`. SDK-internal: the mobile SDKs expose no such option. + * @internal + */ +export interface InternalUploadOptions extends UploadOptions { /** * Who asked: `code_upload` (the default — the app called `upload()` directly) or `code_dialog` (the * user filled in the report dialog). It is the report's `source.type` on the wire, which the viewer @@ -184,6 +200,11 @@ export interface PreparedUpload { discard(): void; } +/** {@link PreparedUpload} whose `send` also accepts the internal `trigger`. @internal */ +export interface InternalPreparedUpload extends PreparedUpload { + send(options?: InternalUploadOptions): Promise; +} + /** The public client surface, extending the provider-facing {@link Client} (grown per slice). */ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar { registerExt(name: K, api: NameExtensionMapping[K]): void; @@ -248,6 +269,27 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar flush(timeout?: number): Promise; } +/** + * The client as the SDK's own packages see it: the public {@link BugseeClient} plus the wire-level + * options (`mechanism`, `trigger`) the public surface deliberately omits. A subtype, so it is assignable + * to `BugseeClient`; the reverse needs {@link asInternalClient}. + * @internal + */ +export interface InternalBugseeClient extends BugseeClient { + logException(error: unknown, options?: InternalLogExceptionOptions): Promise; + upload(options?: InternalUploadOptions): Promise; + prepareUpload(): InternalPreparedUpload | null; +} + +/** + * View a public client through its internal surface. A type-level cast only: every client the SDK + * creates already implements the internal options. + * @internal + */ +export function asInternalClient(client: BugseeClient): InternalBugseeClient { + return client as InternalBugseeClient; +} + /** * A pull-at-report snapshot source: given the report's wall-clock timestamp, returns extra capture * entries to merge into the assembled bundle (e.g. a browser DOM `viewtree`, or a node CPU `profile`). @@ -358,7 +400,7 @@ interface ReportIdentity { userIdentifier: string | null; } -export function createClient(options: CreateClientOptions = {}): BugseeClient { +export function createClient(options: CreateClientOptions = {}): InternalBugseeClient { const clock = options.clock ?? createSystemClock(); const isEnabled = options.isEnabled ?? (() => true); const onError = options.onError ?? (() => {}); @@ -535,7 +577,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { // Build, filter and submit a report someone explicitly asked for (`upload` / a prepared upload). const sendUpload = ( - uploadOptions: UploadOptions, + uploadOptions: InternalUploadOptions, frozen?: Promise>, ): Promise => { const request = createReportingRequest({ @@ -853,7 +895,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { logException: function logException( error: unknown, - exceptionOptions?: LogExceptionOptions, + exceptionOptions?: InternalLogExceptionOptions, ): Promise { // After stop() (§1501) or in the kill-state (§1435), logException is a silent no-op. if (stopped || killed) { @@ -909,14 +951,14 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { return submitReport(handled); }, - upload(uploadOptions: UploadOptions = {}): Promise { + upload(uploadOptions: InternalUploadOptions = {}): Promise { if (stopped || killed) { return Promise.resolve({ ok: false }); } return sendUpload(uploadOptions); }, - prepareUpload(): PreparedUpload | null { + prepareUpload(): InternalPreparedUpload | null { if (stopped || killed) { return null; } @@ -934,7 +976,7 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { frozen.catch(() => undefined); let spent = false; return { - send(sendOptions: UploadOptions = {}): Promise { + send(sendOptions: InternalUploadOptions = {}): Promise { if (spent || stopped || killed) { return Promise.resolve({ ok: false }); } diff --git a/packages/core/src/index.test-d.ts b/packages/core/src/index.test-d.ts index 3f9571b3..befab5dc 100644 --- a/packages/core/src/index.test-d.ts +++ b/packages/core/src/index.test-d.ts @@ -7,8 +7,12 @@ import type { Mechanism } from '@bugsee/protocol'; import type { Breadcrumb, BreadcrumbInput, + BugseeClient, CreateClientOptions, + InternalLogExceptionOptions, + InternalUploadOptions, LogExceptionOptions, + UploadOptions, } from './index'; // createClient's parameter type — home of the onError injection seam. @@ -21,9 +25,13 @@ const options: CreateClientOptions = { const crumbInput: BreadcrumbInput = { message: 'clicked' }; const crumb: Breadcrumb = { message: 'clicked', timestamp: 1 }; -// logException's options carry an optional wire mechanism. +// The INTERNAL logException options carry an optional wire mechanism; the public ones do not. const mechanism: Mechanism = 'programmatic'; -const logOpts: LogExceptionOptions = { mechanism, severity: 'high', labels: ['p1'] }; +const logOpts: InternalLogExceptionOptions = { mechanism, severity: 'high', labels: ['p1'] }; +const publicLogOpts: LogExceptionOptions = { severity: 'high', labels: ['p1'] }; +const internalUpload: InternalUploadOptions = { trigger: 'code_dialog', summary: 's' }; +const publicUpload: UploadOptions = { summary: 's' }; +declare const publicClient: BugseeClient; // --- Negatives --- // @ts-expect-error onError must accept the thrown value; a wrong-arity handler is rejected. @@ -31,4 +39,21 @@ export const badOptions: CreateClientOptions = { onError: (_a: string, _b: strin // @ts-expect-error the base Breadcrumb requires `timestamp` (BreadcrumbInput makes it optional). export const badCrumb: Breadcrumb = { message: 'x' }; -export type IndexAssertions = [typeof options, typeof crumbInput, typeof crumb, typeof logOpts]; +// @ts-expect-error the wire mechanism is not part of the public LogExceptionOptions. +export const badMechanism: LogExceptionOptions = { mechanism }; +// @ts-expect-error the wire trigger is not part of the public UploadOptions. +export const badTrigger: UploadOptions = { trigger: 'code_dialog' }; +// @ts-expect-error a public caller cannot pass `trigger` to BugseeClient.upload. +void publicClient.upload({ trigger: 'code_dialog' }); +// @ts-expect-error a public caller cannot pass `mechanism` to BugseeClient.logException. +void publicClient.logException(new Error('x'), { mechanism }); + +export type IndexAssertions = [ + typeof options, + typeof crumbInput, + typeof crumb, + typeof logOpts, + typeof publicLogOpts, + typeof internalUpload, + typeof publicUpload, +]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..080d95de 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,11 +52,16 @@ export type { ChunkBackend, FrozenPart, PartMeta, PartRef } from './chunk-backen export { type ChunkCaptureStoreOptions, createChunkCaptureStore } from './chunk-capture-store'; export { type ChunkStorage, ChunkStorageToken, createInMemoryChunkStorage } from './chunk-storage'; export { + asInternalClient, type Breadcrumb, type BreadcrumbInput, type BugseeClient, type CreateClientOptions, createClient, + type InternalBugseeClient, + type InternalLogExceptionOptions, + type InternalPreparedUpload, + type InternalUploadOptions, type LogExceptionOptions, type PreparedUpload, type ReportSnapshotSource, diff --git a/packages/elysia/src/hooks.ts b/packages/elysia/src/hooks.ts index 3170ee97..37385c67 100644 --- a/packages/elysia/src/hooks.ts +++ b/packages/elysia/src/hooks.ts @@ -1,4 +1,4 @@ -import { getCarrierClient } from '@bugsee/core'; +import { asInternalClient, getCarrierClient } from '@bugsee/core'; import { type Bugsee, neverThrow, @@ -179,7 +179,7 @@ function setupElysiaUnsafe(app: ElysiaAppLike, options: ElysiaAdapterOptions = { const report = options.shouldReport ? options.shouldReport(c.error) : serverError; if (report) { resolveStore(client)?.setAttribute('http.route', c.route ?? requestPath(c)); - void client.logException(c.error, { mechanism: 'http-error' }); + void asInternalClient(client).logException(c.error, { mechanism: 'http-error' }); } // WAVE 6.8 — a NOT_FOUND is the ONE code Elysia short-circuits, so mapResponse never runs and the // transaction was left open forever. Probed on Elysia 1.4 (`app.handle`), per-request hook order: diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index d206bf55..b3a7d6ad 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -1,4 +1,4 @@ -import { getCarrierClient } from '@bugsee/core'; +import { asInternalClient, getCarrierClient } from '@bugsee/core'; import { type Bugsee, defaultShouldReport, @@ -164,7 +164,7 @@ export function errorHandler(options: ExpressAdapterOptions = {}): ErrorMiddlewa if (store !== undefined && route !== undefined) { store.setAttribute('http.route', route); } - void client.logException(err, { mechanism: 'http-error' }); + void asInternalClient(client).logException(err, { mechanism: 'http-error' }); } } catch { // the adapter must never replace the app's own error handling diff --git a/packages/fastify/src/hooks.ts b/packages/fastify/src/hooks.ts index c8b8f4b4..4573392f 100644 --- a/packages/fastify/src/hooks.ts +++ b/packages/fastify/src/hooks.ts @@ -1,4 +1,4 @@ -import { getCarrierClient } from '@bugsee/core'; +import { asInternalClient, getCarrierClient } from '@bugsee/core'; import { type Bugsee, defaultShouldReport, @@ -154,7 +154,7 @@ function setupFastifyUnsafe(app: FastifyInstance, options: FastifyAdapterOptions if (store !== undefined && route !== undefined) { store.setAttribute('http.route', route); } - void client.logException(error, { mechanism: 'http-error' }); + void asInternalClient(client).logException(error, { mechanism: 'http-error' }); } } catch { // never break Fastify's error handling diff --git a/packages/hapi/src/hooks.ts b/packages/hapi/src/hooks.ts index d80f511d..3be58815 100644 --- a/packages/hapi/src/hooks.ts +++ b/packages/hapi/src/hooks.ts @@ -1,4 +1,4 @@ -import { getCarrierClient } from '@bugsee/core'; +import { asInternalClient, getCarrierClient } from '@bugsee/core'; import { type Bugsee, neverThrow, @@ -170,7 +170,7 @@ function setupHapiUnsafe(server: HapiServerLike, options: HapiAdapterOptions = { ) { // Report the Boom directly against the active context (the owner's under re-entrancy). resolveStore(client)?.setAttribute('http.route', request.route?.path ?? request.path); - void client.logException(response, { mechanism: 'http-error' }); + void asInternalClient(client).logException(response, { mechanism: 'http-error' }); } const span = spans.get(request); if (span !== undefined) { diff --git a/packages/nestjs/src/shared.ts b/packages/nestjs/src/shared.ts index 81a646f6..ddee1c09 100644 --- a/packages/nestjs/src/shared.ts +++ b/packages/nestjs/src/shared.ts @@ -1,4 +1,4 @@ -import { getCarrierClient, type RequestContext } from '@bugsee/core'; +import { asInternalClient, getCarrierClient, type RequestContext } from '@bugsee/core'; import { type Bugsee, type RequestContextStore, RequestContextStoreToken } from '@bugsee/node'; import type { PerformanceApi } from '@bugsee/performance'; import { sanitizeUrl } from '@bugsee/protocol'; @@ -161,6 +161,6 @@ export function reportErrorOnce( if (options.route !== undefined) { resolveStore(client)?.setAttribute('http.route', options.route); } - void client.logException(err, { mechanism: 'http-error' }); + void asInternalClient(client).logException(err, { mechanism: 'http-error' }); return true; } diff --git a/packages/node/src/server-instrument.ts b/packages/node/src/server-instrument.ts index fb2b942b..1fcc9901 100644 --- a/packages/node/src/server-instrument.ts +++ b/packages/node/src/server-instrument.ts @@ -1,5 +1,10 @@ import { parseTraceparent } from '@bugsee/capture'; -import { type BugseeClient, getCarrierClient, type RequestContext } from '@bugsee/core'; +import { + asInternalClient, + type BugseeClient, + getCarrierClient, + type RequestContext, +} from '@bugsee/core'; import type { PerformanceApi, Transaction } from '@bugsee/performance'; import { NAME_SOURCE_ATTRIBUTE, sanitizeUrl } from '@bugsee/protocol'; import { randomId } from '@bugsee/util'; @@ -545,7 +550,7 @@ function makeSpan( // The context merge is enrichment, not the report: a custom store whose mutator throws // must not convert a reportable error into a silent "not reported". } - void client.logException(err, { mechanism: 'http-error' }); + void asInternalClient(client).logException(err, { mechanism: 'http-error' }); return true; } catch { return false; // reporting must never replace the app's own error handling diff --git a/packages/nuxt/src/nitro-edge.ts b/packages/nuxt/src/nitro-edge.ts index 3a24e9d8..4c50b543 100644 --- a/packages/nuxt/src/nitro-edge.ts +++ b/packages/nuxt/src/nitro-edge.ts @@ -9,6 +9,7 @@ // (on `event.context.cloudflare.context`) or Vercel Edge's global request-context symbol. This delivers the // moat's edge value (server errors reported from Nuxt-on-edge — a differentiator Sentry lacks). Full // per-request context/trace correlation on edge needs Nitro fetch-entry wrapping — a documented v2. +import { asInternalClient } from '@bugsee/core'; import { type Bugsee, type BugseeEdgeLaunchOptions, @@ -86,7 +87,7 @@ export function installBugseeNitroEdge( waitUntil( (async () => { try { - await client.logException(error, { mechanism: 'http-error' }); + await asInternalClient(client).logException(error, { mechanism: 'http-error' }); // A non-finite deadline means NO deadline: `flush` races `sleep(timeout)`, and // `setTimeout(fn, Infinity)` coerces to 0 and fires at once, which would abandon immediately. const delivered = await client.flush( diff --git a/packages/performance/src/facade.test.ts b/packages/performance/src/facade.test.ts new file mode 100644 index 00000000..cd6e1019 --- /dev/null +++ b/packages/performance/src/facade.test.ts @@ -0,0 +1,123 @@ +import type { Clock } from '@bugsee/core'; +import { describe, expect, it } from 'vitest'; +import { createPerformanceController } from './controller'; +import { createNoopTransaction, createPerformanceFacade } from './facade'; +import { createTransactionStore } from './transaction-store'; + +const clock: Clock = { wallNow: () => 1000, monotonicNow: () => 0 }; +const realApi = () => createPerformanceController({ clock, store: createTransactionStore() }); + +describe('createNoopTransaction', () => { + it('is a harmless, never-sampled transaction that reports what it was asked to', () => { + const t = createNoopTransaction('Login', 'ui.load', 'desc'); + expect(t.getName()).toBe('Login'); + expect(t.getOperation()).toBe('ui.load'); + expect(t.getDescription()).toBe('desc'); + expect(t.isSampled()).toBe(false); + expect(t.getStatus()).toBe('UNKNOWN'); + expect(t.getSpanId()).toBe(''); + expect(t.getTraceId()).toBe(''); + expect(t.getAttributes()).toEqual({}); + expect(t.isFinished()).toBe(false); + }); + + it('accepts every mutation without effect and chains the fluent setters', () => { + const t = createNoopTransaction('n', 'op'); + expect(t.setName('x')).toBe(t); + expect(t.setDescription('d')).toBe(t); + expect(t.setAttribute('k', 1)).toBe(t); + expect(t.setStatus('ERROR')).toBe(t); + expect(t.getName()).toBe('n'); + expect(t.getDescription()).toBeUndefined(); + expect(t.getAttributes()).toEqual({}); + expect(t.getStatus()).toBe('UNKNOWN'); + t.recordChildSpan('child', { startTimestampMs: 1, endTimestampMs: 2 }); + const child = t.startChildSpan('db', 'q'); + expect(child.getOperation()).toBe('db'); + expect(child.getDescription()).toBe('q'); + expect(child.isFinished()).toBe(false); + expect(child.setName('y')).toBe(child); + expect(child.setDescription('y')).toBe(child); + expect(child.setAttribute('a', 1)).toBe(child); + expect(child.setStatus('OK')).toBe(child); + expect(child.getSpanId()).toBe(''); + child.finish(); + t.finish('OK'); + expect(t.isFinished()).toBe(true); + expect(child.isFinished()).toBe(true); + }); +}); + +describe('createPerformanceFacade (performance disabled / not wired)', () => { + const facade = createPerformanceFacade(() => undefined); + + it('startTransaction returns a no-op transaction carrying name and operation', () => { + const t = facade.startTransaction('Checkout', 'ui.load', { a: 1 }); + expect(t.getName()).toBe('Checkout'); + expect(t.getOperation()).toBe('ui.load'); + expect(t.isSampled()).toBe(false); + }); + + it('startSpan returns a no-op span and getActiveSpan is undefined', () => { + const s = facade.startSpan('db.query', 'select'); + expect(s.getOperation()).toBe('db.query'); + expect(s.getDescription()).toBe('select'); + expect(facade.getActiveSpan()).toBeUndefined(); + }); +}); + +describe('createPerformanceFacade (performance wired)', () => { + it('startTransaction starts a real transaction and applies the initial attributes', () => { + const api = realApi(); + const facade = createPerformanceFacade(() => api); + const t = facade.startTransaction('Checkout', 'ui.load', { cart: 3, vip: true }); + expect(t.getName()).toBe('Checkout'); + expect(t.getOperation()).toBe('ui.load'); + expect(t.isSampled()).toBe(true); + expect(t.getSpanId()).not.toBe(''); + expect(t.getAttributes()).toMatchObject({ cart: 3, vip: true }); + expect(facade.getActiveSpan()).toBe(t); + }); + + it('startTransaction without attributes sets none', () => { + const facade = createPerformanceFacade(() => realApi()); + expect(Object.keys(facade.startTransaction('n', 'op').getAttributes())).not.toContain('cart'); + }); + + it('startSpan nests under the active span, sharing its trace', () => { + const api = realApi(); + const facade = createPerformanceFacade(() => api); + const t = facade.startTransaction('n', 'op'); + const s = facade.startSpan('db.query', 'select 1'); + expect(s.getTraceId()).toBe(t.getTraceId()); + expect(s.getSpanId()).not.toBe(t.getSpanId()); + expect(s.getOperation()).toBe('db.query'); + expect(s.getDescription()).toBe('select 1'); + }); + + it('startSpan with no active span opens a root transaction rather than dropping the work', () => { + const api = realApi(); + const facade = createPerformanceFacade(() => api); + const s = facade.startSpan('job', 'nightly'); + expect(s.getOperation()).toBe('job'); + expect(s.getDescription()).toBe('nightly'); + expect((s as unknown as { getName(): string }).getName()).toBe('nightly'); + expect(facade.getActiveSpan()).toBe(s); + }); + + it('startSpan with no active span and no description names the transaction after the operation', () => { + const api = realApi(); + const facade = createPerformanceFacade(() => api); + const s = facade.startSpan('job'); + expect((s as unknown as { getName(): string }).getName()).toBe('job'); + expect(s.getDescription()).toBeUndefined(); + }); + + it('resolves the API lazily on every call', () => { + let api: ReturnType | undefined; + const facade = createPerformanceFacade(() => api); + expect(facade.startTransaction('a', 'op').isSampled()).toBe(false); + api = realApi(); + expect(facade.startTransaction('a', 'op').isSampled()).toBe(true); + }); +}); diff --git a/packages/performance/src/facade.ts b/packages/performance/src/facade.ts new file mode 100644 index 00000000..c8298392 --- /dev/null +++ b/packages/performance/src/facade.ts @@ -0,0 +1,101 @@ +import type { PerformanceApi } from './controller'; +import type { RecordChildSpanOptions, Span, SpanStatus, Transaction } from './span'; + +// The top-level performance calls the mobile SDKs expose as `Bugsee.startTransaction` / +// `Bugsee.startSpan` / `Bugsee.getActiveSpan` (Android Bugsee.java, iOS Bugsee.h). Thin pass-throughs over +// the `ext('performance')` API; when performance is not wired (or switched off) they hand back a no-op +// transaction/span, as the mobile SDKs do, so calling code never needs a guard. + +/** The performance calls available directly on the launched client. */ +export interface PerformanceFacade { + /** Start a transaction (the root of a trace). A no-op transaction when performance is disabled. */ + startTransaction( + name: string, + operation: string, + attributes?: Record, + ): Transaction; + /** + * Start a span. When a span is active it becomes the parent; otherwise the span is the root of a new + * transaction named after `description` (or the operation). A no-op span when performance is disabled. + */ + startSpan(operation: string, description?: string): Span; + /** The active span, if any. */ + getActiveSpan(): Span | undefined; +} + +function createNoopSpan(operation: string, description: string | undefined): Span { + let finished = false; + const span: Span = { + setName: () => span, + setDescription: () => span, + setAttribute: () => span, + setStatus: () => span, + startChildSpan: (childOperation: string, childDescription?: string) => + createNoopSpan(childOperation, childDescription), + recordChildSpan: (_operation: string, _options: RecordChildSpanOptions) => undefined, + getSpanId: () => '', + getTraceId: () => '', + getStatus: (): SpanStatus => 'UNKNOWN', + getOperation: () => operation, + getDescription: () => description, + getAttributes: () => ({}), + isFinished: () => finished, + finish: () => { + finished = true; + }, + }; + return span; +} + +/** A transaction that records nothing: what the facade returns when performance is not available. */ +export function createNoopTransaction( + name: string, + operation: string, + description?: string, +): Transaction { + const span = createNoopSpan(operation, description); + const transaction: Transaction = { + ...span, + setName: () => transaction, + setDescription: () => transaction, + setAttribute: () => transaction, + setStatus: () => transaction, + getName: () => name, + isSampled: () => false, + }; + return transaction; +} + +/** + * Build the facade over a lazily-resolved {@link PerformanceApi}. `getApi` returns `undefined` when the + * extension is not wired; it is consulted on every call, so a late registration is picked up. + */ +export function createPerformanceFacade( + getApi: () => PerformanceApi | undefined, +): PerformanceFacade { + return { + startTransaction(name, operation, attributes) { + const api = getApi(); + if (api === undefined) return createNoopTransaction(name, operation); + const transaction = api.startTransaction({ name, operation }); + if (attributes !== undefined) { + for (const [key, value] of Object.entries(attributes)) transaction.setAttribute(key, value); + } + return transaction; + }, + startSpan(operation, description) { + const api = getApi(); + if (api === undefined) return createNoopSpan(operation, description); + const parent = api.getActiveSpan(); + if (parent !== undefined) return parent.startChildSpan(operation, description); + return api.startTransaction({ + name: description ?? operation, + operation, + ...(description !== undefined ? { description } : {}), + }); + }, + getActiveSpan() { + return getApi()?.getActiveSpan(); + }, + }; +} diff --git a/packages/performance/src/index.ts b/packages/performance/src/index.ts index 02d0a919..4f7bc637 100644 --- a/packages/performance/src/index.ts +++ b/packages/performance/src/index.ts @@ -22,6 +22,7 @@ export { type PerformanceExtension, type PerformanceExtensionOptions, } from './extension'; +export { createNoopTransaction, createPerformanceFacade, type PerformanceFacade } from './facade'; export { collectHttpSpans, type HttpSpanCollectorDeps, diff --git a/packages/vercel-edge/src/edge-context.ts b/packages/vercel-edge/src/edge-context.ts index de232b91..66e642a4 100644 --- a/packages/vercel-edge/src/edge-context.ts +++ b/packages/vercel-edge/src/edge-context.ts @@ -1,5 +1,5 @@ import type { AttributeValue, RequestContext } from '@bugsee/core'; -import { neverThrow } from '@bugsee/core'; +import { asInternalClient, neverThrow } from '@bugsee/core'; import { randomId } from '@bugsee/util'; import { type Bugsee, EdgeContextStoreToken } from './launch'; import type { EdgeRequestContextStore } from './request-context-store'; @@ -106,7 +106,10 @@ export async function runInEdgeContext( // "SDK internal failure" instead of what actually went wrong. `neverThrow` also attaches a rejection // handler to the returned promise, so the fire-and-forget call cannot surface as an unhandled // rejection in the isolate one tick later. - neverThrow(() => client.logException(error, { mechanism: 'uncaught' }), options.onError); + neverThrow( + () => asInternalClient(client).logException(error, { mechanism: 'uncaught' }), + options.onError, + ); throw error; } }; diff --git a/packages/web-adapter/src/adapter.ts b/packages/web-adapter/src/adapter.ts index a3a8ac33..42dfc3f4 100644 --- a/packages/web-adapter/src/adapter.ts +++ b/packages/web-adapter/src/adapter.ts @@ -1,5 +1,11 @@ import type { Bugsee } from '@bugsee/browser'; -import { getCarrierClient, guarded, type LogExceptionOptions, neverThrow } from '@bugsee/core'; +import { + asInternalClient, + getCarrierClient, + guarded, + type InternalLogExceptionOptions, + neverThrow, +} from '@bugsee/core'; import type { PerformanceApi } from '@bugsee/performance'; // Re-exported so each adapter can contain its OWN pre-report work (component-name lookup, route extraction) @@ -13,7 +19,7 @@ export { guarded, neverThrow }; // call (+ mechanism/labels), and the performance naming seam live in ONE place instead of duplicated 5×. /** Capture mechanism for an adapter error report (the `logException` mechanism vocabulary). */ -export type AdapterMechanism = NonNullable; +export type AdapterMechanism = NonNullable; /** Base options shared by every adapter entry point. */ export interface AdapterClientOptions { @@ -72,7 +78,7 @@ export function reportError(error: unknown, options: ReportErrorOptions = {}): v neverThrow(() => { const client = resolveClient(options.getClient); if (client === undefined) return; - return client.logException(error, { + return asInternalClient(client).logException(error, { mechanism: options.mechanism ?? 'uncaught', ...(options.labels !== undefined ? { labels: options.labels } : {}), }); diff --git a/packages/webworker/src/event.ts b/packages/webworker/src/event.ts index 5b174e0c..f7c70054 100644 --- a/packages/webworker/src/event.ts +++ b/packages/webworker/src/event.ts @@ -1,4 +1,4 @@ -import { neverThrow } from '@bugsee/core'; +import { asInternalClient, neverThrow } from '@bugsee/core'; import type { Bugsee } from './launch'; // Service Worker event flush (design §3.x — the SW analog of the edge ctx.waitUntil). A Service Worker is @@ -73,7 +73,10 @@ export function withBugseeEvent( const onError = resolved.onError; const flushTimeoutMs = resolved.flushTimeoutMs ?? SW_FLUSH_TIMEOUT_MS; const capture = (error: unknown): void => { - neverThrow(() => client.logException(error, { mechanism: 'uncaught' }), onError); + neverThrow( + () => asInternalClient(client).logException(error, { mechanism: 'uncaught' }), + onError, + ); }; // A flush that can only resolve — see the note above on rejected extend-lifetime promises. const flushed = async (): Promise => {