diff --git a/.oxlintrc.json b/.oxlintrc.json index 2fb3db6dc..223cfd93d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -124,11 +124,16 @@ } }, // The rule points at an Effect primitive, so it only applies where Effect is on - // the dependency list. These three ship to customers with no runtime deps at all - // (`@maple/browser` and `@maple/browser-session` are under a bundle-size budget), - // so `try`/`catch` is the only error handling they have. + // the dependency list. These ship to customers with no runtime deps at all + // (`@maple/browser`, and `@maple/browser-session` and `@maple/sdk-core` bundled into + // it, are under a bundle-size budget), so `try`/`catch` is the only error handling they have. { - "files": ["packages/browser/**", "packages/browser-session/**", "packages/clickhouse-cli/**"], + "files": [ + "packages/browser/**", + "packages/browser-session/**", + "packages/sdk-core/**", + "packages/clickhouse-cli/**" + ], "rules": { "maple/no-try-catch": "off" } diff --git a/bun.lock b/bun.lock index 2ee3f61ad..df4982efb 100644 --- a/bun.lock +++ b/bun.lock @@ -658,6 +658,7 @@ }, "devDependencies": { "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@vitest/browser-playwright": "catalog:", @@ -881,6 +882,20 @@ "vitest": "catalog:", }, }, + "packages/sdk-core": { + "name": "@maple/sdk-core", + "version": "0.1.0", + "dependencies": { + "@maple/browser-session": "workspace:*", + "web-vitals": "^6.2.2", + }, + "devDependencies": { + "@vitest/browser-playwright": "catalog:", + "playwright": "catalog:", + "typescript": "catalog:tooling", + "vitest": "catalog:", + }, + }, "packages/ui": { "name": "@maple/ui", "dependencies": { @@ -1626,6 +1641,8 @@ "@maple/scraper": ["@maple/scraper@workspace:apps/scraper"], + "@maple/sdk-core": ["@maple/sdk-core@workspace:packages/sdk-core"], + "@maple/ui": ["@maple/ui@workspace:packages/ui"], "@maple/unitflow": ["@maple/unitflow@workspace:lib/unitflow"], diff --git a/knip.json b/knip.json index abbe3554d..7a70bb13d 100644 --- a/knip.json +++ b/knip.json @@ -100,7 +100,8 @@ "entry": ["src/drain/index.ts"] }, "packages/browser": { - "ignoreDependencies": ["rrweb"] + // Imported by the private packages it bundles (browser-session, sdk-core); listed here so the build keeps them external. + "ignoreDependencies": ["rrweb", "web-vitals"] } }, "rules": { diff --git a/packages/browser/package.json b/packages/browser/package.json index 3bde1201b..ea627e274 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@vitest/browser-playwright": "catalog:", diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index cfbdad260..c4245c53e 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -24,7 +24,9 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { /** - * 44 since 2026-09: `tracing.captureHeaders` in the request hooks (~0.3 kB). + * 44.5 since 2026-09: `@maple/sdk-core`'s page-wide coordination with the Effect + * SDK (one error is one issue across SDK copies) and shared option resolution (~0.4 kB). + * 44: `tracing.captureHeaders` in the request hooks (~0.3 kB). * 43.5: the offline queue's exporter wrapper (~0.2 kB). 43: XHR spans and the HTTP status policy, which must patch * before the app's first request (~1.5 kB). Document timing went to the * deferred chunk instead. 42: error filters and cause chains. 41 before that: @@ -32,7 +34,7 @@ const BUDGET = { * code, the rest chunk-split overhead now that a second chunk shares the OTel * core). Was 38 for navigation spans. */ - eager: 44, + eager: 44.5, /** * Every page load, after `init()`, off the critical path: the OTel logs SDK * and exporter, document timing, `web-vitals` (~3.3 kB), breadcrumbs, @@ -66,9 +68,11 @@ const BUDGET = { * (~0.5 kB), both needed before the deferred chunk lands. 17 for error * filters and cause chains (~0.8 kB), which run on the capture path. 17.5 * for `errors.captureHttpStatus`, applied by the span exporter. 18 for the - * offline queue's exporter wrapper, 18.5 for `tracing.captureHeaders`. + * offline queue's exporter wrapper, 18.5 for `tracing.captureHeaders`. 19 for + * `@maple/sdk-core`'s page-wide coordination with the Effect SDK (~0.45 kB). + * 19.5 for the linear stack-frame parser that replaced a backtracking regex (CodeQL). */ - firstParty: 18.5, + firstParty: 19.5, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index efc96152e..98b89fba2 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -7,16 +7,19 @@ import { resolveIngestEndpoint, warnIfKeylessMapleIngest, } from "@maple/browser-session" -import type { ErrorFilterOptions } from "./error-filters" -import { type HeaderCapture, resolveHeaderCapture } from "./http-headers" - -/** Ingest keeps 1,024 bytes of a session-event attribute; this leaves room for the cut marker. */ -const MAX_BODY_LENGTH = 1_000 +import { + type ReplayOptions, + type ResolvedSignalOptions, + resolveReplayOptions, + resolveSignalOptions, + type SignalOptions, + type TracingSignalOptions, +} from "@maple/sdk-core" -export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error" +export type { ConsoleLevel } from "@maple/sdk-core" -/** Public configuration for `MapleBrowser.init`. */ -export interface MapleBrowserConfig { +/** Public configuration for `MapleBrowser.init`. The signal groups are shared with the Effect SDK. */ +export interface MapleBrowserConfig extends SignalOptions { /** * Public ingest key (`maple_pk_...`), sent as `Authorization: Bearer …` and * nothing else. Leave it unset when a proxy at `endpoint` adds auth: tracing @@ -51,7 +54,7 @@ export interface MapleBrowserConfig { readonly userId?: string | null | undefined /** End-user identity attached to sessions and browser spans. */ readonly user?: MapleIdentity | undefined - readonly tracing?: { + readonly tracing?: TracingSignalOptions & { /** Default true. */ readonly enabled?: boolean /** @@ -79,88 +82,8 @@ export interface MapleBrowserConfig { * `traceparent` header in CORS. Example: `[/^https:\/\/api\.example\.com\//]`. */ readonly propagateTraceHeaderCorsUrls?: ReadonlyArray - /** - * Fraction of sessions whose traces are exported, 0–1. Default 1. Decided - * once per session, so a sampled session keeps every trace. Error spans are - * always exported. - */ - readonly sampleRate?: number - /** - * Request and response headers to record on `fetch`/XHR spans, as - * `http.request.header.` / `http.response.header.`, e.g. - * `{ response: ["x-request-id", "x-cache"] }`. `authorization`, `cookie` - * and `set-cookie` are never recorded. XHR spans get response headers only. - */ - readonly captureHeaders?: { - readonly request?: ReadonlyArray - readonly response?: ReadonlyArray - } - /** - * Span main-thread frames of 100ms or more (`longAnimationFrame`, with the - * script that ran longest; `longtask` where that API is missing). Default false. - */ - readonly longFrames?: boolean - /** Span interactions of 200ms or more (`interaction click`, ...), split into input delay, processing and presentation. Default false. */ - readonly slowInteractions?: boolean - } - /** - * Report Core Web Vitals (LCP, CLS, INP, FCP, TTFB) as `browser.web_vital` - * log events. Default true. - */ - readonly webVitals?: boolean - /** - * Keep the last clicks, inputs, navigations and console lines in memory, and - * export them as logs linked to the error when one is recorded. Default true. - */ - readonly breadcrumbs?: boolean - readonly logs?: { - /** Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. Default none. */ - readonly captureConsole?: ReadonlyArray - } - readonly reporting?: { - /** Content Security Policy violations as `maple.browser.csp_violation` WARN logs. Default true. */ - readonly csp?: boolean - /** Browser deprecation and intervention reports as `maple.browser.report` WARN logs. Default false. */ - readonly browserReports?: boolean - } - readonly transport?: { - /** - * Keep span and log batches that could not be sent (the browser was - * offline) in IndexedDB for up to 24 hours, and send them once it is back - * online or on the next page load. Default false. - */ - readonly offline?: boolean - } - /** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */ - readonly errors?: ErrorFilterOptions - readonly replay?: { - /** Default true. */ - readonly enabled?: boolean - /** Fraction of sessions to record, 0–1. Default 1. */ - readonly sampleRate?: number - /** - * Fraction of the sessions not recorded that keep the last minute in - * memory, and upload it and record the rest of the session only if an - * error happens. 0–1, default 0. - */ - readonly onErrorSampleRate?: number - /** - * Record `` content at this many frames per second, e.g. 2. Off by - * default: it is heavy. Never with `privacy.maskAllText`, since canvas pixels can hold text. - */ - readonly canvasFps?: number - /** - * Keep request and response bodies (text and JSON only) on the replay's - * network events for these URLs, cut to `maxLength` characters. Ingest - * keeps at most 1,024 bytes of each, so `maxLength` is capped at 1,000 - * (the default). Nothing is captured for other URLs, or with - * `privacy.maskAllText`. - */ - readonly networkBodies?: { - readonly urls: ReadonlyArray - readonly maxLength?: number - } } + readonly replay?: ReplayOptions readonly privacy?: { /** Mask all `` values. Default true. */ readonly maskAllInputs?: boolean @@ -205,7 +128,7 @@ export interface MapleBrowserConfig { } } -export interface ResolvedConfig { +export interface ResolvedConfig extends ResolvedSignalOptions { readonly ingestKey: string | undefined readonly serviceName: string readonly endpoint: string @@ -219,14 +142,6 @@ export interface ResolvedConfig { readonly tracingInstrumentXhr: boolean readonly tracingCaptureErrors: boolean readonly propagateTraceHeaderCorsUrls: ReadonlyArray - readonly tracingSampleRate: number - readonly errorFilters: ErrorFilterOptions - readonly webVitals: boolean - readonly breadcrumbs: boolean - readonly captureConsole: ReadonlyArray - readonly reportCsp: boolean - readonly reportBrowser: boolean - readonly offlineQueue: boolean readonly replayEnabled: boolean readonly replaySampleRate: number readonly replayOnErrorSampleRate: number @@ -234,9 +149,6 @@ export interface ResolvedConfig { readonly networkBodies: | { readonly urls: ReadonlyArray; readonly maxLength: number } | undefined - readonly captureHeaders: HeaderCapture - readonly longFrames: boolean - readonly slowInteractions: boolean readonly maskAllInputs: boolean readonly maskAllText: boolean readonly persistVisitorId: boolean @@ -259,24 +171,6 @@ export function resolveIdentity(config: { return normalizeIdentity((config.user ?? config.userId) as IdentifyInput) } -/** - * A sample rate outside 0–1 (or not a number) is a typo, not a policy. Clamp it - * and say so, rather than recording everyone or no one without a word. - */ -function resolveSampleRate(option: string, raw: number | undefined): number { - if (raw === undefined) return 1 - if (typeof raw !== "number" || Number.isNaN(raw)) { - console.warn(`[maple] ${option} must be a number between 0 and 1; got ${String(raw)}. Using 1.`) - return 1 - } - if (raw < 0 || raw > 1) { - const clamped = Math.min(1, Math.max(0, raw)) - console.warn(`[maple] ${option} must be between 0 and 1; got ${raw}. Using ${clamped}.`) - return clamped - } - return raw -} - export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { const endpoint = resolveIngestEndpoint({ endpoints: [config.endpoint], regions: [config.region] }) warnIfKeylessMapleIngest({ @@ -285,7 +179,9 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { hasIngestKey: Boolean(config.ingestKey), hint: "Pass `ingestKey`, or point `endpoint` at a proxy that adds it.", }) + const replay = resolveReplayOptions(config.replay) return { + ...resolveSignalOptions(config), ingestKey: config.ingestKey, serviceName: config.serviceName, endpoint, @@ -298,33 +194,11 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { tracingInstrumentXhr: config.tracing?.instrumentXhr ?? true, tracingCaptureErrors: config.tracing?.captureErrors ?? true, propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [], - tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate), - errorFilters: config.errors ?? {}, - webVitals: config.webVitals ?? true, - breadcrumbs: config.breadcrumbs ?? true, - captureConsole: config.logs?.captureConsole ?? [], - reportCsp: config.reporting?.csp ?? true, - reportBrowser: config.reporting?.browserReports ?? false, - offlineQueue: config.transport?.offline ?? false, - replayEnabled: config.replay?.enabled ?? true, - replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), - canvasFps: config.replay?.canvasFps, - networkBodies: config.replay?.networkBodies?.urls.length - ? { - urls: config.replay.networkBodies.urls, - maxLength: Math.min( - MAX_BODY_LENGTH, - config.replay.networkBodies.maxLength ?? MAX_BODY_LENGTH, - ), - } - : undefined, - captureHeaders: resolveHeaderCapture(config.tracing?.captureHeaders), - longFrames: config.tracing?.longFrames ?? false, - slowInteractions: config.tracing?.slowInteractions ?? false, - replayOnErrorSampleRate: - config.replay?.onErrorSampleRate === undefined - ? 0 - : resolveSampleRate("replay.onErrorSampleRate", config.replay.onErrorSampleRate), + replayEnabled: replay.enabled, + replaySampleRate: replay.sampleRate, + replayOnErrorSampleRate: replay.onErrorSampleRate, + canvasFps: replay.canvasFps, + networkBodies: replay.networkBodies, maskAllInputs: config.privacy?.maskAllInputs ?? true, maskAllText: config.privacy?.maskAllText ?? false, persistVisitorId: config.privacy?.persistVisitorId ?? true, diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts index 53cdebf23..7f9a80d93 100644 --- a/packages/browser/src/deferred/index.ts +++ b/packages/browser/src/deferred/index.ts @@ -1,36 +1,122 @@ // Everything that can start a moment after `init()` without losing data lives // behind this chunk, so it stays off the eager bundle every page load pays for. -import type { SpanContext } from "@opentelemetry/api" +// The collectors are shared with the Effect SDK; this file adapts them to OTel. +import { hasConsent, ingestHeaders, sdkHint } from "@maple/browser-session" +import { claimPageSignal, onErrorRecorded, type PageSignal, type SpanLink } from "@maple/sdk-core" +import { flushBreadcrumbs, startBreadcrumbs } from "@maple/sdk-core/browser/breadcrumbs" +import { type RecordChild, recordDocumentTiming } from "@maple/sdk-core/browser/document-timing" +import { startOfflineQueue } from "@maple/sdk-core/browser/offline" +import { type StartSpan, startPerf } from "@maple/sdk-core/browser/perf" +import { startReports } from "@maple/sdk-core/browser/reports" +import { startWebVitals } from "@maple/sdk-core/browser/web-vitals" +import { context, type Span, trace, TraceFlags } from "@opentelemetry/api" +import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer" import type { ResolvedConfig } from "../config" -import { onErrorRecorded } from "../errors" -import { onDocumentPageload } from "../navigation" +import { emitLog } from "../logs" +import { navigationSpanAt, onDocumentPageload } from "../navigation" import { attachSpanStash } from "../offline" -import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs" -import { recordDocumentTiming } from "./document-timing" +import { liveMapleTracer } from "../tracing" +import { SDK_NAME, SDK_VERSION } from "../version" import { startLogs } from "./logs" -import { startOfflineQueue } from "./offline" -import { startPerf } from "./perf" -import { startReports } from "./reports" -import { startWebVitals } from "./web-vitals" + +/** Jank spans nest under the navigation open when they began, on Maple's own provider. */ +const perfSpan: StartSpan = (name, startMs, endMs, attributes) => { + const tracer = hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : undefined + if (!tracer) return + const navigation = navigationSpanAt(startMs) + const parent = navigation ? trace.setSpan(context.active(), navigation) : context.active() + tracer.startSpan(name, { startTime: startMs, attributes }, parent).end(endMs) +} + +/** Page signals this copy leased; another Maple SDK copy may already collect the rest. */ +function leaseSignals(wanted: Record): { + readonly has: (signal: PageSignal) => boolean + readonly release: () => void +} { + const releases = new Map void>() + for (const signal of PAGE_SIGNALS) { + const release = wanted[signal] ? claimPageSignal(signal) : undefined + if (release) releases.set(signal, release) + } + return { + has: (signal) => releases.has(signal), + release: () => { + for (const release of releases.values()) release() + }, + } +} + +const PAGE_SIGNALS: ReadonlyArray = [ + "breadcrumbs", + "browserReports", + "console", + "csp", + "longFrames", + "slowInteractions", + "webVitals", +] export function startDeferred(config: ResolvedConfig): () => Promise { - let pageload: SpanContext | undefined + let pageload: SpanLink | undefined onDocumentPageload((tracer, span) => { pageload = span.spanContext() - recordDocumentTiming(tracer, span) + // Sampled, not recording: the app has usually ended the span by the time this chunk lands. + if ((span.spanContext().traceFlags & TraceFlags.SAMPLED) === 0) return + const child: RecordChild = (parent, name, startMs, endMs, attributes) => { + const next = tracer.startSpan( + name, + { startTime: startMs, attributes }, + trace.setSpan(context.active(), parent), + ) + next.end(endMs) + return next + } + recordDocumentTiming(child, span) }) - // Before the logs pipeline, so vitals reported on page hide are emitted before its flush listener runs. - const stopVitals = config.webVitals ? startWebVitals(() => pageload) : () => {} - const stopBreadcrumbs = startBreadcrumbs({ + const leased = leaseSignals({ + webVitals: config.webVitals, breadcrumbs: config.breadcrumbs, - captureConsole: config.captureConsole, + console: config.captureConsole.length > 0, + csp: config.reportCsp, + browserReports: config.reportBrowser, + longFrames: config.longFrames, + slowInteractions: config.slowInteractions, + }) + const stopVitals = leased.has("webVitals") ? startWebVitals(emitLog, () => pageload) : () => {} + const stopBreadcrumbs = startBreadcrumbs(emitLog, { + breadcrumbs: leased.has("breadcrumbs"), + captureConsole: leased.has("console") ? config.captureConsole : [], }) const stopErrorListener = onErrorRecorded(flushBreadcrumbs) - const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser }) - const stopPerf = startPerf({ longFrames: config.longFrames, slowInteractions: config.slowInteractions }) - const offline = config.offlineQueue ? startOfflineQueue(config) : undefined - attachSpanStash(offline?.stashSpans) - const stopLogs = startLogs(config, offline?.stashLogs) + const stopReports = startReports(emitLog, { + csp: leased.has("csp"), + browserReports: leased.has("browserReports"), + }) + const stopPerf = startPerf(perfSpan, { + longFrames: leased.has("longFrames"), + slowInteractions: leased.has("slowInteractions"), + }) + const offline = config.offlineQueue + ? startOfflineQueue({ + endpoint: config.endpoint, + headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }), + }) + : undefined + attachSpanStash( + offline && + ((spans) => { + const body = spans.length > 0 ? JsonTraceSerializer.serializeRequest(spans) : undefined + if (body) offline.stash("traces", body) + }), + ) + const stopLogs = startLogs( + config, + offline && + ((logs) => { + const body = logs.length > 0 ? JsonLogsSerializer.serializeRequest(logs) : undefined + if (body) offline.stash("logs", body) + }), + ) return async () => { stopVitals() onDocumentPageload(undefined) @@ -38,6 +124,7 @@ export function startDeferred(config: ResolvedConfig): () => Promise { stopBreadcrumbs() stopReports() stopPerf() + leased.release() // The logs' last flush may fail into the offline queue, so it closes after them. await stopLogs() attachSpanStash(undefined) diff --git a/packages/browser/src/deferred/logs.ts b/packages/browser/src/deferred/logs.ts index 42a9726b0..72f9ddb27 100644 --- a/packages/browser/src/deferred/logs.ts +++ b/packages/browser/src/deferred/logs.ts @@ -86,9 +86,7 @@ export function startLogs( body: record.body, attributes: record.attributes, timestamp: record.timestamp, - context: record.spanContext - ? trace.setSpanContext(ROOT_CONTEXT, record.spanContext) - : ROOT_CONTEXT, + context: record.link ? trace.setSpanContext(ROOT_CONTEXT, record.link) : ROOT_CONTEXT, }), ) return async () => { diff --git a/packages/browser/src/deferred/perf.browser.test.ts b/packages/browser/src/deferred/perf.browser.test.ts index 32561fc05..1e24577e3 100644 --- a/packages/browser/src/deferred/perf.browser.test.ts +++ b/packages/browser/src/deferred/perf.browser.test.ts @@ -20,7 +20,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ })) const { MapleBrowser } = await import("../index") -const { interactionKey, onLongFrame } = await import("./perf") +const { interactionKey, onLongFrame } = await import("@maple/sdk-core/browser/perf") describe("interactionKey", () => { it("skips non-interactions, groups by id, and still keys events from engines without ids", () => { diff --git a/packages/browser/src/error-causes.test.ts b/packages/browser/src/error-causes.test.ts index 91d971e8e..70f56b723 100644 --- a/packages/browser/src/error-causes.test.ts +++ b/packages/browser/src/error-causes.test.ts @@ -1,65 +1,12 @@ import { describe, expect, it } from "vitest" -import { exceptionOf, stackWithCauses } from "./error-causes" +import { exceptionOf } from "./error-causes" -const withStack = (error: Error, frames: string): Error => { - error.stack = `${error.name}: ${error.message}\n${frames}` - return error -} - -describe("stackWithCauses", () => { - it("returns the stack untouched when nothing is linked", () => { - const error = withStack(new Error("top"), " at a (https://app.test/a.js:1:1)") - expect(stackWithCauses(error)).toBe(error.stack) +describe("exceptionOf", () => { + it("hands OTel the error itself when nothing is linked", () => { + const error = new Error("top") expect(exceptionOf(error)).toBe(error) }) - it("appends the cause chain after the error's own frames", () => { - const root = withStack(new TypeError("socket closed"), " at read (https://app.test/net.js:5:1)") - const error = withStack( - new Error("load failed", { cause: root }), - " at load (https://app.test/a.js:1:1)", - ) - expect(stackWithCauses(error)).toBe( - [ - "Error: load failed", - " at load (https://app.test/a.js:1:1)", - "Caused by: TypeError: socket closed", - " at read (https://app.test/net.js:5:1)", - ].join("\n"), - ) - }) - - it("renders a non-Error cause and the members of an AggregateError", () => { - const aggregate = new AggregateError([new Error("one"), "two"], "all failed") - aggregate.stack = "AggregateError: all failed" - const stack = stackWithCauses(new Error("outer", { cause: aggregate })) ?? "" - expect(stack).toContain("Caused by: AggregateError: all failed") - expect(stack).toContain("Caused by: Error: one") - expect(stack).toContain("Caused by: two") - }) - - it("stops at a cycle and at five linked errors", () => { - const a = new Error("a") - const b = new Error("b", { cause: a }) - Object.defineProperty(a, "cause", { value: b }) - expect(stackWithCauses(a)?.match(/Caused by/g)).toHaveLength(1) - - let deep = new Error("0") - for (let i = 1; i <= 10; i++) deep = new Error(String(i), { cause: deep }) - expect(stackWithCauses(deep)?.match(/Caused by/g)).toHaveLength(5) - }) - - it("never throws on a cause that cannot be turned into a string", () => { - const hostile = Object.create(null) - const throwing = { - toString() { - throw new Error("nope") - }, - } - expect(stackWithCauses(new Error("a", { cause: hostile }))).toContain("Caused by: [object Object]") - expect(stackWithCauses(new Error("b", { cause: throwing }))).toContain("Caused by: [object Object]") - }) - it("keeps a DOMException-style code, which OTel uses as exception.type", () => { const error = Object.assign(new Error("gone", { cause: new Error("why") }), { code: 20 }) expect(exceptionOf(error)).toMatchObject({ code: 20, name: "Error", message: "gone" }) diff --git a/packages/browser/src/error-causes.ts b/packages/browser/src/error-causes.ts index a4825ffe7..e9ae2546e 100644 --- a/packages/browser/src/error-causes.ts +++ b/packages/browser/src/error-causes.ts @@ -1,62 +1,6 @@ -// `error.cause` chains and `AggregateError.errors`, rendered into the stack -// trace as `Caused by:` blocks after the error's own frames, the way OTel Java -// records a Throwable. Fingerprints hash the top frames, so they stay put. +import { stackWithCauses } from "@maple/sdk-core" import type { Exception } from "@opentelemetry/api" -/** Deep enough for real wrapping chains, bounded against a cause cycle or a huge aggregate. */ -const MAX_LINKED = 5 - -function linkedErrors(error: Error): unknown[] { - const linked: unknown[] = [] - const seen = new Set([error]) - const queue: unknown[] = [error] - while (queue.length > 0 && linked.length < MAX_LINKED) { - const current = queue.shift() - const next: unknown[] = [] - // Guarded: `AggregateError` is missing on older engines, and a bare reference would throw. - if (typeof AggregateError === "function" && current instanceof AggregateError) - next.push(...current.errors) - if (current instanceof Error && current.cause !== undefined) next.push(current.cause) - for (const candidate of next) { - if (seen.has(candidate) || linked.length >= MAX_LINKED) continue - seen.add(candidate) - linked.push(candidate) - queue.push(candidate) - } - } - return linked -} - -/** V8 starts a stack with a `Name: message` header; the other engines start at the first frame. */ -function framesOf(error: Error): string { - const stack = error.stack ?? "" - const header = error.message ? `${error.name}: ${error.message}` : error.name - return stack.startsWith(header) ? stack.slice(header.length).replace(/^\n/, "") : stack -} - -/** A null-prototype object or a throwing `toString` must not break the error path. */ -function render(value: unknown): string { - try { - return String(value) - } catch { - return Object.prototype.toString.call(value) - } -} - -function describe(value: unknown): string { - if (!(value instanceof Error)) return `Caused by: ${render(value)}` - const frames = framesOf(value) - const header = `Caused by: ${value.name}: ${value.message}` - return frames ? `${header}\n${frames}` : header -} - -/** The stack trace to record for `error`, with its linked errors appended. */ -export function stackWithCauses(error: Error): string | undefined { - const linked = linkedErrors(error) - if (linked.length === 0) return error.stack - return [error.stack ?? `${error.name}: ${error.message}`, ...linked.map(describe)].join("\n") -} - /** * What to hand `span.recordException`: the error itself when nothing is linked, * otherwise a copy carrying the longer stack. `code` is kept because OTel diff --git a/packages/browser/src/error-filters.ts b/packages/browser/src/error-filters.ts index 7621ef7fa..e31fd8a6c 100644 --- a/packages/browser/src/error-filters.ts +++ b/packages/browser/src/error-filters.ts @@ -1,90 +1,10 @@ -// Client-side error filtering: runs before an error span exists, so a dropped -// error costs nothing and never reaches an issue. +// The filter `init()` configured, shared by the global handlers and `captureException`. +import { type ErrorFilter, type ErrorFilterOptions, makeErrorFilter } from "@maple/sdk-core" -import type { HttpStatusRange } from "./http-status" - -export type ErrorSource = "captureException" | "window.onerror" | "unhandledrejection" - -export interface ErrorFilterHint { - readonly source: ErrorSource - /** What was thrown, before it was normalized into an `Error`. */ - // BOUNDARY: a thrown value is unparsed by definition. - readonly originalError: unknown -} - -export interface ErrorFilterOptions { - /** Drop errors whose `Name: message` contains a string or matches a RegExp. */ - readonly ignore?: ReadonlyArray - /** Report only errors whose top frame's script URL matches one of these. Errors with no frames are kept. */ - readonly allowUrls?: ReadonlyArray - /** Drop errors whose top frame's script URL matches one of these. */ - readonly denyUrls?: ReadonlyArray - /** Return `false` to drop the error. Runs after the lists; if it throws, the error is kept. */ - readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean - /** - * HTTP response statuses that make a `fetch`/XHR span an error (and so an - * issue), e.g. `[[500, 599]]`. Default none: a response status alone is not - * an error, and a network failure always is. - */ - readonly captureHttpStatus?: ReadonlyArray - /** - * Drop errors thrown from browser extensions and the benign `ResizeObserver - * loop` notices. Default true. - */ - readonly defaultFilters?: boolean -} - -const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\// -const BENIGN_MESSAGES = [/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/] - -// `at fn (url:1:2)`, `at url:1:2` (V8) and `fn@url:1:2` (SpiderMonkey, JavaScriptCore). -const FRAME_URL = /(?:^\s*at (?:.*?\()?|@)([a-z][\w+.-]*:\/\/[^\s()]+?)(?::\d+){1,2}\)?\s*$/i - -/** The script URL of each stack frame, top first. */ -export function frameUrls(stack: string | undefined): string[] { - if (!stack) return [] - const urls: string[] = [] - for (const line of stack.split("\n")) { - const url = FRAME_URL.exec(line)?.[1] - if (url) urls.push(url) - } - return urls -} - -const matches = (value: string, patterns: ReadonlyArray): boolean => - patterns.some((pattern) => { - if (typeof pattern === "string") return value.includes(pattern) - // A `g`/`y` regex is stateful: `test` advances `lastIndex`, so reset it first. - pattern.lastIndex = 0 - return pattern.test(value) - }) - -let options: ErrorFilterOptions = {} +let filter: ErrorFilter = makeErrorFilter() export function configureErrorFilters(next: ErrorFilterOptions | undefined): void { - options = next ?? {} + filter = makeErrorFilter(next) } -/** - * Whether `error` should be reported. `frameUrl`, when given, is the top frame's - * script URL (`window.onerror`'s filename). Otherwise only a thrown `Error` has - * frames of its own: the stack of an `Error` wrapped around anything else points - * at this SDK, so no URL list applies to it. - */ -export function shouldCapture(error: Error, hint: ErrorFilterHint, frameUrl?: string): boolean { - const text = `${error.name}: ${error.message}` - const topUrl = frameUrl ?? (hint.originalError instanceof Error ? frameUrls(error.stack)[0] : undefined) - if (options.defaultFilters !== false) { - if (matches(error.message, BENIGN_MESSAGES)) return false - if (topUrl && EXTENSION_URL.test(topUrl)) return false - } - if (options.ignore && matches(text, options.ignore)) return false - if (topUrl && options.denyUrls && matches(topUrl, options.denyUrls)) return false - if (topUrl && options.allowUrls?.length && !matches(topUrl, options.allowUrls)) return false - if (!options.beforeCapture) return true - try { - return options.beforeCapture(error, hint) !== false - } catch { - return true - } -} +export const shouldCapture: ErrorFilter = (error, hint, frameUrl) => filter(error, hint, frameUrl) diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 699d99efc..e4e784deb 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -10,13 +10,23 @@ // Error. That is the shape `error_events_mv` fingerprints on, so these arrive in // error tracking beside server-side errors rather than in a separate silo. import { hasConsent, scrubUrl } from "@maple/browser-session" -import { context, type Span, type SpanContext, SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { + asError, + type ErrorSource, + markReported, + notifyErrorRecorded, + resetPageForTests, + wasReported, +} from "@maple/sdk-core" +import { context, type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api" import { exceptionOf } from "./error-causes" -import { type ErrorSource, shouldCapture } from "./error-filters" +import { shouldCapture } from "./error-filters" import { keepContext } from "./sampling" import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" +export { onErrorRecorded } from "@maple/sdk-core" + export interface CaptureExceptionOptions { /** Span name. Default `"exception"`. */ readonly name?: string | undefined @@ -24,46 +34,16 @@ export interface CaptureExceptionOptions { readonly attributes?: Record | undefined } -const asError = (value: unknown): Error => { - if (value instanceof Error) return value - if (typeof value === "string") return new Error(value) - if (typeof value === "object" && value !== null) { - const message = (value as { readonly message?: unknown }).message - if (typeof message === "string") return new Error(message) - } - // A rejected promise can carry literally anything. `String` keeps a number or - // a boolean legible; an unrenderable object still produces one grouped issue - // rather than throwing inside the error handler. - try { - return new Error(String(value)) - } catch { - return new Error("Unknown error") - } -} - /** - * Errors already reported, by identity. Module-level so the global handlers and - * `captureException` share it: a framework boundary that reports an error and - * then rethrows it would otherwise produce two issues for one crash. + * Errors already reported, by identity, page-wide: a framework boundary that + * reports an error and then rethrows it, or both Maple SDKs on one page, would + * otherwise produce two issues for one crash. */ -let reported = new WeakSet() - -type ErrorRecordedListener = (spanContext: SpanContext) => void -/** Told about every recorded error: breadcrumbs export their trail, a buffered replay keeps itself. */ -const errorListeners = new Set() - -export function onErrorRecorded(listener: ErrorRecordedListener): () => void { - errorListeners.add(listener) - return () => errorListeners.delete(listener) -} - -/** Whether this exact error object was already recorded. */ -const alreadyReported = (error: unknown): boolean => - typeof error === "object" && error !== null && reported.has(error) +const alreadyReported = wasReported /** Test seam. */ export function resetReportedErrorsForTests(): void { - reported = new WeakSet() + resetPageForTests() } /** @@ -78,16 +58,9 @@ export function recordFailure(span: Span, error: unknown): void { const normalized = asError(error) if (!alreadyReported(error)) { const exported = span.isRecording() && hasConsent() - if (exported && typeof error === "object" && error !== null) reported.add(error) + if (exported) markReported(error) span.recordException(exceptionOf(normalized)) - if (exported) { - for (const listener of errorListeners) { - // A listener must never turn one error into another. - try { - listener(span.spanContext()) - } catch {} - } - } + if (exported) notifyErrorRecorded(span.spanContext()) } span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) } @@ -155,7 +128,7 @@ export function setupErrorCapture(): () => void { // One error must not become two issues. The same throw can reach both // handlers (a rejected promise whose reason is later rethrown), and a host // app's own boundary may report it through `captureException` as well: - // `alreadyReported` is shared with that path. + // `alreadyReported` is shared with that path, and with the other Maple SDK. const onError = (event: ErrorEvent): void => { // A cross-origin script surfaces as a bare "Script error." with no error // object and no usable frames. It fingerprints to one meaningless issue @@ -176,6 +149,7 @@ export function setupErrorCapture(): () => void { // every uncaught error in the attribute list. ...(event.filename ? { "code.file.path": event.filename } : undefined), ...(event.lineno ? { "code.line.number": event.lineno } : undefined), + ...(event.colno ? { "code.column.number": event.colno } : undefined), }, }, "window.onerror", diff --git a/packages/browser/src/http-headers.ts b/packages/browser/src/http-headers.ts index 66efb5f97..fb97367a7 100644 --- a/packages/browser/src/http-headers.ts +++ b/packages/browser/src/http-headers.ts @@ -1,29 +1,5 @@ -// Allowlisted request/response headers as the HTTP semconv span attributes -// `http.request.header.` / `http.response.header.` (string arrays). import type { Span } from "@opentelemetry/api" -/** Never recorded, even when listed: they carry credentials. */ -const CREDENTIAL_HEADERS = new Set([ - "authorization", - "proxy-authorization", - "cookie", - "set-cookie", - "x-api-key", -]) - -export interface HeaderCapture { - readonly request: ReadonlyArray - readonly response: ReadonlyArray -} - -export function resolveHeaderCapture( - raw: { readonly request?: ReadonlyArray; readonly response?: ReadonlyArray } | undefined, -): HeaderCapture { - const clean = (names: ReadonlyArray | undefined) => - (names ?? []).map((name) => name.toLowerCase()).filter((name) => !CREDENTIAL_HEADERS.has(name)) - return { request: clean(raw?.request), response: clean(raw?.response) } -} - /** `getAllResponseHeaders()` text as a lower-cased map: only the headers CORS exposes are in it. */ export function responseHeaders(raw: string): Map { const headers = new Map() diff --git a/packages/browser/src/http-status.ts b/packages/browser/src/http-status.ts index 760da03d1..751100c1c 100644 --- a/packages/browser/src/http-status.ts +++ b/packages/browser/src/http-status.ts @@ -1,36 +1,18 @@ -// One rule for when an HTTP client span is an error, whichever instrumentation -// made it. The fetch instrumentation leaves 4xx/5xx responses Unset; the XHR -// one marks every status >= 400 Error, and every Error span becomes an issue. -// A response status is an error only when the app lists it in -// `errors.captureHttpStatus`; a network failure always is. +// The OTel adapter for the shared HTTP status policy. The fetch instrumentation +// leaves 4xx/5xx responses Unset; the XHR one marks every status >= 400 Error, +// and every Error span becomes an issue, so both are brought to the one rule. +import { + type HttpStatusRange, + httpStatusError, + inStatusRanges, + type ReadAttribute, + responseStatus, +} from "@maple/sdk-core" import { type Attributes, type SpanStatus, SpanKind, SpanStatusCode } from "@opentelemetry/api" import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base" const HTTP_STATUS = /^\d{3}$/ -/** A status code, or an inclusive `[from, to]` range. */ -export type HttpStatusRange = number | readonly [number, number] - -const statusOf = (span: ReadableSpan): number | undefined => { - const value = span.attributes["http.response.status_code"] ?? span.attributes["http.status_code"] - return typeof value === "number" ? value : undefined -} - -const inRanges = (status: number, ranges: ReadonlyArray): boolean => - ranges.some((range) => - typeof range === "number" ? range === status : status >= range[0] && status <= range[1], - ) - -/** `GET https://api.example.com/users/42 -> 500`, without the query: ids are redacted by the issue fingerprint. */ -function failureMessage(span: ReadableSpan, status: number | string): string { - const method = span.attributes["http.request.method"] ?? span.attributes["http.method"] ?? "GET" - const url = String(span.attributes["url.full"] ?? span.attributes["http.url"] ?? "").replace( - /[?#].*$/, - "", - ) - return `${String(method)} ${url} -> ${status}` -} - /** An Error set only because of the response status: `error.type` is the status code itself. */ function isStatusOnlyError(span: ReadableSpan): boolean { return ( @@ -70,17 +52,18 @@ export class HttpStatusExporter implements SpanExporter { ) {} private apply(span: ReadableSpan): ReadableSpan { - const status = statusOf(span) - if (span.kind === SpanKind.CLIENT && status !== undefined && inRanges(status, this.captureStatus)) { + const read: ReadAttribute = (key) => span.attributes[key] + const status = responseStatus(read) + if ( + span.kind === SpanKind.CLIENT && + status !== undefined && + inStatusRanges(status, this.captureStatus) + ) { if (span.events.some((event) => event.name === "exception")) return span return withStatus( span, { code: SpanStatusCode.ERROR }, - { - ...span.attributes, - "error.type": String(status), - "error.message": failureMessage(span, status), - }, + { ...span.attributes, ...httpStatusError(read, status) }, ) } const errorType = span.attributes["error.type"] @@ -95,7 +78,7 @@ export class HttpStatusExporter implements SpanExporter { // A network failure (`TypeError`, `error`, `timeout`): the same message shape as a status error. return withStatus(span, span.status, { ...span.attributes, - "error.message": failureMessage(span, errorType), + "error.message": httpStatusError(read, errorType)["error.message"], }) } if (!isStatusOnlyError(span)) return span diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 582d8e2df..3caf60b91 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -12,10 +12,17 @@ export type { TrackProps, TraitValue, } from "@maple/browser-session" +export type { + ErrorFilterHint, + ErrorFilterOptions, + ErrorSource, + HttpStatusRange, + ReplayOptions, + SignalOptions, + TracingSignalOptions, +} from "@maple/sdk-core" export type { ConsoleLevel, MapleBrowserConfig } from "./config" -export type { ErrorFilterHint, ErrorFilterOptions, ErrorSource } from "./error-filters" export type { CaptureExceptionOptions } from "./errors" -export type { HttpStatusRange } from "./http-status" export type { MapleBrowserHandle } from "./init" export type { LogAttributeValue } from "./logs" export type { MapleLogger } from "./logger" diff --git a/packages/browser/src/logs.ts b/packages/browser/src/logs.ts index 4f4877d85..4617eda98 100644 --- a/packages/browser/src/logs.ts +++ b/packages/browser/src/logs.ts @@ -2,29 +2,16 @@ // to the active span at emit time, until the deferred chunk attaches the OTel // LoggerProvider that exports them. import { hasConsent, readSessionSink } from "@maple/browser-session" -import { context, type SpanContext, trace } from "@opentelemetry/api" +import type { LogAttributeValue, SignalLogRecord, SpanLink } from "@maple/sdk-core" +import { context, trace } from "@opentelemetry/api" -export type LogAttributeValue = string | number | boolean +export type { LogAttributeValue } from "@maple/sdk-core" +export { Severity } from "@maple/sdk-core" -/** OTel severity numbers for the levels this SDK emits. */ -export const Severity = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 } as const - -export interface MapleLogRecord { - /** Set for a log-based event (`LogRecord.event_name`); absent for a plain log line. */ - readonly eventName?: string | undefined - readonly severityNumber: number - readonly severityText: string - readonly body?: string | undefined - readonly attributes?: Readonly> | undefined - /** Epoch ms when it happened. Defaults to now. */ - readonly timestamp?: number | undefined - /** The span to link to. Defaults to the active span. */ - readonly spanContext?: SpanContext | undefined -} - -export interface QueuedLogRecord extends MapleLogRecord { +export interface QueuedLogRecord extends SignalLogRecord { readonly timestamp: number readonly attributes: Readonly> + readonly link: SpanLink | undefined } type LogSink = (record: QueuedLogRecord) => void @@ -36,7 +23,7 @@ let queue: QueuedLogRecord[] = [] let sink: LogSink | undefined let getUserId: () => string | undefined = () => undefined -export function emitLog(record: MapleLogRecord): void { +export function emitLog(record: SignalLogRecord): void { if (!hasConsent()) return const sessionId = readSessionSink()?.sessionId const userId = getUserId() @@ -49,7 +36,7 @@ export function emitLog(record: MapleLogRecord): void { ...record, attributes, timestamp: record.timestamp ?? Date.now(), - spanContext: record.spanContext ?? trace.getSpanContext(context.active()), + link: record.link ?? trace.getSpanContext(context.active()), } if (sink) { sink(queued) diff --git a/packages/browser/src/navigation.ts b/packages/browser/src/navigation.ts index f572f83ea..6f538cb09 100644 --- a/packages/browser/src/navigation.ts +++ b/packages/browser/src/navigation.ts @@ -73,11 +73,6 @@ const tracedSpans = new WeakSet() */ const tracer = () => (hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : undefined) -/** The navigation span in flight, for work that should nest under it. */ -export function openNavigationSpan(): Span | undefined { - return navigation?.span -} - /** The open navigation span, if it had already started at `epochMs`: a child must not begin before its parent. */ export function navigationSpanAt(epochMs: number): Span | undefined { return navigation && navigation.startedAt <= epochMs ? navigation.span : undefined diff --git a/packages/browser/src/offline.browser.test.ts b/packages/browser/src/offline.browser.test.ts new file mode 100644 index 000000000..91972e5b7 --- /dev/null +++ b/packages/browser/src/offline.browser.test.ts @@ -0,0 +1,35 @@ +import { + BasicTracerProvider, + InMemorySpanExporter, + type ReadableSpan, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base" +import { afterEach, describe, expect, it } from "vitest" +import { attachSpanStash, OfflineSpanExporter, resetOfflineForTests } from "./offline" + +const finishedSpans = (...names: string[]): ReadableSpan[] => { + const memory = new InMemorySpanExporter() + const tracer = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memory)] }).getTracer( + "t", + ) + for (const name of names) tracer.startSpan(name).end() + return memory.getFinishedSpans() +} + +afterEach(() => resetOfflineForTests()) + +describe("OfflineSpanExporter", () => { + it("hands failed batches to the stash, holding them until it is attached", () => { + const failing = { + export: (_spans: ReadableSpan[], callback: (result: { code: number }) => void) => + callback({ code: 1 }), + shutdown: async () => {}, + } + const exporter = new OfflineSpanExporter(failing) + exporter.export(finishedSpans("early"), () => {}) + const stashed: ReadableSpan[][] = [] + attachSpanStash((batch) => stashed.push(batch)) + exporter.export(finishedSpans("late"), () => {}) + expect(stashed.map((batch) => batch[0]?.name)).toEqual(["early", "late"]) + }) +}) diff --git a/packages/browser/src/sampling.test.ts b/packages/browser/src/sampling.test.ts index e6257ecd3..5f9163e7b 100644 --- a/packages/browser/src/sampling.test.ts +++ b/packages/browser/src/sampling.test.ts @@ -2,44 +2,16 @@ import { clearSessionSink, publishSessionSink } from "@maple/browser-session" import { ROOT_CONTEXT, trace, TraceFlags } from "@opentelemetry/api" import { SamplingDecision } from "@opentelemetry/sdk-trace-base" import { afterEach, describe, expect, it } from "vitest" -import { keepContext, randomnessValue, rejectionThreshold, SessionSampler, sessionRoll } from "./sampling" +import { sessionRoll } from "@maple/sdk-core" +import { keepContext, SessionSampler } from "./sampling" +// The sampling math itself is tested in @maple/sdk-core; this is the OTel adapter. const TRACE_ID = "0af7651916cd43dd8448eb211c80319c" const parent = (flags: number) => trace.setSpanContext(ROOT_CONTEXT, { traceId: TRACE_ID, spanId: "b7ad6b7169203331", traceFlags: flags }) -/** The weight ingest derives from `th`: inverse of the acceptance probability. */ -const weightOf = (hex: string): number => 1 / (1 - Number.parseInt(hex, 16) / 16 ** hex.length) - afterEach(() => clearSessionSink()) -describe("rejectionThreshold", () => { - it("encodes the probability so ingest reads back its inverse as the weight", () => { - expect(weightOf(rejectionThreshold(0.1))).toBeCloseTo(10, 6) - expect(weightOf(rejectionThreshold(0.25))).toBeCloseTo(4, 6) - expect(rejectionThreshold(0.5)).toBe("8") - expect(rejectionThreshold(1)).toBe("0") - }) -}) - -describe("randomnessValue", () => { - it("maps a roll onto 56 bits so that rv >= th exactly when the roll is under the rate", () => { - const threshold = Math.round((1 - 0.25) * 2 ** 56) - expect(randomnessValue(0.2)).toBeGreaterThanOrEqual(threshold) - expect(randomnessValue(0.3)).toBeLessThan(threshold) - expect(randomnessValue(0)).toBe(2 ** 56 - 1) - }) -}) - -describe("sessionRoll", () => { - it("is stable per session and within [0, 1)", () => { - const roll = sessionRoll("3b0e7f4c-5a8e-4d0a-9b61-0c5a5d1e2f3a") - expect(roll).toBe(sessionRoll("3b0e7f4c-5a8e-4d0a-9b61-0c5a5d1e2f3a")) - expect(roll).toBeGreaterThanOrEqual(0) - expect(roll).toBeLessThan(1) - }) -}) - describe("SessionSampler", () => { it("samples a whole session or none of it, and marks sampled roots with th", () => { const sampler = new SessionSampler(0.5) diff --git a/packages/browser/src/sampling.ts b/packages/browser/src/sampling.ts index a2e6847d9..87e2ead5c 100644 --- a/packages/browser/src/sampling.ts +++ b/packages/browser/src/sampling.ts @@ -1,6 +1,6 @@ -// Head sampling, decided per session rather than per trace, so a session's -// replay never links to a trace that was dropped halfway through it. +// The OTel adapter for the shared per-session sampling decision. import { readSessionSink } from "@maple/browser-session" +import { sampleSession } from "@maple/sdk-core" import { type Context, createContextKey, @@ -23,46 +23,8 @@ export function keepContext(ctx: Context): Context { return base.setValue(KEEP, true) } -/** FNV-1a over the session id, mapped to [0, 1). */ -export function sessionRoll(sessionId: string): number { - let hash = 0x811c9dc5 - for (let i = 0; i < sessionId.length; i++) { - hash ^= sessionId.charCodeAt(i) - hash = Math.imul(hash, 0x01000193) - } - return (hash >>> 0) / 2 ** 32 -} - -const MAX_56 = 2 ** 56 - -/** 56 bits as the 14-hex-digit form `ot=th`/`ot=rv` use; `th` drops trailing zeros. */ -const hex56 = (value: number): string => value.toString(16).padStart(14, "0") - -/** - * The W3C `ot=th:` rejection threshold for a sampling probability: 56 bits, - * hex, trailing zeros dropped. Ingest reads it back as the span's weight. - */ -export function rejectionThreshold(probability: number): string { - return hex56(Math.round((1 - probability) * MAX_56)).replace(/0+$/, "") || "0" -} - -/** - * The session's randomness as the W3C `ot=rv:` value. The decision is `rv >= th`, - * so a downstream consistent-probability sampler reaches the same answer as this - * one instead of re-deciding from the trace id. - */ -export function randomnessValue(roll: number): number { - return Math.min(MAX_56 - 1, Math.floor((1 - roll) * MAX_56)) -} - export class SessionSampler implements Sampler { - private readonly threshold: string - private readonly thresholdValue: number - - constructor(private readonly rate: number) { - this.threshold = rejectionThreshold(rate) - this.thresholdValue = Math.round((1 - rate) * MAX_56) - } + constructor(private readonly rate: number) {} shouldSample(ctx: Context): SamplingResult { if (ctx.getValue(KEEP) === true) return { decision: SamplingDecision.RECORD_AND_SAMPLED } @@ -75,14 +37,12 @@ export class SessionSampler implements Sampler { : SamplingDecision.RECORD_AND_SAMPLED, } } - if (this.rate >= 1) return { decision: SamplingDecision.RECORD_AND_SAMPLED } - if (this.rate <= 0) return { decision: SamplingDecision.NOT_RECORD } - const sessionId = readSessionSink()?.sessionId - const rv = randomnessValue(sessionId ? sessionRoll(sessionId) : Math.random()) - if (rv < this.thresholdValue) return { decision: SamplingDecision.NOT_RECORD } + const decision = sampleSession(readSessionSink()?.sessionId, this.rate) + if (!decision.sampled) return { decision: SamplingDecision.NOT_RECORD } + // `createTraceState` parses the `ot=…` list member itself. return { decision: SamplingDecision.RECORD_AND_SAMPLED, - traceState: createTraceState().set("ot", `th:${this.threshold};rv:${hex56(rv)}`), + ...(decision.traceState ? { traceState: createTraceState(decision.traceState) } : undefined), } } diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json new file mode 100644 index 000000000..47663693d --- /dev/null +++ b/packages/sdk-core/package.json @@ -0,0 +1,32 @@ +{ + "name": "@maple/sdk-core", + "version": "0.1.0", + "private": true, + "description": "Framework-free behaviour shared by @maple-dev/browser and @maple-dev/effect-sdk: sampling, error filtering and rendering, the HTTP status policy, header capture, and the browser signal collectors. Private: consumers bundle it. The root export is runtime-agnostic; DOM collectors live behind ./browser/* so consumers can code-split them.", + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./browser/breadcrumbs": "./src/browser/breadcrumbs.ts", + "./browser/document-timing": "./src/browser/document-timing.ts", + "./browser/offline": "./src/browser/offline.ts", + "./browser/perf": "./src/browser/perf.ts", + "./browser/reports": "./src/browser/reports.ts", + "./browser/web-vitals": "./src/browser/web-vitals.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@maple/browser-session": "workspace:*", + "web-vitals": "^6.2.2" + }, + "devDependencies": { + "@vitest/browser-playwright": "catalog:", + "playwright": "catalog:", + "typescript": "catalog:tooling", + "vitest": "catalog:" + } +} diff --git a/packages/sdk-core/src/architecture.test.ts b/packages/sdk-core/src/architecture.test.ts new file mode 100644 index 000000000..7101b1aca --- /dev/null +++ b/packages/sdk-core/src/architecture.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest" + +/** + * Both SDKs bundle this package, so it stays neutral: no Effect (the browser + * SDK's eager chunk), no OpenTelemetry (the Effect SDK never ships it), no rrweb + * (only the lazy replay chunk may). The root is also bundled server-side, so it stays DOM-free. + */ +declare global { + interface ImportMeta { + glob: ( + pattern: string, + options: { query: "?raw"; import: "default"; eager: true }, + ) => Record + } +} + +const sources = import.meta.glob("./**/*.ts", { query: "?raw", import: "default", eager: true }) + +const files = Object.keys(sources) + .map((key) => key.replace(/^\.\//, "")) + .filter((file) => !file.endsWith(".test.ts")) + .sort() + +/** Every module specifier, whether imported, re-exported, dynamically imported or required, in either quote style. */ +const specifiers = (file: string): string[] => + [...(sources[`./${file}`] ?? "").matchAll(/(?:\bfrom|\bimport|\brequire)\s*\(?\s*["']([^"']+)["']/g)].map( + (m) => m[1] ?? "", + ) + +const isRuntime = (s: string): boolean => + s === "effect" || + s.startsWith("effect/") || + s.startsWith("@opentelemetry/") || + s === "rrweb" || + s.startsWith("rrweb-") || + s.startsWith("@rrweb/") || + // The replay entry pulls rrweb in; only an SDK's lazy chunk may import it. + s === "@maple/browser-session/replay" + +/** Browser globals the root tier must not touch: the Effect SDK's server presets bundle it. */ +const DOM_GLOBALS = /\b(?:window|document|navigator|location|localStorage|indexedDB)\s*[.[]/ + +/** Source with comments and string literals blanked, so `"window.onerror"` as a label is not a use. */ +const codeOnly = (source: string): string => + source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""') + +describe("module layout", () => { + it("sees the source tree", () => { + expect(files.length).toBeGreaterThan(10) + expect(files).toContain("browser/web-vitals.ts") + }) + + it("imports no SDK runtime and nothing that reaches rrweb", () => { + const offenders = files.flatMap((file) => + specifiers(file) + .filter(isRuntime) + .map((s) => `${file} → ${s}`), + ) + expect(offenders).toEqual([]) + }) + + it("keeps the root tier free of the DOM tier", () => { + const root = files.filter((file) => !file.startsWith("browser/")) + const imports = root.flatMap((file) => + specifiers(file) + .filter( + (s) => + s.startsWith("./browser") || + s.startsWith("@maple/browser-session") || + s === "web-vitals", + ) + .map((s) => `${file} → ${s}`), + ) + const globals = root.filter((file) => DOM_GLOBALS.test(codeOnly(sources[`./${file}`] ?? ""))) + expect(imports).toEqual([]) + expect(globals).toEqual([]) + }) +}) diff --git a/packages/browser/src/deferred/breadcrumbs.ts b/packages/sdk-core/src/browser/breadcrumbs.ts similarity index 71% rename from packages/browser/src/deferred/breadcrumbs.ts rename to packages/sdk-core/src/browser/breadcrumbs.ts index 3bdaea555..170272094 100644 --- a/packages/browser/src/deferred/breadcrumbs.ts +++ b/packages/sdk-core/src/browser/breadcrumbs.ts @@ -1,13 +1,11 @@ // The trail that led to an error: the last clicks, inputs, navigations and // console lines, held in memory and exported only when an error is recorded, -// as OTel log records linked to the error's span. Also forwards chosen console +// as log records linked to the error's span. Also forwards chosen console // levels as logs straight away (`logs.captureConsole`). import { onSessionEvent, scrubUrl, type SessionEvent } from "@maple/browser-session" import { installConsoleCapture } from "@maple/browser-session/console" -import type { SpanContext } from "@opentelemetry/api" -import { emitLog, Severity } from "../logs" - -import type { ConsoleLevel } from "../config" +import { type EmitLog, Severity, severityOf, type SpanLink } from "../log-record" +import type { ConsoleLevel } from "../options" const MAX_CRUMBS = 50 @@ -20,15 +18,8 @@ interface Crumb { readonly url?: string | undefined } -const severityOf = (level: string | undefined): { number: number; text: keyof typeof Severity } => { - if (level === "error") return { number: Severity.ERROR, text: "ERROR" } - if (level === "warn") return { number: Severity.WARN, text: "WARN" } - if (level === "debug") return { number: Severity.DEBUG, text: "DEBUG" } - return { number: Severity.INFO, text: "INFO" } -} - let crumbs: Crumb[] = [] -let active = false +let emit: EmitLog | undefined function push(crumb: Crumb): void { // Typing is one `input` per keystroke: keep one crumb per field, or a message evicts the whole trail. @@ -47,21 +38,22 @@ function push(crumb: Crumb): void { } function emitConsole( + sink: EmitLog, level: string | undefined, message: string, timestamp: number, - spanContext?: SpanContext, + link?: SpanLink, ): void { const severity = severityOf(level) - emitLog({ + sink({ severityNumber: severity.number, severityText: severity.text, body: message, timestamp, - spanContext, + link, attributes: { "maple.log.source": "console", - ...(spanContext ? { "maple.breadcrumb.type": "console" } : undefined), + ...(link ? { "maple.breadcrumb.type": "console" } : undefined), }, }) } @@ -73,15 +65,16 @@ export interface BreadcrumbOptions { readonly captureConsole: ReadonlyArray } -/** Start collecting. Returns a stop. */ -export function startBreadcrumbs(options: BreadcrumbOptions): () => void { +/** Start collecting into `sink`. Returns a stop. */ +export function startBreadcrumbs(sink: EmitLog, options: BreadcrumbOptions): () => void { if (!options.breadcrumbs && options.captureConsole.length === 0) return () => {} - active = true + emit = sink + const live = (): boolean => emit === sink const forwarded = new Set(options.captureConsole) const stopEvents = options.breadcrumbs ? onSessionEvent((ev) => { // Console comes from this module's own capture, which runs whether or not replay records. - if (!active || (ev.type !== "click" && ev.type !== "input" && ev.type !== "navigation")) + if (!live() || (ev.type !== "click" && ev.type !== "input" && ev.type !== "navigation")) return push({ timestamp: ev.timestamp ?? Date.now(), @@ -93,39 +86,42 @@ export function startBreadcrumbs(options: BreadcrumbOptions): () => void { }) : () => {} const stopConsole = installConsoleCapture((ev) => { - if (!active || ev.message === undefined) return + if (!live() || ev.message === undefined) return const timestamp = Date.now() if (ev.level !== undefined && forwarded.has(ev.level)) { - emitConsole(ev.level, ev.message, timestamp) + emitConsole(sink, ev.level, ev.message, timestamp) return } if (options.breadcrumbs) push({ timestamp, type: "console", level: ev.level, message: ev.message }) }) return () => { - active = false - crumbs = [] + if (live()) { + emit = undefined + crumbs = [] + } stopEvents() stopConsole() } } /** Export the trail so far, linked to the error's span, and start a new one. */ -export function flushBreadcrumbs(spanContext: SpanContext): void { - if (crumbs.length === 0) return +export function flushBreadcrumbs(link: SpanLink): void { + const sink = emit + if (!sink || crumbs.length === 0) return const trail = crumbs crumbs = [] for (const crumb of trail) { if (crumb.type === "console") { - emitConsole(crumb.level, crumb.message ?? "", crumb.timestamp, spanContext) + emitConsole(sink, crumb.level, crumb.message ?? "", crumb.timestamp, link) continue } - emitLog({ + sink({ eventName: "maple.browser.breadcrumb", severityNumber: Severity.INFO, severityText: "INFO", body: [crumb.type, crumb.target, crumb.message].filter(Boolean).join(" "), timestamp: crumb.timestamp, - spanContext, + link, attributes: { "maple.breadcrumb.type": crumb.type, ...(crumb.target ? { "maple.breadcrumb.target": crumb.target } : undefined), @@ -138,5 +134,5 @@ export function flushBreadcrumbs(spanContext: SpanContext): void { /** Test seam. */ export function resetBreadcrumbsForTests(): void { crumbs = [] - active = false + emit = undefined } diff --git a/packages/browser/src/deferred/document-timing.ts b/packages/sdk-core/src/browser/document-timing.ts similarity index 59% rename from packages/browser/src/deferred/document-timing.ts rename to packages/sdk-core/src/browser/document-timing.ts index bbbdd2c5d..02267fb1d 100644 --- a/packages/browser/src/deferred/document-timing.ts +++ b/packages/sdk-core/src/browser/document-timing.ts @@ -2,7 +2,18 @@ // response for the HTML (and its network phases), DOM processing, and the load // event. Read from the Navigation Timing entry once the page has loaded. import { scrubUrl } from "@maple/browser-session" -import { context, type Span, type Tracer, trace, TraceFlags } from "@opentelemetry/api" + +/** + * Record one finished child of `parent` (epoch ms) and return it, so phases can + * nest under it. Each SDK implements this on its own tracer. + */ +export type RecordChild

= ( + parent: P, + name: string, + startMs: number, + endMs: number, + attributes?: Record, +) => P type Mark = (entry: PerformanceNavigationTiming) => number type Phase = readonly [name: string, start: Mark, end: Mark] @@ -22,56 +33,41 @@ const PAGE_PHASES: ReadonlyArray = [ /** Epoch ms for a Navigation Timing offset. */ const at = (offset: number): number => performance.timeOrigin + offset -function spanPhases( - tracer: Tracer, - parent: Span, +function spanPhases

( + child: RecordChild

, + parent: P, entry: PerformanceNavigationTiming, phases: ReadonlyArray, ): void { - const ctx = trace.setSpan(context.active(), parent) for (const [name, start, end] of phases) { const from = start(entry) const to = end(entry) // Zero marks are phases that did not happen: a reused connection has no dns or connect. if (from <= 0 || to <= from) continue - tracer.startSpan(name, { startTime: at(from) }, ctx).end(at(to)) + child(parent, name, at(from), at(to)) } } -function record(tracer: Tracer, pageload: Span): void { +function record

(child: RecordChild

, pageload: P): void { const [entry] = performance.getEntriesByType("navigation") if (!(entry instanceof PerformanceNavigationTiming) || entry.responseEnd <= 0) return - const fetch = tracer.startSpan( - "documentFetch", - { - startTime: at(entry.fetchStart), - attributes: { - "url.full": scrubUrl(entry.name), - ...(entry.responseStatus > 0 - ? { "http.response.status_code": entry.responseStatus } - : undefined), - ...(entry.encodedBodySize > 0 - ? { "http.response.body.size": entry.encodedBodySize } - : undefined), - }, - }, - trace.setSpan(context.active(), pageload), - ) - spanPhases(tracer, fetch, entry, FETCH_PHASES) - fetch.end(at(entry.responseEnd)) - spanPhases(tracer, pageload, entry, PAGE_PHASES) + const fetch = child(pageload, "documentFetch", at(entry.fetchStart), at(entry.responseEnd), { + "url.full": scrubUrl(entry.name), + ...(entry.responseStatus > 0 ? { "http.response.status_code": entry.responseStatus } : undefined), + ...(entry.encodedBodySize > 0 ? { "http.response.body.size": entry.encodedBodySize } : undefined), + }) + spanPhases(child, fetch, entry, FETCH_PHASES) + spanPhases(child, pageload, entry, PAGE_PHASES) } /** Span the document's load under `pageload`, now or once the `load` event has finished. */ -export function recordDocumentTiming(tracer: Tracer, pageload: Span): void { - // Sampled, not recording: the app has usually ended the span by the time this chunk lands. - const sampled = (pageload.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0 - if (!sampled || typeof performance.getEntriesByType !== "function") return +export function recordDocumentTiming

(child: RecordChild

, pageload: P): void { + if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") return // A task after `load`, so `loadEventEnd` is set. Timing is best-effort: never throw into the page. const run = (): void => void setTimeout(() => { try { - record(tracer, pageload) + record(child, pageload) } catch {} }, 0) if (document.readyState === "complete") run() diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/sdk-core/src/browser/offline.browser.test.ts similarity index 76% rename from packages/browser/src/deferred/offline.browser.test.ts rename to packages/sdk-core/src/browser/offline.browser.test.ts index 0272b3f59..31ca3eab7 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/sdk-core/src/browser/offline.browser.test.ts @@ -1,26 +1,16 @@ import { configurePrivacy, resetConsentForTests, setConsent } from "@maple/browser-session" -import { - BasicTracerProvider, - InMemorySpanExporter, - type ReadableSpan, - SimpleSpanProcessor, -} from "@opentelemetry/sdk-trace-base" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { resolveConfig } from "../config" -import { attachSpanStash, OfflineSpanExporter, resetOfflineForTests } from "../offline" -import { startOfflineQueue } from "./offline" - -const CONFIG = resolveConfig({ ingestKey: "k", serviceName: "web", endpoint: "https://ingest.test" }) - -const finishedSpans = (...names: string[]): ReadableSpan[] => { - const memory = new InMemorySpanExporter() - const tracer = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memory)] }).getTracer( - "t", - ) - for (const name of names) tracer.startSpan(name).end() - return memory.getFinishedSpans() +import { type OfflineQueueOptions, startOfflineQueue } from "./offline" + +const CONFIG: OfflineQueueOptions = { + endpoint: "https://ingest.test", + headers: { Authorization: "Bearer k", "x-maple-sdk": "test/1" }, } +/** An OTLP JSON body naming one span, as an exporter would have sent it. */ +const body = (name: string): Uint8Array => + new TextEncoder().encode(JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans: [{ name }] }] }] })) + /** How many batches the queue holds, read straight from IndexedDB. */ const storedCount = async (): Promise => { const db = await new Promise((resolve, reject) => { @@ -51,7 +41,6 @@ beforeEach(async () => { afterEach(() => { stop?.() stop = undefined - resetOfflineForTests() resetConsentForTests() vi.unstubAllGlobals() }) @@ -70,7 +59,7 @@ describe("offline queue", () => { ) const queue = startOfflineQueue(CONFIG) stop = queue.stop - queue.stashSpans(finishedSpans("checkout")) + queue.stash("traces", body("checkout")) await vi.waitFor(async () => expect(await storedCount()).toBe(1)) online = true @@ -83,7 +72,7 @@ describe("offline queue", () => { ) }) - it("keeps batches while ingest is failing, and drops ones it rejects", async () => { + it("keeps batches while ingest is failing, drops ones it rejects, and never stores an empty one", async () => { const statuses = [503, 400] vi.stubGlobal( "fetch", @@ -92,14 +81,43 @@ describe("offline queue", () => { const queue = startOfflineQueue(CONFIG) stop = queue.stop await queue.resend() - queue.stashLogs([]) - queue.stashSpans(finishedSpans("a")) + queue.stash("logs", new Uint8Array()) + queue.stash("traces", body("a")) await vi.waitFor(async () => expect(await storedCount()).toBe(1)) await queue.resend() expect(await storedCount()).toBe(1) await queue.resend() expect(await storedCount()).toBe(0) }) + + it("never resends another SDK's batch under this one's endpoint or key", async () => { + const posts: Array<{ url: string; auth: string | null }> = [] + let online = false + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + if (!online) throw new TypeError("Failed to fetch") + posts.push({ url, auth: new Headers(init.headers).get("authorization") }) + return new Response("{}") + }), + ) + const other = startOfflineQueue({ + endpoint: "https://proxy.test", + headers: { Authorization: "Bearer other" }, + }) + const mine = startOfflineQueue(CONFIG) + other.stash("traces", body("theirs")) + mine.stash("traces", body("mine")) + await vi.waitFor(async () => expect(await storedCount()).toBe(2)) + online = true + await mine.resend() + expect(posts).toEqual([{ url: "https://ingest.test/v1/traces", auth: "Bearer k" }]) + expect(await storedCount()).toBe(1) + await other.resend() + expect(posts.at(-1)).toEqual({ url: "https://proxy.test/v1/traces", auth: "Bearer other" }) + other.stop() + mine.stop() + }) }) describe("offline queue across tabs", () => { @@ -118,7 +136,7 @@ describe("offline queue across tabs", () => { // Two queues on one origin stand in for two tabs: they share the store and the lock. const tabA = startOfflineQueue(CONFIG) const tabB = startOfflineQueue(CONFIG) - tabA.stashSpans(finishedSpans("once")) + tabA.stash("traces", body("once")) await vi.waitFor(async () => expect(await storedCount()).toBe(1)) online = true await Promise.all([tabA.resend(), tabB.resend()]) @@ -144,7 +162,7 @@ describe("offline queue and consent", () => { configurePrivacy({ requireConsent: true }) setConsent(true) const first = startOfflineQueue(CONFIG) - first.stashSpans(finishedSpans("offline")) + first.stash("traces", body("offline")) await vi.waitFor(async () => expect(await storedCount()).toBe(1)) first.stop() @@ -173,7 +191,7 @@ describe("offline queue and consent", () => { const first = startOfflineQueue(CONFIG) // Let its startup resend (of an empty store) finish before anything is stored. await first.resend() - first.stashSpans(finishedSpans("before")) + first.stash("traces", body("before")) await vi.waitFor(async () => expect(await storedCount()).toBe(1)) first.stop() @@ -213,28 +231,11 @@ describe("offline queue and consent", () => { ) const queue = startOfflineQueue(CONFIG) stop = queue.stop - queue.stashSpans(finishedSpans("one")) - queue.stashSpans(finishedSpans("two")) + queue.stash("traces", body("one")) + queue.stash("traces", body("two")) await vi.waitFor(async () => expect(await storedCount()).toBe(2)) online = true await queue.resend() expect(posts).toHaveLength(1) }) }) - -describe("OfflineSpanExporter", () => { - it("hands failed batches to the stash, holding them until it is attached", () => { - const failing = { - export: (_spans: ReadableSpan[], callback: (result: { code: number }) => void) => - callback({ code: 1 }), - shutdown: async () => {}, - } - const exporter = new OfflineSpanExporter(failing) - const spans = finishedSpans("early") - exporter.export(spans, () => {}) - const stashed: ReadableSpan[][] = [] - attachSpanStash((batch) => stashed.push(batch)) - exporter.export(finishedSpans("late"), () => {}) - expect(stashed.map((batch) => batch[0]?.name)).toEqual(["early", "late"]) - }) -}) diff --git a/packages/browser/src/deferred/offline.ts b/packages/sdk-core/src/browser/offline.ts similarity index 74% rename from packages/browser/src/deferred/offline.ts rename to packages/sdk-core/src/browser/offline.ts index 888f21ac5..420e65f1f 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/sdk-core/src/browser/offline.ts @@ -2,25 +2,23 @@ // the exporters send, and sent again when the browser is back online or on the // next page load. Best-effort: a private window or blocked storage just means // nothing is kept. -import { consentRevokedAt, hasConsent, ingestHeaders, onConsentChange, sdkHint } from "@maple/browser-session" -import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer" -import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" -import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" -import type { ResolvedConfig } from "../config" -import { SDK_NAME, SDK_VERSION } from "../version" +import { consentRevokedAt, hasConsent, onConsentChange } from "@maple/browser-session" const DB_NAME = "maple-offline" const STORE = "batches" const MAX_AGE_MS = 24 * 60 * 60 * 1_000 const MAX_BATCHES = 100 -type Signal = "traces" | "logs" +export type OfflineSignal = "traces" | "logs" +type Signal = OfflineSignal interface StoredBatch { readonly id?: number readonly signal: Signal readonly body: Uint8Array readonly createdAt: number + /** Endpoint plus a fingerprint of the credentials: another SDK on the page may send elsewhere. */ + readonly target?: string } const isStoredBatch = (value: unknown): value is StoredBatch & { readonly id: number } => @@ -55,20 +53,36 @@ function openDb(): Promise { } } +/** FNV-1a, hex: identifies the credentials without storing them. */ +function fingerprint(value: string): string { + let hash = 0x811c9dc5 + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16) +} + +export interface OfflineQueueOptions { + /** Ingest base URL; batches are sent to `/v1/`. */ + readonly endpoint: string + /** Auth and SDK headers for the resend. */ + readonly headers: Record +} + export interface OfflineQueue { - readonly stashSpans: (spans: ReadableSpan[]) => void - readonly stashLogs: (logs: ReadableLogRecord[]) => void - /** Send what is stored, oldest first. Stops at the first failure. */ + /** Keep an OTLP JSON request body the exporter gave up on. */ + readonly stash: (signal: OfflineSignal, body: Uint8Array) => void + /** Send what is stored for this endpoint and key, oldest first. Stops at the first failure. */ readonly resend: () => Promise readonly stop: () => void } -export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { +export function startOfflineQueue(config: OfflineQueueOptions): OfflineQueue { const db = openDb() - const headers = { - ...ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }), - "Content-Type": "application/json", - } + const headers = { ...config.headers, "Content-Type": "application/json" } + const auth = Object.entries(config.headers).find(([name]) => name.toLowerCase() === "authorization") + const target = `${config.endpoint}|${fingerprint(auth?.[1] ?? "")}` const store = async (mode: IDBTransactionMode): Promise => (await db)?.transaction(STORE, mode).objectStore(STORE) @@ -77,7 +91,7 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { if (!hasConsent() || createdAt <= consentRevokedAt()) return const batches = await store("readwrite") if (!batches) return - await settle(batches.add({ signal, body, createdAt } satisfies StoredBatch)) + await settle(batches.add({ signal, body, createdAt, target } satisfies StoredBatch)) const keys = await settle(batches.getAllKeys()) for (const key of keys.slice(0, Math.max(0, keys.length - MAX_BATCHES))) await settle(batches.delete(key)) @@ -90,8 +104,11 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { for (const batch of stored) { // Checked per batch: consent can be withdrawn while an earlier POST is in flight. if (!hasConsent()) return + const expired = Date.now() - batch.createdAt > MAX_AGE_MS + // Another SDK's batch is left for it, unless it is too old for anyone to send. + if (!expired && batch.target !== undefined && batch.target !== target) continue // Expired, or captured before consent was last withdrawn (a revoke this queue never saw): drop it. - if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt > consentRevokedAt()) { + if (!expired && batch.createdAt > consentRevokedAt()) { const response = await fetch(`${config.endpoint}/v1/${batch.signal}`, { method: "POST", headers, @@ -155,11 +172,8 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { void resend() return { - stashSpans: (spans) => { - if (spans.length > 0) stash("traces", JsonTraceSerializer.serializeRequest(spans)) - }, - stashLogs: (logs) => { - if (logs.length > 0) stash("logs", JsonLogsSerializer.serializeRequest(logs)) + stash: (signal, body) => { + if (body.byteLength > 0) stash(signal, body) }, resend, stop: () => { diff --git a/packages/browser/src/deferred/perf.ts b/packages/sdk-core/src/browser/perf.ts similarity index 89% rename from packages/browser/src/deferred/perf.ts rename to packages/sdk-core/src/browser/perf.ts index 9f514c7f0..57e5e7b76 100644 --- a/packages/browser/src/deferred/perf.ts +++ b/packages/sdk-core/src/browser/perf.ts @@ -1,17 +1,24 @@ // Main-thread jank as spans: long animation frames (with the script that ran // longest) and slow interactions (split into input delay, processing and // presentation). Opt-in; nested under the open navigation when there is one. -import { hasConsent, scrubUrl, selectorOf } from "@maple/browser-session" -import { context, trace } from "@opentelemetry/api" -import { navigationSpanAt } from "../navigation" -import { liveMapleTracer } from "../tracing" -import { SDK_NAME, SDK_VERSION } from "../version" +import { scrubUrl, selectorOf } from "@maple/browser-session" /** A frame this long is visible jank; the Long Animation Frames API reports from 50ms. */ const LONG_FRAME_MS = 100 /** INP's "needs improvement" line. */ const SLOW_INTERACTION_MS = 200 +/** + * Record one finished span, epoch ms. The SDK nests it under its open + * navigation only if that had started by `startMs`: buffered entries can predate it. + */ +export type StartSpan = ( + name: string, + startMs: number, + endMs: number, + attributes: Record, +) => void + export interface PerfOptions { readonly longFrames: boolean readonly slowInteractions: boolean @@ -26,18 +33,15 @@ interface ScriptTiming { const epoch = (offset: number): number => performance.timeOrigin + offset +let sink: StartSpan | undefined + function span( name: string, start: number, duration: number, attributes: Record, ): void { - const tracer = hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : undefined - if (!tracer) return - // Buffered entries can predate the open navigation: those stay roots. - const navigation = navigationSpanAt(epoch(start)) - const parent = navigation ? trace.setSpan(context.active(), navigation) : context.active() - tracer.startSpan(name, { startTime: epoch(start), attributes }, parent).end(epoch(start + duration)) + sink?.(name, epoch(start), epoch(start + duration), attributes) } function scriptsOf(entry: PerformanceEntry): ScriptTiming[] { @@ -137,8 +141,9 @@ function spanInteraction(entry: PerformanceEventTiming): void { }) } -export function startPerf(options: PerfOptions): () => void { +export function startPerf(startSpan: StartSpan, options: PerfOptions): () => void { const stops: Array<() => void> = [] + if (options.longFrames || options.slowInteractions) sink = startSpan if (options.longFrames) { const hasLoaf = typeof PerformanceObserver !== "undefined" && @@ -178,5 +183,6 @@ export function startPerf(options: PerfOptions): () => void { } return () => { for (const stop of stops) stop() + if (sink === startSpan) sink = undefined } } diff --git a/packages/browser/src/deferred/reports.browser.test.ts b/packages/sdk-core/src/browser/reports.browser.test.ts similarity index 66% rename from packages/browser/src/deferred/reports.browser.test.ts rename to packages/sdk-core/src/browser/reports.browser.test.ts index 14965dda4..4a3b073c3 100644 --- a/packages/browser/src/deferred/reports.browser.test.ts +++ b/packages/sdk-core/src/browser/reports.browser.test.ts @@ -1,13 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { attachLogSink, resetLogsForTests } from "../logs" +import type { SignalLogRecord } from "../log-record" import { startReports } from "./reports" -// The module under test emits through the eager queue; record what reaches it. -const emitted: Array<{ eventName?: string; body?: string; attributes: Record }> = [] +const emitted: SignalLogRecord[] = [] +const sink = (record: SignalLogRecord): void => { + emitted.push(record) +} afterEach(() => { emitted.length = 0 - resetLogsForTests() vi.unstubAllGlobals() }) @@ -27,9 +28,8 @@ const violation = (blockedURI: string) => describe("startReports", () => { it("reports a CSP violation once per kind as a WARN log event", () => { - attachLogSink((record) => emitted.push(record)) vi.stubGlobal("ReportingObserver", undefined) - const stop = startReports({ csp: true, browserReports: false }) + const stop = startReports(sink, { csp: true, browserReports: false }) document.dispatchEvent(violation("https://tracker.test/pixel.gif")) document.dispatchEvent(violation("https://tracker.test/pixel.gif")) stop() @@ -37,22 +37,20 @@ describe("startReports", () => { expect(emitted).toHaveLength(1) expect(emitted[0]?.eventName).toBe("maple.browser.csp_violation") expect(emitted[0]?.body).toBe("img-src blocked https://tracker.test/pixel.gif") - expect(emitted[0]?.attributes["maple.csp.disposition"]).toBe("enforce") - expect(emitted[0]?.attributes["code.line.number"]).toBe(12) + expect(emitted[0]?.attributes?.["maple.csp.disposition"]).toBe("enforce") + expect(emitted[0]?.attributes?.["code.line.number"]).toBe(12) }) it("reports nothing when turned off", () => { - attachLogSink((record) => emitted.push(record)) vi.stubGlobal("ReportingObserver", undefined) - const stop = startReports({ csp: false, browserReports: false }) + const stop = startReports(sink, { csp: false, browserReports: false }) document.dispatchEvent(violation("https://tracker.test/pixel.gif")) stop() expect(emitted).toEqual([]) }) - it("reads CSP reports through ReportingObserver where it exists", async () => { - attachLogSink((record) => emitted.push(record)) - const stop = startReports({ csp: true, browserReports: false }) + it("reads CSP reports through ReportingObserver and the DOM event, once", async () => { + const stop = startReports(sink, { csp: true, browserReports: false }) const meta = document.createElement("meta") meta.httpEquiv = "Content-Security-Policy" meta.content = "img-src 'none'" @@ -63,7 +61,10 @@ describe("startReports", () => { await vi.waitFor(() => expect(emitted.some((record) => record.eventName === "maple.browser.csp_violation")).toBe(true), ) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(emitted.filter((record) => record.eventName === "maple.browser.csp_violation")).toHaveLength(1) stop() img.remove() + meta.remove() }) }) diff --git a/packages/browser/src/deferred/reports.ts b/packages/sdk-core/src/browser/reports.ts similarity index 96% rename from packages/browser/src/deferred/reports.ts rename to packages/sdk-core/src/browser/reports.ts index b3a57726c..986049b5a 100644 --- a/packages/browser/src/deferred/reports.ts +++ b/packages/sdk-core/src/browser/reports.ts @@ -2,7 +2,7 @@ // reports, as WARN log events. They are worth seeing but are not errors in the // app's code, so they never become issues. import { scrubUrl } from "@maple/browser-session" -import { emitLog, type LogAttributeValue, Severity } from "../logs" +import { type EmitLog, type LogAttributeValue, Severity } from "../log-record" export interface ReportOptions { /** Content Security Policy violations. */ @@ -58,7 +58,7 @@ function sourceAttributes(fields: ReportFields): Record void { +export function startReports(emitLog: EmitLog, options: ReportOptions): () => void { if (!options.csp && !options.browserReports) return () => {} const seen = new Set() const once = (key: string): boolean => { diff --git a/packages/browser/src/deferred/web-vitals.ts b/packages/sdk-core/src/browser/web-vitals.ts similarity index 59% rename from packages/browser/src/deferred/web-vitals.ts rename to packages/sdk-core/src/browser/web-vitals.ts index 96a268a17..e1d8b72a6 100644 --- a/packages/browser/src/deferred/web-vitals.ts +++ b/packages/sdk-core/src/browser/web-vitals.ts @@ -1,19 +1,17 @@ -// Core Web Vitals as OTel log-based events, following the `browser.web_vital` -// event in the browser semantic conventions. Aggregates are derived in the -// warehouse, so each event keeps its page and the pageload trace it belongs to. +// Core Web Vitals as log-based events, following the `browser.web_vital` event +// in the browser semantic conventions. Aggregates are derived in the warehouse, +// so each event keeps its page and the pageload trace it belongs to. import { scrubUrl } from "@maple/browser-session" -import type { SpanContext } from "@opentelemetry/api" import { type Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals" -import { emitLog, Severity } from "../logs" +import { type EmitLog, Severity, type SpanLink } from "../log-record" // web-vitals has no unsubscribe: register once per page, and gate reporting instead. let registered = false -let reporting = false -let pageload: (() => SpanContext | undefined) | undefined +let emit: EmitLog | undefined +let pageload: (() => SpanLink | undefined) | undefined function report(metric: Metric): void { - if (!reporting) return - emitLog({ + emit?.({ eventName: "browser.web_vital", severityNumber: Severity.INFO, severityText: "INFO", @@ -27,20 +25,21 @@ function report(metric: Metric): void { "url.path": scrubUrl(location.pathname), }, // Only the trace link needs a pageload span; tracing may be off, or the app not navigating yet. - spanContext: pageload?.(), + link: pageload?.(), }) } -/** Report vitals, linked to the document's pageload span when there is one. Returns a stop. */ -export function startWebVitals(getPageload: () => SpanContext | undefined): () => void { - reporting = true +/** Report vitals through `sink`, linked to the document's pageload span when there is one. Returns a stop. */ +export function startWebVitals(sink: EmitLog, getPageload: () => SpanLink | undefined): () => void { + emit = sink pageload = getPageload if (!registered) { registered = true for (const on of [onCLS, onFCP, onINP, onLCP, onTTFB]) on(report) } return () => { - reporting = false + if (emit !== sink) return + emit = undefined pageload = undefined } } diff --git a/packages/browser/src/error-filters.test.ts b/packages/sdk-core/src/error-filters.test.ts similarity index 78% rename from packages/browser/src/error-filters.test.ts rename to packages/sdk-core/src/error-filters.test.ts index 64180fb82..cff3738a8 100644 --- a/packages/browser/src/error-filters.test.ts +++ b/packages/sdk-core/src/error-filters.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest" -import { configureErrorFilters, frameUrls, shouldCapture } from "./error-filters" +import { beforeEach, describe, expect, it } from "vitest" +import { type ErrorFilter, type ErrorFilterOptions, frameUrls, makeErrorFilter } from "./error-filters" const V8_STACK = `TypeError: x is undefined at render (https://app.test/assets/index-abc.js:10:5) @@ -13,11 +13,15 @@ const errorWith = (message: string, stack?: string, name = "Error"): Error => { error.stack = stack return error } +let shouldCapture: ErrorFilter = makeErrorFilter() +const configureErrorFilters = (options: ErrorFilterOptions): void => { + shouldCapture = makeErrorFilter(options) +} /** As the SDK calls it for a thrown Error: the error is its own original. */ const check = (error: Error, frameUrl?: string): boolean => shouldCapture(error, { source: "captureException", originalError: error }, frameUrl) -afterEach(() => configureErrorFilters(undefined)) +beforeEach(() => configureErrorFilters({})) describe("frameUrls", () => { it("reads frame URLs from V8 and Firefox/Safari stacks, top first", () => { @@ -36,9 +40,31 @@ describe("frameUrls", () => { frameUrls("Error: failed to load https://api.test/x\n at f (https://app.test/a.js:1:1)"), ).toEqual(["https://app.test/a.js"]) }) + + it("reads line-only frames, async frames and ports, and skips frames with no script URL", () => { + expect( + frameUrls( + [ + " at async load (https://app.test:8443/a.js:12)", + " at https://app.test/b.js:3:4", + " at native", + " at f ()", + "g@https://app.test/c.js:5:6", + ].join("\n"), + ), + ).toEqual(["https://app.test:8443/a.js", "https://app.test/b.js", "https://app.test/c.js"]) + }) + + it("stays linear on a hostile stack line", () => { + // The shape a backtracking pattern chokes on: many `@a://` after `at a://`. + const line = `at a://${"@a://".repeat(50_000)}` + const started = performance.now() + expect(frameUrls(line)).toEqual([]) + expect(performance.now() - started).toBeLessThan(200) + }) }) -describe("shouldCapture", () => { +describe("makeErrorFilter", () => { it("drops extension errors and ResizeObserver notices by default", () => { const extension = errorWith( "boom", diff --git a/packages/sdk-core/src/error-filters.ts b/packages/sdk-core/src/error-filters.ts new file mode 100644 index 000000000..cb9c6c86b --- /dev/null +++ b/packages/sdk-core/src/error-filters.ts @@ -0,0 +1,119 @@ +// Client-side error filtering: runs before an error span exists, so a dropped +// error costs nothing and never reaches an issue. +import type { HttpStatusRange } from "./http-status" + +export type ErrorSource = "captureException" | "window.onerror" | "unhandledrejection" + +export interface ErrorFilterHint { + readonly source: ErrorSource + /** What was thrown, before it was normalized into an `Error`. */ + // BOUNDARY: a thrown value is unparsed by definition. + readonly originalError: unknown +} + +export interface ErrorFilterOptions { + /** Drop errors whose `Name: message` contains a string or matches a RegExp. */ + readonly ignore?: ReadonlyArray + /** Report only errors whose top frame's script URL matches one of these. Errors with no frames are kept. */ + readonly allowUrls?: ReadonlyArray + /** Drop errors whose top frame's script URL matches one of these. */ + readonly denyUrls?: ReadonlyArray + /** Return `false` to drop the error. Runs after the lists; if it throws, the error is kept. */ + readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean + /** + * HTTP response statuses that make a client request span an error (and so an + * issue), e.g. `[[500, 599]]`. Default none: a response status alone is not + * an error, and a network failure always is. + */ + readonly captureHttpStatus?: ReadonlyArray + /** + * Drop errors thrown from browser extensions and the benign `ResizeObserver + * loop` notices. Default true. + */ + readonly defaultFilters?: boolean +} + +/** `(error, hint, frameUrl?) => keep`. `frameUrl` is the top frame's script URL when the caller knows it. */ +export type ErrorFilter = (error: Error, hint: ErrorFilterHint, frameUrl?: string) => boolean + +const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\// +const BENIGN_MESSAGES = [/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/] + +const URL_SCHEME = /^[a-z][\w+.-]*:\/\//i +const DIGITS = /^\d+$/ + +/** + * The script URL of one frame: `at fn (url:1:2)`, `at url:1:2` (V8) or `fn@url:1:2` + * (SpiderMonkey, JavaScriptCore). Parsed by position, not one regex: a stack is page + * data, and a backtracking pattern over it can be made to run in polynomial time. + */ +function frameUrl(line: string): string | undefined { + let frame = line.trim() + if (frame.endsWith(")")) { + const open = frame.lastIndexOf("(") + if (open === -1) return undefined + frame = frame.slice(open + 1, -1) + } else if (frame.startsWith("at ")) { + frame = frame.slice(3) + } else { + const at = frame.lastIndexOf("@") + if (at === -1) return undefined + frame = frame.slice(at + 1) + } + // `:line` and an optional `:column` come off the end; at least the line must be there. + for (let parts = 0; parts < 2; parts++) { + const colon = frame.lastIndexOf(":") + if (colon === -1 || !DIGITS.test(frame.slice(colon + 1))) { + if (parts === 0) return undefined + break + } + frame = frame.slice(0, colon) + } + if (!URL_SCHEME.test(frame) || /[\s()]/.test(frame)) return undefined + return frame +} + +/** The script URL of each stack frame, top first. */ +export function frameUrls(stack: string | undefined): string[] { + if (!stack) return [] + const urls: string[] = [] + for (const line of stack.split("\n")) { + const url = frameUrl(line) + if (url) urls.push(url) + } + return urls +} + +const matches = (value: string, patterns: ReadonlyArray): boolean => + patterns.some((pattern) => { + if (typeof pattern === "string") return value.includes(pattern) + // A `g`/`y` regex is stateful: `test` advances `lastIndex`, so reset it first. + pattern.lastIndex = 0 + return pattern.test(value) + }) + +/** + * Build the filter for `options`. Without a `frameUrl`, only a thrown `Error` + * has frames of its own: an `Error` wrapped around anything else points at the + * SDK, so no URL list applies to it. + */ +export function makeErrorFilter(options: ErrorFilterOptions = {}): ErrorFilter { + return (error, hint, frameUrl) => { + const text = `${error.name}: ${error.message}` + const topUrl = + frameUrl ?? (hint.originalError instanceof Error ? frameUrls(error.stack)[0] : undefined) + if (options.defaultFilters !== false) { + if (matches(error.message, BENIGN_MESSAGES)) return false + if (topUrl && EXTENSION_URL.test(topUrl)) return false + } + if (options.ignore && matches(text, options.ignore)) return false + if (topUrl && options.denyUrls && matches(topUrl, options.denyUrls)) return false + if (topUrl && options.allowUrls?.length && !matches(topUrl, options.allowUrls)) return false + if (!options.beforeCapture) return true + try { + return options.beforeCapture(error, hint) !== false + } catch { + return true + } + } +} diff --git a/packages/sdk-core/src/errors.test.ts b/packages/sdk-core/src/errors.test.ts new file mode 100644 index 000000000..d4ee0fa2a --- /dev/null +++ b/packages/sdk-core/src/errors.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" +import { asError, stackWithCauses } from "./errors" + +const withStack = (error: Error, frames: string): Error => { + error.stack = `${error.name}: ${error.message}\n${frames}` + return error +} + +describe("stackWithCauses", () => { + it("returns the stack untouched when nothing is linked", () => { + const error = withStack(new Error("top"), " at a (https://app.test/a.js:1:1)") + expect(stackWithCauses(error)).toBe(error.stack) + }) + + it("appends the cause chain after the error's own frames", () => { + const root = withStack(new TypeError("socket closed"), " at read (https://app.test/net.js:5:1)") + const error = withStack( + new Error("load failed", { cause: root }), + " at load (https://app.test/a.js:1:1)", + ) + expect(stackWithCauses(error)).toBe( + [ + "Error: load failed", + " at load (https://app.test/a.js:1:1)", + "Caused by: TypeError: socket closed", + " at read (https://app.test/net.js:5:1)", + ].join("\n"), + ) + }) + + it("renders a non-Error cause and the members of an AggregateError", () => { + const aggregate = new AggregateError([new Error("one"), "two"], "all failed") + aggregate.stack = "AggregateError: all failed" + const stack = stackWithCauses(new Error("outer", { cause: aggregate })) ?? "" + expect(stack).toContain("Caused by: AggregateError: all failed") + expect(stack).toContain("Caused by: Error: one") + expect(stack).toContain("Caused by: two") + }) + + it("stops at a cycle and at five linked errors", () => { + const a = new Error("a") + const b = new Error("b", { cause: a }) + Object.defineProperty(a, "cause", { value: b }) + expect(stackWithCauses(a)?.match(/Caused by/g)).toHaveLength(1) + + let deep = new Error("0") + for (let i = 1; i <= 10; i++) deep = new Error(String(i), { cause: deep }) + expect(stackWithCauses(deep)?.match(/Caused by/g)).toHaveLength(5) + }) + + it("never throws on a cause that cannot be turned into a string", () => { + const hostile = Object.create(null) + const throwing = { + toString() { + throw new Error("nope") + }, + } + expect(stackWithCauses(new Error("a", { cause: hostile }))).toContain("Caused by: [object Object]") + expect(stackWithCauses(new Error("b", { cause: throwing }))).toContain("Caused by: [object Object]") + }) + + it("renders plain members of an errors list by their message, and ignores non-error objects' lists", () => { + // A GraphQL-style error: `errors` holds plain `{ message }` objects. + const graphql = Object.assign(new Error("query failed"), { errors: [{ message: "field missing" }] }) + expect(stackWithCauses(graphql)).toContain("Caused by: field missing") + const notAnError = { errors: [new Error("hidden")] } + expect(stackWithCauses(new Error("outer", { cause: notAnError }))).not.toContain("hidden") + }) + + it("walks copies that are Error-shaped but not Error instances", () => { + // Effect's pretty errors are plain Errors carrying copied fields: no AggregateError prototype. + const copy = Object.assign(new Error("all failed"), { + name: "AggregateError", + errors: [new Error("one")], + }) + const outer = new Error("outer", { cause: copy }) + const stack = stackWithCauses(outer) ?? "" + expect(stack).toContain("Caused by: AggregateError: all failed") + expect(stack).toContain("Caused by: Error: one") + }) +}) + +describe("asError", () => { + it("narrows whatever was thrown without throwing itself", () => { + const error = new TypeError("x") + expect(asError(error)).toBe(error) + expect(asError("boom").message).toBe("boom") + expect(asError({ message: "shaped" }).message).toBe("shaped") + expect(asError(42).message).toBe("42") + expect(asError(Object.create(null)).message).toBe("Unknown error") + }) +}) diff --git a/packages/sdk-core/src/errors.ts b/packages/sdk-core/src/errors.ts new file mode 100644 index 000000000..06fb99b6a --- /dev/null +++ b/packages/sdk-core/src/errors.ts @@ -0,0 +1,90 @@ +// A thrown value as an `exception` event records it: one `Error`, with its cause +// chain and `errors` list appended as `Caused by:` blocks after its own frames, +// which is why fingerprints (hashed on the top frames) do not move. + +/** Deep enough for real wrapping chains, bounded against a cause cycle or a huge aggregate. */ +const MAX_LINKED = 5 + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +/** Error-shaped: an `Error`, or a copy with its fields (Effect's pretty errors, a structured clone). */ +const isErrorLike = (value: unknown): value is Error => + value instanceof Error || + (isRecord(value) && + typeof value.name === "string" && + typeof value.message === "string" && + (value.stack === undefined || typeof value.stack === "string")) + +/** + * BOUNDARY: a thrown value is unparsed by definition, JavaScript can throw anything. + * Narrow it into an `Error` without ever throwing from the error path. + */ +export function asError(value: unknown): Error { + if (value instanceof Error) return value + if (typeof value === "string") return new Error(value) + if (isRecord(value) && typeof value.message === "string") return new Error(value.message) + try { + return new Error(String(value)) + } catch { + return new Error("Unknown error") + } +} + +/** What an error links to: its `cause`, and its `errors` list (duck-typed, so copies qualify). */ +const linksOf = (value: unknown): unknown[] => { + if (!isErrorLike(value)) return [] + const next: unknown[] = [] + // Arbitrary objects can carry throwing getters; the error path must survive them. + try { + if ("errors" in value && Array.isArray(value.errors)) next.push(...value.errors) + if (value.cause !== undefined) next.push(value.cause) + } catch {} + return next +} + +/** A null-prototype object or a throwing `toString` must not break the error path. */ +function render(value: unknown): string { + if (isRecord(value) && typeof value.message === "string") return value.message + try { + return String(value) + } catch { + return Object.prototype.toString.call(value) + } +} + +function linkedErrors(error: Error): unknown[] { + const linked: unknown[] = [] + const seen = new Set([error]) + const queue: unknown[] = [error] + while (queue.length > 0 && linked.length < MAX_LINKED) { + for (const candidate of linksOf(queue.shift())) { + if (seen.has(candidate) || linked.length >= MAX_LINKED) continue + seen.add(candidate) + linked.push(candidate) + queue.push(candidate) + } + } + return linked +} + +/** V8 starts a stack with a `Name: message` header; the other engines start at the first frame. */ +function framesOf(error: Error): string { + const stack = typeof error.stack === "string" ? error.stack : "" + const header = error.message ? `${error.name}: ${error.message}` : error.name + return stack.startsWith(header) ? stack.slice(header.length).replace(/^\n/, "") : stack +} + +function describe(value: unknown): string { + if (!isErrorLike(value)) return `Caused by: ${render(value)}` + const frames = framesOf(value) + const header = `Caused by: ${value.name}: ${value.message}` + return frames ? `${header}\n${frames}` : header +} + +/** The stack trace to record for `error`, with its linked errors appended. */ +export function stackWithCauses(error: Error): string | undefined { + const linked = linkedErrors(error) + if (linked.length === 0) return error.stack + return [error.stack ?? `${error.name}: ${error.message}`, ...linked.map(describe)].join("\n") +} diff --git a/packages/sdk-core/src/http-headers.ts b/packages/sdk-core/src/http-headers.ts new file mode 100644 index 000000000..2359ce746 --- /dev/null +++ b/packages/sdk-core/src/http-headers.ts @@ -0,0 +1,52 @@ +// Allowlisted request/response headers as the HTTP semconv span attributes +// `http.request.header.` / `http.response.header.` (string arrays). +import type { AttributeValue } from "./http-status" + +/** Never recorded, even when listed: they carry credentials. */ +const CREDENTIAL_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", +]) + +export interface HeaderCaptureOptions { + readonly request?: ReadonlyArray + readonly response?: ReadonlyArray +} + +export interface HeaderCapture { + readonly request: ReadonlyArray + readonly response: ReadonlyArray +} + +export function resolveHeaderCapture(raw: HeaderCaptureOptions | undefined): HeaderCapture { + const clean = (names: ReadonlyArray | undefined) => + (names ?? []).map((name) => name.toLowerCase()).filter((name) => !CREDENTIAL_HEADERS.has(name)) + return { request: clean(raw?.request), response: clean(raw?.response) } +} + +const HEADER_ATTRIBUTE = /^http\.(request|response)\.header\.(.+)$/ + +/** + * Keep a `http..header.` attribute only when `` is + * allowlisted, as a one-element string array. Returns `undefined` to drop it, + * and passes any other attribute through untouched. + */ +export function filterHeaderAttribute( + capture: HeaderCapture, + key: string, + value: AttributeValue | undefined, +): AttributeValue | undefined { + const match = HEADER_ATTRIBUTE.exec(key) + if (!match) return value + const names = match[1] === "request" ? capture.request : capture.response + const name = (match[2] ?? "").toLowerCase() + if (!names.includes(name) || value === undefined || value === "") return undefined + // One entry: commas are part of many values (`date`, `cache-control`). + return isList(value) ? value.map(String) : [String(value)] +} + +const isList = (value: AttributeValue): value is Extract> => + Array.isArray(value) diff --git a/packages/sdk-core/src/http-status.ts b/packages/sdk-core/src/http-status.ts new file mode 100644 index 000000000..2720713f0 --- /dev/null +++ b/packages/sdk-core/src/http-status.ts @@ -0,0 +1,43 @@ +// One rule for when an HTTP client span is an error, whichever SDK or +// instrumentation made it: a response status is an error only when the app +// lists it in `errors.captureHttpStatus`; a network failure always is. + +/** A status code, or an inclusive `[from, to]` range. */ +export type HttpStatusRange = number | readonly [number, number] + +/** A span attribute value as OTel models it; an SDK with looser values narrows them before asking. */ +export type AttributeValue = + | string + | number + | boolean + | ReadonlyArray + +/** Reads one attribute of the span being classified. */ +export type ReadAttribute = (key: string) => AttributeValue | undefined + +export const inStatusRanges = (status: number, ranges: ReadonlyArray): boolean => + ranges.some((range) => + typeof range === "number" ? range === status : status >= range[0] && status <= range[1], + ) + +/** The status on a span's attributes, current or pre-1.23 semconv key. */ +export function responseStatus(read: ReadAttribute): number | undefined { + const value = read("http.response.status_code") ?? read("http.status_code") + if (typeof value === "number") return value + if (typeof value === "string" && /^\d{3}$/.test(value)) return Number(value) + return undefined +} + +/** + * The `error.type` / `error.message` pair for a listed status or a network + * failure (`TypeError`, `timeout`), e.g. `POST https://api.example.com/users/42 -> 503`. + * The query is dropped; ids in the path are redacted by the issue fingerprint. + */ +export function httpStatusError( + read: ReadAttribute, + status: number | string, +): { readonly "error.type": string; readonly "error.message": string } { + const method = read("http.request.method") ?? read("http.method") ?? "GET" + const url = String(read("url.full") ?? read("http.url") ?? "").replace(/[?#].*$/, "") + return { "error.type": String(status), "error.message": `${String(method)} ${url} -> ${status}` } +} diff --git a/packages/sdk-core/src/http.test.ts b/packages/sdk-core/src/http.test.ts new file mode 100644 index 000000000..f8938613a --- /dev/null +++ b/packages/sdk-core/src/http.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" +import { filterHeaderAttribute, resolveHeaderCapture } from "./http-headers" +import { + type AttributeValue, + httpStatusError, + inStatusRanges, + type ReadAttribute, + responseStatus, +} from "./http-status" + +const attributes = + (values: Record): ReadAttribute => + (key) => + values[key] + +describe("http status policy", () => { + it("matches codes and inclusive ranges", () => { + expect(inStatusRanges(503, [[500, 599]])).toBe(true) + expect(inStatusRanges(429, [429])).toBe(true) + expect(inStatusRanges(404, [[500, 599], 429])).toBe(false) + }) + + it("reads the current and the legacy status key", () => { + expect(responseStatus(attributes({ "http.response.status_code": 502 }))).toBe(502) + expect(responseStatus(attributes({ "http.status_code": "404" }))).toBe(404) + expect(responseStatus(attributes({}))).toBeUndefined() + }) + + it("describes the failure by method and URL without the query", () => { + const read = attributes({ + "http.request.method": "POST", + "url.full": "https://api.test/users/42?token=x", + }) + expect(httpStatusError(read, 503)).toEqual({ + "error.type": "503", + "error.message": "POST https://api.test/users/42 -> 503", + }) + }) +}) + +describe("header capture", () => { + it("never keeps credential headers, even when listed", () => { + expect( + resolveHeaderCapture({ request: ["Authorization", "X-Request-Id"], response: ["set-cookie"] }), + ).toEqual({ + request: ["x-request-id"], + response: [], + }) + }) + + it("keeps only allowlisted header attributes, as string arrays", () => { + const capture = resolveHeaderCapture({ response: ["x-cache"] }) + expect(filterHeaderAttribute(capture, "http.response.header.x-cache", "HIT")).toEqual(["HIT"]) + expect(filterHeaderAttribute(capture, "http.response.header.server", "nginx")).toBeUndefined() + expect(filterHeaderAttribute(capture, "http.request.header.x-cache", "HIT")).toBeUndefined() + expect(filterHeaderAttribute(capture, "url.full", "https://a.test")).toBe("https://a.test") + }) +}) diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts new file mode 100644 index 000000000..da3a24391 --- /dev/null +++ b/packages/sdk-core/src/index.ts @@ -0,0 +1,38 @@ +// Runtime-agnostic: nothing here touches the DOM at import time, so the Effect +// SDK's server and Workers presets can bundle it. DOM collectors live behind +// the `./browser/*` subpaths. +export { asError, stackWithCauses } from "./errors" +export type { ErrorFilter, ErrorFilterHint, ErrorFilterOptions, ErrorSource } from "./error-filters" +export { frameUrls, makeErrorFilter } from "./error-filters" +export type { HeaderCapture, HeaderCaptureOptions } from "./http-headers" +export { filterHeaderAttribute, resolveHeaderCapture } from "./http-headers" +export type { AttributeValue, HttpStatusRange, ReadAttribute } from "./http-status" +export { httpStatusError, inStatusRanges, responseStatus } from "./http-status" +export type { EmitLog, LogAttributeValue, SignalLogRecord, SpanLink } from "./log-record" +export { Severity, severityOf } from "./log-record" +export type { + ConsoleLevel, + ReplayOptions, + ResolvedReplayOptions, + ResolvedSignalOptions, + SignalOptions, + TracingSignalOptions, +} from "./options" +export { resolveReplayOptions, resolveSignalOptions } from "./options" +export type { PageSignal } from "./page" +export { + claimPageSignal, + markReported, + notifyErrorRecorded, + onErrorRecorded, + resetPageForTests, + wasReported, +} from "./page" +export type { SamplingDecision } from "./sampling" +export { + randomnessValue, + rejectionThreshold, + resolveSampleRate, + sampleSession, + sessionRoll, +} from "./sampling" diff --git a/packages/sdk-core/src/log-record.ts b/packages/sdk-core/src/log-record.ts new file mode 100644 index 000000000..dc87eea62 --- /dev/null +++ b/packages/sdk-core/src/log-record.ts @@ -0,0 +1,45 @@ +// The log record every shared collector emits. Neutral on purpose: each SDK +// maps it onto its own pipeline (the OTel logs SDK, the Effect log buffer). + +export type LogAttributeValue = string | number | boolean + +/** OTel severity numbers for the levels the SDKs emit. */ +export const Severity = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 } as const + +/** A span to link a record to; structurally an OTel `SpanContext`. */ +export interface SpanLink { + readonly traceId: string + readonly spanId: string + readonly traceFlags: number +} + +export interface SignalLogRecord { + /** Set for a log-based event (`LogRecord.event_name`); absent for a plain log line. */ + readonly eventName?: string | undefined + readonly severityNumber: number + readonly severityText: string + readonly body?: string | undefined + readonly attributes?: Readonly> | undefined + /** Epoch ms when it happened. Defaults to now. */ + readonly timestamp?: number | undefined + /** The span to link to. Defaults to whatever the SDK considers active. */ + readonly link?: SpanLink | undefined +} + +export type EmitLog = (record: SignalLogRecord) => void + +/** A `Map`, not an object: a level string like `"constructor"` must not reach the prototype. */ +const LEVELS = new Map([ + ["error", "ERROR"], + ["warn", "WARN"], + ["debug", "DEBUG"], +]) + +/** Severity for a console level: `log` and `info` are INFO. */ +export function severityOf(level: string | undefined): { + readonly number: number + readonly text: keyof typeof Severity +} { + const text = (level !== undefined ? LEVELS.get(level) : undefined) ?? "INFO" + return { number: Severity[text], text } +} diff --git a/packages/sdk-core/src/options.ts b/packages/sdk-core/src/options.ts new file mode 100644 index 000000000..8882dd10f --- /dev/null +++ b/packages/sdk-core/src/options.ts @@ -0,0 +1,140 @@ +// Configuration groups both SDKs accept with the same names, defaults and +// meaning. Each SDK's config extends these; `resolveSignalOptions` is the one +// place the defaults live. +import type { ErrorFilterOptions } from "./error-filters" +import { type HeaderCapture, type HeaderCaptureOptions, resolveHeaderCapture } from "./http-headers" +import { resolveSampleRate } from "./sampling" + +export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error" + +export interface TracingSignalOptions { + /** + * Fraction of sessions whose traces are exported, 0–1. Default 1. Decided + * once per session, so a sampled session keeps every trace. Error spans are + * always exported. + */ + readonly sampleRate?: number + /** + * Request and response headers to record on HTTP client spans, as + * `http.request.header.` / `http.response.header.`. Default none. + * Credential headers (`authorization`, `cookie`, `set-cookie`, ...) are never recorded. + */ + readonly captureHeaders?: HeaderCaptureOptions + /** Span main-thread frames of 100ms or more. Default false. */ + readonly longFrames?: boolean + /** Span interactions of 200ms or more, split into input delay, processing and presentation. Default false. */ + readonly slowInteractions?: boolean +} + +export interface ReplayOptions { + /** Record session replays. Default true. */ + readonly enabled?: boolean + /** Fraction of sessions to record, 0–1. Default 1. */ + readonly sampleRate?: number + /** + * Fraction of the sessions not recorded that keep the last minute in memory, + * and upload it and record the rest of the session only if an error happens. + * 0–1, default 0. + */ + readonly onErrorSampleRate?: number + /** + * Record `` content at this many frames per second. Off by default, + * and never with `maskAllText`: canvas pixels can hold text. + */ + readonly canvasFps?: number + /** + * Keep text bodies on the replay's network events for these full URLs, cut to + * `maxLength` characters (at most and by default 1,000). Nothing with + * `maskAllText`; request bodies only with `maskAllInputs` off. + */ + readonly networkBodies?: { + readonly urls: ReadonlyArray + readonly maxLength?: number + } +} + +/** The signal options shared verbatim by `MapleBrowser.init` and the Effect client presets. */ +export interface SignalOptions { + readonly tracing?: TracingSignalOptions + /** Which captured errors to drop, and which HTTP statuses count as errors. */ + readonly errors?: ErrorFilterOptions + /** Report Core Web Vitals as `browser.web_vital` log events. Default true. */ + readonly webVitals?: boolean + /** Keep the last clicks, inputs, navigations and console lines, and export them with the next error. Default true. */ + readonly breadcrumbs?: boolean + readonly logs?: { + /** Console levels exported as logs as they happen, e.g. `["warn", "error"]`. Default none. */ + readonly captureConsole?: ReadonlyArray + } + readonly reporting?: { + /** Content Security Policy violations as `maple.browser.csp_violation` WARN logs. Default true. */ + readonly csp?: boolean + /** Browser deprecation and intervention reports as `maple.browser.report` WARN logs. Default false. */ + readonly browserReports?: boolean + } + readonly transport?: { + /** + * Keep batches that could not be sent in IndexedDB for up to 24 hours and + * send them once the browser is back online or on the next page load. Default false. + */ + readonly offline?: boolean + } +} + +export interface ResolvedSignalOptions { + readonly tracingSampleRate: number + readonly captureHeaders: HeaderCapture + readonly longFrames: boolean + readonly slowInteractions: boolean + readonly errorFilters: ErrorFilterOptions + readonly webVitals: boolean + readonly breadcrumbs: boolean + readonly captureConsole: ReadonlyArray + readonly reportCsp: boolean + readonly reportBrowser: boolean + readonly offlineQueue: boolean +} + +export function resolveSignalOptions(options: SignalOptions): ResolvedSignalOptions { + return { + tracingSampleRate: resolveSampleRate("tracing.sampleRate", options.tracing?.sampleRate), + captureHeaders: resolveHeaderCapture(options.tracing?.captureHeaders), + longFrames: options.tracing?.longFrames ?? false, + slowInteractions: options.tracing?.slowInteractions ?? false, + errorFilters: options.errors ?? {}, + webVitals: options.webVitals ?? true, + breadcrumbs: options.breadcrumbs ?? true, + captureConsole: options.logs?.captureConsole ?? [], + reportCsp: options.reporting?.csp ?? true, + reportBrowser: options.reporting?.browserReports ?? false, + offlineQueue: options.transport?.offline ?? false, + } +} + +/** Ingest keeps 1,024 bytes of a session-event attribute; this leaves room for the cut marker. */ +const MAX_BODY_LENGTH = 1_000 + +export interface ResolvedReplayOptions { + readonly enabled: boolean + readonly sampleRate: number + readonly onErrorSampleRate: number + readonly canvasFps: number | undefined + readonly networkBodies: + | { readonly urls: ReadonlyArray; readonly maxLength: number } + | undefined +} + +export function resolveReplayOptions(replay: ReplayOptions | undefined): ResolvedReplayOptions { + return { + enabled: replay?.enabled ?? true, + sampleRate: resolveSampleRate("replay.sampleRate", replay?.sampleRate), + onErrorSampleRate: resolveSampleRate("replay.onErrorSampleRate", replay?.onErrorSampleRate, 0), + canvasFps: replay?.canvasFps, + networkBodies: replay?.networkBodies?.urls.length + ? { + urls: replay.networkBodies.urls, + maxLength: Math.min(MAX_BODY_LENGTH, replay.networkBodies.maxLength ?? MAX_BODY_LENGTH), + } + : undefined, + } +} diff --git a/packages/sdk-core/src/page.test.ts b/packages/sdk-core/src/page.test.ts new file mode 100644 index 000000000..d6f19b529 --- /dev/null +++ b/packages/sdk-core/src/page.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest" +import { + claimPageSignal, + leasePageSignalAsOtherCopyForTests, + markReported, + notifyErrorRecorded, + onErrorRecorded, + pageSignalLeasesForTests, + resetPageForTests, + wasReported, +} from "./page" + +afterEach(() => resetPageForTests()) + +const LINK = { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", traceFlags: 1 } +const KEY = "__MAPLE_SDK_PAGE_V1__" + +describe("page coordination", () => { + it("records an error object once across SDK copies", () => { + const error = new Error("x") + expect(wasReported(error)).toBe(false) + markReported(error) + expect(wasReported(error)).toBe(true) + expect(wasReported("a string")).toBe(false) + }) + + it("tells every listener about a recorded error, and survives a throwing one", () => { + const seen: string[] = [] + const stopBroken = onErrorRecorded(() => { + throw new Error("listener bug") + }) + const stop = onErrorRecorded((link) => seen.push(link.spanId)) + notifyErrorRecorded(LINK) + stop() + stopBroken() + notifyErrorRecorded(LINK) + expect(seen).toEqual([LINK.spanId]) + }) + + it("leases each collector to one bundled copy until its last instance releases it", () => { + const first = claimPageSignal("webVitals") + const second = claimPageSignal("webVitals") + expect(first).toBeDefined() + expect(second).toBeDefined() + first?.() + first?.() + expect(pageSignalLeasesForTests("webVitals")).toBe(1) + second?.() + expect(pageSignalLeasesForTests("webVitals")).toBe(0) + expect(claimPageSignal("webVitals")).toBeDefined() + }) + + it("refuses a collector another copy runs, and leaves the other collectors free", () => { + leasePageSignalAsOtherCopyForTests("webVitals") + expect(claimPageSignal("webVitals")).toBeUndefined() + expect(claimPageSignal("csp")).toBeDefined() + }) + + it("starts fresh instead of misreading state an older copy wrote", () => { + Object.assign(globalThis, { [KEY]: { reported: new WeakSet(), errorListeners: new Set() } }) + expect(claimPageSignal("longFrames")).toBeDefined() + expect(wasReported(new Error("y"))).toBe(false) + }) +}) diff --git a/packages/sdk-core/src/page.ts b/packages/sdk-core/src/page.ts new file mode 100644 index 000000000..bc01d713a --- /dev/null +++ b/packages/sdk-core/src/page.ts @@ -0,0 +1,111 @@ +// Page-wide coordination between SDK copies. Both SDKs can run on one page, and +// each bundles its own copy of this module, so the state lives on `globalThis`: +// one error is one issue, one copy runs each collector, and errors reach both. +import type { SpanLink } from "./log-record" + +/** One page-level signal that must be collected once per page: one per option, so each SDK keeps its own settings. */ +export type PageSignal = + | "breadcrumbs" + | "browserReports" + | "console" + | "csp" + | "longFrames" + | "slowInteractions" + | "webVitals" + +interface Lease { + readonly owner: symbol + count: number +} + +interface PageState { + readonly version: 1 + readonly reported: WeakSet + readonly errorListeners: Set<(link: SpanLink) => void> + readonly leases: Map +} + +/** Versioned: a copy with another layout gets its own slot instead of overwriting this one. */ +const KEY = "__MAPLE_SDK_PAGE_V1__" +/** This bundled copy: instances of one copy share a lease, another copy does not. */ +const COPY = Symbol("maple-sdk-copy") + +const isPageState = (value: unknown): value is PageState => + typeof value === "object" && value !== null && "version" in value && value.version === 1 + +function page(): PageState { + const owner: Record = globalThis + const existing = owner[KEY] + if (isPageState(existing)) return existing + const fresh: PageState = { + version: 1, + reported: new WeakSet(), + errorListeners: new Set(), + leases: new Map(), + } + owner[KEY] = fresh + return fresh +} + +/** Whether this exact error object was already recorded, by either SDK. */ +export function wasReported(error: unknown): boolean { + return typeof error === "object" && error !== null && page().reported.has(error) +} + +/** Claim `error` as recorded, so a rethrow or a second handler does not record it again. */ +export function markReported(error: unknown): void { + if (typeof error === "object" && error !== null) page().reported.add(error) +} + +/** Told about every recorded error: breadcrumbs export their trail, a buffered replay keeps itself. */ +export function onErrorRecorded(listener: (link: SpanLink) => void): () => void { + page().errorListeners.add(listener) + return () => page().errorListeners.delete(listener) +} + +export function notifyErrorRecorded(link: SpanLink): void { + for (const listener of page().errorListeners) { + // A listener must never turn one error into another. + try { + listener(link) + } catch {} + } +} + +/** + * Lease one page collector for this bundled copy. `undefined` means another + * copy already runs it, and starting it again would report everything twice. + */ +export function claimPageSignal(signal: PageSignal): (() => void) | undefined { + const leases = page().leases + const lease = leases.get(signal) + if (lease && lease.owner !== COPY) return undefined + const held = lease ?? { owner: COPY, count: 0 } + held.count++ + leases.set(signal, held) + let released = false + return () => { + if (released) return + released = true + held.count-- + if (held.count <= 0 && leases.get(signal) === held) leases.delete(signal) + } +} + +/** Test seam: how many instances of this copy hold `signal`, 0 when another copy or nobody does. */ +export function pageSignalLeasesForTests(signal: PageSignal): number { + const lease = page().leases.get(signal) + return lease?.owner === COPY ? lease.count : 0 +} + +/** Test seam: have another bundled copy hold `signal`. */ +export function leasePageSignalAsOtherCopyForTests(signal: PageSignal): void { + page().leases.set(signal, { owner: Symbol("other maple-sdk copy"), count: 1 }) +} + +/** Test seam: forget reported errors and leases; listeners belong to live SDK instances and stay. */ +export function resetPageForTests(): void { + const listeners = page().errorListeners + const owner: Record = globalThis + owner[KEY] = { version: 1, reported: new WeakSet(), errorListeners: listeners, leases: new Map() } +} diff --git a/packages/sdk-core/src/sampling.test.ts b/packages/sdk-core/src/sampling.test.ts new file mode 100644 index 000000000..6f434bbea --- /dev/null +++ b/packages/sdk-core/src/sampling.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" +import { + randomnessValue, + rejectionThreshold, + resolveSampleRate, + sampleSession, + sessionRoll, +} from "./sampling" + +/** The weight ingest derives from `th`: inverse of the acceptance probability. */ +const weightOf = (hex: string): number => 1 / (1 - Number.parseInt(hex, 16) / 16 ** hex.length) + +describe("rejectionThreshold", () => { + it("encodes the probability so ingest reads back its inverse as the weight", () => { + expect(weightOf(rejectionThreshold(0.1))).toBeCloseTo(10, 6) + expect(weightOf(rejectionThreshold(0.25))).toBeCloseTo(4, 6) + expect(rejectionThreshold(0.5)).toBe("8") + expect(rejectionThreshold(1)).toBe("0") + }) +}) + +describe("randomnessValue", () => { + it("maps a roll onto 56 bits so that rv >= th exactly when the roll is under the rate", () => { + const threshold = Math.round((1 - 0.25) * 2 ** 56) + expect(randomnessValue(0.2)).toBeGreaterThanOrEqual(threshold) + expect(randomnessValue(0.3)).toBeLessThan(threshold) + expect(randomnessValue(0)).toBe(2 ** 56 - 1) + }) +}) + +describe("sessionRoll", () => { + it("is stable per session and within [0, 1)", () => { + const roll = sessionRoll("3b0e7f4c-5a8e-4d0a-9b61-0c5a5d1e2f3a") + expect(roll).toBe(sessionRoll("3b0e7f4c-5a8e-4d0a-9b61-0c5a5d1e2f3a")) + expect(roll).toBeGreaterThanOrEqual(0) + expect(roll).toBeLessThan(1) + }) +}) + +describe("sampleSession", () => { + it("samples a whole session or none of it, and marks sampled roots with th and rv", () => { + const ids = Array.from({ length: 40 }, (_, i) => `session-${i}`) + for (const id of ids) { + const decision = sampleSession(id, 0.5) + expect(sampleSession(id, 0.5)).toEqual(decision) + expect(decision.sampled).toBe(sessionRoll(id) < 0.5) + if (decision.sampled) { + expect(decision.traceState).toMatch(/^ot=th:8;rv:[0-9a-f]{14}$/) + expect( + Number.parseInt(decision.traceState?.split("rv:")[1] ?? "", 16), + ).toBeGreaterThanOrEqual(2 ** 55) + } + } + expect(ids.some((id) => sampleSession(id, 0.5).sampled)).toBe(true) + expect(ids.every((id) => sampleSession(id, 0.5).sampled)).toBe(false) + }) + + it("adds no tracestate at rate 1 and keeps nothing at 0", () => { + expect(sampleSession("any", 1)).toEqual({ sampled: true }) + expect(sampleSession("any", 0)).toEqual({ sampled: false }) + }) +}) + +describe("resolveSampleRate", () => { + it("defaults, clamps and rejects non-numbers", () => { + expect(resolveSampleRate("x", undefined)).toBe(1) + expect(resolveSampleRate("x", undefined, 0)).toBe(0) + expect(resolveSampleRate("x", 0.3)).toBe(0.3) + expect(resolveSampleRate("x", 2)).toBe(1) + expect(resolveSampleRate("x", Number.NaN)).toBe(1) + }) +}) diff --git a/packages/sdk-core/src/sampling.ts b/packages/sdk-core/src/sampling.ts new file mode 100644 index 000000000..11afafacd --- /dev/null +++ b/packages/sdk-core/src/sampling.ts @@ -0,0 +1,72 @@ +// Head sampling decided per session rather than per trace, so a session's +// replay never links to a trace that was dropped halfway through it. Pure: each +// SDK applies the decision in its own tracer. + +/** FNV-1a over the session id, mapped to [0, 1). */ +export function sessionRoll(sessionId: string): number { + let hash = 0x811c9dc5 + for (let i = 0; i < sessionId.length; i++) { + hash ^= sessionId.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0) / 2 ** 32 +} + +const MAX_56 = 2 ** 56 + +/** 56 bits as the 14-hex-digit form `ot=th`/`ot=rv` use; `th` drops trailing zeros. */ +const hex56 = (value: number): string => value.toString(16).padStart(14, "0") + +/** + * The W3C `ot=th:` rejection threshold for a sampling probability: 56 bits, + * hex, trailing zeros dropped. Ingest reads it back as the span's weight. + */ +export function rejectionThreshold(probability: number): string { + return hex56(Math.round((1 - probability) * MAX_56)).replace(/0+$/, "") || "0" +} + +/** + * The session's randomness as the W3C `ot=rv:` value. The decision is `rv >= th`, + * so a downstream consistent-probability sampler reaches the same answer. + */ +export function randomnessValue(roll: number): number { + return Math.min(MAX_56 - 1, Math.floor((1 - roll) * MAX_56)) +} + +export interface SamplingDecision { + readonly sampled: boolean + /** The `tracestate` value for a sampled trace at a rate below 1: `ot=th:…;rv:…`. */ + readonly traceState?: string | undefined +} + +const KEEP: SamplingDecision = { sampled: true } +const DROP: SamplingDecision = { sampled: false } + +/** + * Whether a new root trace in `sessionId` is exported at `rate`. Without a + * session (SSR, consent withheld) the roll is per trace. + */ +export function sampleSession(sessionId: string | undefined, rate: number): SamplingDecision { + if (rate >= 1) return KEEP + if (rate <= 0) return DROP + const rv = randomnessValue(sessionId ? sessionRoll(sessionId) : Math.random()) + if (rv < Math.round((1 - rate) * MAX_56)) return DROP + return { sampled: true, traceState: `ot=th:${rejectionThreshold(rate)};rv:${hex56(rv)}` } +} + +/** A rate outside 0 to 1 is a typo, not a policy: clamp it and say so. */ +export function resolveSampleRate(option: string, raw: number | undefined, fallback = 1): number { + if (raw === undefined) return fallback + if (typeof raw !== "number" || Number.isNaN(raw)) { + console.warn( + `[maple] ${option} must be a number between 0 and 1; got ${String(raw)}. Using ${fallback}.`, + ) + return fallback + } + if (raw < 0 || raw > 1) { + const clamped = Math.min(1, Math.max(0, raw)) + console.warn(`[maple] ${option} must be between 0 and 1; got ${raw}. Using ${clamped}.`) + return clamped + } + return raw +} diff --git a/packages/sdk-core/tsconfig.json b/packages/sdk-core/tsconfig.json new file mode 100644 index 000000000..aad191f15 --- /dev/null +++ b/packages/sdk-core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["src/**/*.ts"], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": false + } +} diff --git a/packages/sdk-core/vitest.config.ts b/packages/sdk-core/vitest.config.ts new file mode 100644 index 000000000..9744cdcbb --- /dev/null +++ b/packages/sdk-core/vitest.config.ts @@ -0,0 +1,29 @@ +import { playwright } from "@vitest/browser-playwright" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: "node", + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["src/**/*.browser.test.ts"], + }, + }, + { + test: { + name: "browser", + include: ["src/**/*.browser.test.ts"], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + }, + }, + }, + ], + }, +}) diff --git a/packages/tsconfig.browser.dts.json b/packages/tsconfig.browser.dts.json index c80c53f3b..8fe4f383a 100644 --- a/packages/tsconfig.browser.dts.json +++ b/packages/tsconfig.browser.dts.json @@ -1,7 +1,7 @@ { // Declaration emit for browser's tsdown build only. It lives here so tsgo's - // rootDir covers @maple/browser-session, whose types the SDK bundles. + // rootDir covers @maple/browser-session and @maple/sdk-core, whose types the SDK bundles. "extends": "./browser/tsconfig.json", - "include": ["./browser/src/**/*.ts", "./browser-session/src/**/*.ts"], + "include": ["./browser/src/**/*.ts", "./browser-session/src/**/*.ts", "./sdk-core/src/**/*.ts"], "exclude": ["**/*.test.ts", "**/*.bench.ts"] }