From 2dc22191fc5f41553fc04811b05901b863312d15 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:02:09 +0200 Subject: [PATCH 1/2] feat(browser): OTel logs pipeline, MapleBrowser.logger and per-session trace sampling Adds the logs signal to the browser SDK and a way to sample traces. - MapleBrowser.logger.{debug,info,warn,error} writes OpenTelemetry log records, linked to the active span and stamped with session.id/user.id. Records queue in a small eager module; the OTel LoggerProvider and OTLP exporter live in a new deferred chunk that init() imports right away, so the logs SDK stays off the eager bundle. - tracing.sampleRate samples traces per session (FNV hash of session.id), so a sampled session keeps every trace and its replay never links to a dropped one. Sampled roots carry the W3C ot=th threshold, which ingest already turns into the SampleRate weight. Remote parents are followed. - Error spans from captureException and the global handlers are always exported, detached from an unsampled parent so they are not orphans. - The size script reports the deferred chunk on its own line. Eager budget 38 -> 41 kB and first-party 14.5 -> 16 kB for the sampler and log queue. --- bun.lock | 4 + docs/browser-sdk.md | 25 ++++ packages/browser/README.md | 19 ++- packages/browser/package.json | 2 + packages/browser/scripts/size.ts | 43 ++++-- packages/browser/src/config.ts | 18 ++- packages/browser/src/deferred/index.ts | 11 ++ packages/browser/src/deferred/logs.ts | 73 ++++++++++ packages/browser/src/errors.ts | 21 +-- packages/browser/src/index.ts | 9 ++ packages/browser/src/init.ts | 17 +++ packages/browser/src/logger.ts | 31 +++++ packages/browser/src/logs.browser.test.ts | 137 +++++++++++++++++++ packages/browser/src/logs.ts | 84 ++++++++++++ packages/browser/src/navigation.test.ts | 1 + packages/browser/src/sampling.test.ts | 82 +++++++++++ packages/browser/src/sampling.ts | 76 ++++++++++ packages/browser/src/tracing.browser.test.ts | 1 + packages/browser/src/tracing.ts | 38 ++--- 19 files changed, 647 insertions(+), 45 deletions(-) create mode 100644 packages/browser/src/deferred/index.ts create mode 100644 packages/browser/src/deferred/logs.ts create mode 100644 packages/browser/src/logger.ts create mode 100644 packages/browser/src/logs.browser.test.ts create mode 100644 packages/browser/src/logs.ts create mode 100644 packages/browser/src/sampling.test.ts create mode 100644 packages/browser/src/sampling.ts diff --git a/bun.lock b/bun.lock index ad4c665d8e..4d64e70dbc 100644 --- a/bun.lock +++ b/bun.lock @@ -642,10 +642,12 @@ "version": "0.9.0", "dependencies": { "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", "@opentelemetry/instrumentation": "^0.222.0", "@opentelemetry/instrumentation-fetch": "^0.222.0", "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-trace-base": "^2.10.0", "@opentelemetry/sdk-trace-web": "^2.10.0", "@opentelemetry/semantic-conventions": "^1.43.0", @@ -1677,6 +1679,8 @@ "@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.222.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.222.0", "@opentelemetry/otlp-transformer": "0.222.0", "@opentelemetry/sdk-logs": "0.222.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg=="], + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.222.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.222.0", "@opentelemetry/otlp-transformer": "0.222.0", "@opentelemetry/sdk-trace": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.222.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.222.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fhbRDuAgzPKpq1k5+hX3uS+3QAYNca0on0Ngot/R8dDsQtuFaADeZnG2uSgxABuvcI7S2phAaRhbct9jieeKZw=="], diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 0c5cf177a0..a7ad6d752d 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -53,6 +53,7 @@ Every field accepted by `MapleBrowser.init`: | `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. | | `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). | | `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). | +| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; error spans are always exported. See [Sampling](#sampling). | | `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. | | `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). | | `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. | @@ -241,6 +242,21 @@ Cross-origin scripts report a bare `"Script error."` with no stack or filename. since they all fingerprint to one empty issue. Add `crossorigin` to the script tag to get the real error. +## Logs + +`MapleBrowser.logger` writes OpenTelemetry log records. Each one is linked to the span active when +it was logged and carries `session.id` (and `user.id` once known), so it shows up on the trace, in +the logs explorer, and next to the session. + +```ts +MapleBrowser.logger.info("checkout started", { "cart.items": 3 }) +MapleBrowser.logger.error("payment declined", { "payment.provider": "card" }) +``` + +Levels are `debug`, `info`, `warn` and `error`. Attribute values are strings, numbers or booleans. +Calls before `init()` are queued. The logs SDK loads in a separate chunk right after `init()`, so it +stays out of the bundle every page load has to parse first. + ## Tracing across origins `fetch` spans send the W3C `traceparent` header to same-origin requests only. When your API lives @@ -327,14 +343,23 @@ privacy: { To record only a fraction of sessions, set `replay.sampleRate` between `0` and `1`. For example, `0.1` records ~10% of sessions. Tracing is unaffected by this setting. +`tracing.sampleRate` does the same for traces. The decision is made once per session (a hash of +`session.id`), so a sampled session keeps every one of its traces and its replay never links to a +dropped one. Spans that record an error are always exported, whatever the rate. + ```ts MapleBrowser.init({ ingestKey: "maple_pk_...", serviceName: "acme-web", + tracing: { sampleRate: 0.25 }, replay: { sampleRate: 0.1 }, }) ``` +Sampled traces carry the W3C `tracestate` threshold (`ot=th:…`), so Maple weights each one by the +inverse of the rate and request counts stay realistic. A trace joined from a server-rendered +`traceparent` follows the server's decision instead. + ## Framework examples ### Plain HTML diff --git a/packages/browser/README.md b/packages/browser/README.md index d2a5cd1cd9..4d39b34387 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -72,8 +72,9 @@ Bundled, minified and gzipped, as your bundler would ship it: | | gzipped | what it is | | ---------------- | ------- | --------------------------------------------------------- | -| **eager** | ~36 kB | every page load, before any sampling decision | -| ↳ our code alone | ~13 kB | the marginal cost if your app already ships OpenTelemetry | +| **eager** | ~40 kB | every page load, before any sampling decision | +| ↳ our code alone | ~16 kB | the marginal cost if your app already ships OpenTelemetry | +| **deferred** | ~5 kB | the OTel logs SDK, fetched right after `init()` | | **lazy** | ~61 kB | rrweb — downloaded only by sessions sampled into replay | The eager figure is ~90% OpenTelemetry. If your app already uses the OTel web @@ -118,6 +119,20 @@ a separate analytics silo. Calls before `init()` finishes are queued. MapleBrowser.track("checkout_completed", { plan: "pro", seats: 12 }) ``` +## Logs + +`MapleBrowser.logger` writes OpenTelemetry log records, linked to the active span and the session. +Calls before `init()` are queued. + +```ts +MapleBrowser.logger.info("checkout started", { "cart.items": 3 }) +``` + +## Trace sampling + +`tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per +session, so a sampled session keeps all of its traces. Error spans are always exported. + ## Regions Maple runs separate US and EU instances, and an ingest key only works in the diff --git a/packages/browser/package.json b/packages/browser/package.json index 68af26e7bb..1426327add 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -43,10 +43,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.222.0", "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", "@opentelemetry/instrumentation": "^0.222.0", "@opentelemetry/instrumentation-fetch": "^0.222.0", "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-logs": "^0.222.0", "@opentelemetry/sdk-trace-base": "^2.10.0", "@opentelemetry/sdk-trace-web": "^2.10.0", "@opentelemetry/semantic-conventions": "^1.43.0", diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index fa8e1ba8f4..d70905b020 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -6,12 +6,14 @@ * unminified and with every dependency left external, so reading it tells you * almost nothing. What a visitor downloads is the *bundled, minified, gzipped* * graph their bundler produces, OpenTelemetry and rrweb included. This builds - * exactly that and splits it two ways: + * exactly that and splits it three ways: * - * eager — the entry plus everything statically reachable from it. Paid by - * every visitor on every page load, before any sampling decision. - * lazy — reachable only through `import()`. Paid only by visitors sampled - * into replay, which is the entire point of the code split. + * eager — the entry plus everything statically reachable from it. Paid by + * every visitor on every page load, before any sampling decision. + * deferred — the `./deferred` chunk `init()` imports right away. Paid by every + * visitor too, but after `init()` and off the critical path. + * lazy — the rrweb chunk. Paid only by visitors sampled into replay, + * which is the entire point of the code split. * * A regression in `eager` is the expensive kind: it hits 100% of page loads. * The budgets below fail CI so that cost has to be argued for in review rather @@ -21,8 +23,14 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { - /** 38 since 2026-09: navigation spans took it to ~37.3 kB; see `firstParty`. */ - eager: 38, + /** + * 41 since 2026-09: per-session trace sampling and the `logger` queue added + * ~2.4 kB (~1.2 kB code, the rest chunk-split overhead now that a second + * chunk shares the OTel core). Was 38 for navigation spans. + */ + eager: 41, + /** Every page load, after `init()`: the OTel logs SDK and exporter. */ + deferred: 8, lazy: 68, /** * Our own eager code, with OpenTelemetry and rrweb left external. @@ -45,8 +53,11 @@ const BUDGET = { * 14.5 since 2026-09: navigation and data-loading spans (`startNavigation`, * `endNavigation`, `traced`) added ~0.6 kB. Apps used to copy the same code * into their own bundle, so for them this is a move rather than a cost. + * + * 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue + * (~0.5 kB), both needed before the deferred chunk lands. */ - firstParty: 14.5, + firstParty: 16, } /** How close to a ceiling counts as worth warning about. */ @@ -119,7 +130,12 @@ const eagerChunks = (group: Chunk[]): Chunk[] => { const eager = eagerChunks(chunks) const eagerNames = new Set(eager.map((chunk) => chunk.name)) -const lazy = chunks.filter((chunk) => !eagerNames.has(chunk.name)) +const notEager = chunks.filter((chunk) => !eagerNames.has(chunk.name)) +// The rrweb chunk is the one carrying the recorder; every other `import()` +// target is deferred work that every page load fetches. +const isReplay = (chunk: Chunk): boolean => chunk.text.includes("rrweb") +const lazy = notEager.filter(isReplay) +const deferred = notEager.filter((chunk) => !isReplay(chunk)) const total = (group: Chunk[]): number => group.reduce((sum, chunk) => sum + chunk.gzip, 0) // Same entry, dependencies left external: what a host app that already ships @@ -145,11 +161,12 @@ const report = (label: string, group: Chunk[], budget: number): boolean => { } console.log("@maple-dev/browser — bundled, minified, gzipped") -const eagerOk = report("eager every page load ", eager, BUDGET.eager) -const lazyOk = report("lazy sampled sessions", lazy, BUDGET.lazy) -const firstPartyOk = report("ours eager, deps external", firstParty, BUDGET.firstParty) +const eagerOk = report("eager every page load ", eager, BUDGET.eager) +const deferredOk = report("deferred every page load, after init", deferred, BUDGET.deferred) +const lazyOk = report("lazy sampled sessions", lazy, BUDGET.lazy) +const firstPartyOk = report("ours eager, deps external", firstParty, BUDGET.firstParty) -if (!eagerOk || !lazyOk || !firstPartyOk) { +if (!eagerOk || !deferredOk || !lazyOk || !firstPartyOk) { console.error("\nbundle size exceeds budget — raise it in scripts/size.ts if the cost is intended") process.exit(1) } diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 852e0cd5d0..6b6139ae8b 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -67,6 +67,12 @@ 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 } readonly replay?: { /** Default true. */ @@ -131,6 +137,7 @@ export interface ResolvedConfig { readonly tracingInstrumentFetch: boolean readonly tracingCaptureErrors: boolean readonly propagateTraceHeaderCorsUrls: ReadonlyArray + readonly tracingSampleRate: number readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -159,17 +166,15 @@ export function resolveIdentity(config: { * 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(raw: number | undefined): number { +function resolveSampleRate(option: string, raw: number | undefined): number { if (raw === undefined) return 1 if (typeof raw !== "number" || Number.isNaN(raw)) { - console.warn( - `[maple] replay.sampleRate must be a number between 0 and 1; got ${String(raw)}. Using 1.`, - ) + 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] replay.sampleRate must be between 0 and 1; got ${raw}. Using ${clamped}.`) + console.warn(`[maple] ${option} must be between 0 and 1; got ${raw}. Using ${clamped}.`) return clamped } return raw @@ -195,8 +200,9 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true, tracingCaptureErrors: config.tracing?.captureErrors ?? true, propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [], + tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate), replayEnabled: config.replay?.enabled ?? true, - replaySampleRate: resolveSampleRate(config.replay?.sampleRate), + replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), 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 new file mode 100644 index 0000000000..fedef2c720 --- /dev/null +++ b/packages/browser/src/deferred/index.ts @@ -0,0 +1,11 @@ +// 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 { ResolvedConfig } from "../config" +import { startLogs } from "./logs" + +export function startDeferred(config: ResolvedConfig): () => Promise { + const stops = [startLogs(config)] + return async () => { + await Promise.all(stops.map((stop) => stop())) + } +} diff --git a/packages/browser/src/deferred/logs.ts b/packages/browser/src/deferred/logs.ts new file mode 100644 index 0000000000..df33b71408 --- /dev/null +++ b/packages/browser/src/deferred/logs.ts @@ -0,0 +1,73 @@ +import { consentAllowedSince, hasConsent, ingestHeaders, sdkHint } from "@maple/browser-session" +import { ROOT_CONTEXT, trace } from "@opentelemetry/api" +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http" +import { resourceFromAttributes } from "@opentelemetry/resources" +import { + BatchLogRecordProcessor, + LoggerProvider, + type LogRecordExporter, + type ReadableLogRecord, +} from "@opentelemetry/sdk-logs" +import type { ResolvedConfig } from "../config" +import { attachLogSink, detachLogSink } from "../logs" +import { resourceAttributes } from "../tracing" +import { SDK_NAME, SDK_VERSION } from "../version" + +/** Same rule as spans: drop records made while consent was absent, even if it is granted by flush time. */ +class ConsentLogExporter implements LogRecordExporter { + constructor(private readonly inner: LogRecordExporter) {} + + export(logs: ReadableLogRecord[], callback: (result: { code: number; error?: Error }) => void): void { + const since = consentAllowedSince() + const eligible = + hasConsent() && Number.isFinite(since) + ? logs.filter((log) => log.hrTime[0] * 1_000 + log.hrTime[1] / 1_000_000 >= since) + : [] + if (eligible.length === 0) { + callback({ code: 0 }) + return + } + this.inner.export(eligible, callback) + } + + forceFlush(): Promise { + return this.inner.forceFlush?.() ?? Promise.resolve() + } + + shutdown(): Promise { + return this.inner.shutdown() + } +} + +/** Start the OTel logs pipeline and drain the eager queue into it. Returns a shutdown. */ +export function startLogs(config: ResolvedConfig): () => Promise { + const exporter = new ConsentLogExporter( + new OTLPLogExporter({ + url: `${config.endpoint}/v1/logs`, + headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }), + }), + ) + // The browser processor flushes on `visibilitychange → hidden` and `pagehide` itself. + const provider = new LoggerProvider({ + resource: resourceFromAttributes(resourceAttributes(config)), + processors: [new BatchLogRecordProcessor({ exporter, scheduledDelayMillis: 2_000 })], + }) + const logger = provider.getLogger(SDK_NAME, SDK_VERSION) + attachLogSink((record) => + logger.emit({ + eventName: record.eventName, + severityNumber: record.severityNumber, + severityText: record.severityText, + body: record.body, + attributes: record.attributes, + timestamp: record.timestamp, + context: record.spanContext + ? trace.setSpanContext(ROOT_CONTEXT, record.spanContext) + : ROOT_CONTEXT, + }), + ) + return async () => { + detachLogSink() + await provider.shutdown() + } +} diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index c8424ea6fa..cd35964215 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -10,7 +10,8 @@ // 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 { scrubUrl } from "@maple/browser-session" -import { type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { context, type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { keepContext } from "./sampling" import { mapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -71,15 +72,19 @@ export function recordFailure(span: Span, error: unknown): void { span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) } -/** Record `error` on a one-off span. */ +/** Record `error` on a one-off span, exported whatever the session's trace sampling. */ function recordException(error: unknown, options: CaptureExceptionOptions): void { - const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", { - kind: SpanKind.INTERNAL, - attributes: { - ...(typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : undefined), - ...options.attributes, + const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan( + options.name ?? "exception", + { + kind: SpanKind.INTERNAL, + attributes: { + ...(typeof location !== "undefined" ? { "url.full": scrubUrl(location.href) } : undefined), + ...options.attributes, + }, }, - }) + keepContext(context.active()), + ) recordFailure(span, error) span.end() } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 8680c9161a..67ea08c16a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,6 +2,7 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/b import type { MapleBrowserConfig } from "./config" import { type CaptureExceptionOptions, captureException } from "./errors" import { identify, init, type MapleBrowserHandle } from "./init" +import { type MapleLogger, logger } from "./logger" import { endNavigation, startNavigation, type TracedOptions, traced } from "./navigation" export type { @@ -14,6 +15,8 @@ export type { export type { MapleBrowserConfig } from "./config" export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" +export type { LogAttributeValue } from "./logs" +export type { MapleLogger } from "./logger" export type { TracedOptions } from "./navigation" /** The `MapleBrowser` namespace object. */ @@ -52,6 +55,11 @@ export interface MapleBrowserApi { * Errors are recorded once and rethrown. Only requests started before `fn`'s first `await` nest under the span. */ traced: (name: string, fn: () => Promise, options?: TracedOptions) => Promise + /** + * Structured logs, exported as OpenTelemetry log records linked to the active + * span and the session. Safe before `init`: records queue until it runs. + */ + logger: MapleLogger } /** @@ -81,4 +89,5 @@ export const MapleBrowser: MapleBrowserApi = { startNavigation, endNavigation, traced, + logger, } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 745b4a4339..93184cf6ac 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -28,6 +28,7 @@ import type { ReplaySessionHandle } from "@maple/browser-session/replay" import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" import { setupErrorCapture } from "./errors" +import { setLogIdentity } from "./logs" import { resetNavigation } from "./navigation" import { setupTracing } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -93,6 +94,8 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { let rotateOnNextStart = false let shutdownTracing: (() => Promise) | undefined let stopErrorCapture: (() => void) | undefined + let deferredPending: Promise | undefined + let stopDeferred: (() => Promise) | undefined // Bumped by every start and stop, so a replay chunk that lands after a // consent revoke (or a rotation) never attaches a recorder to a dead runtime. let generation = 0 @@ -124,6 +127,17 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) { stopErrorCapture = setupErrorCapture() } + if (!deferredPending) { + setLogIdentity(() => activeConfig?.identity?.id) + deferredPending = import("./deferred") + // Started even when shutdown() is already waiting on it, so queued records still flush. + .then(({ startDeferred }) => { + stopDeferred = startDeferred(config) + }) + .catch(() => { + // A blocked chunk costs the deferred signals, never the page. + }) + } const shared = { endpoint: config.endpoint, ingestKey: config.ingestKey, @@ -224,6 +238,9 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopErrorCapture = undefined // Before the provider shuts down, so an open navigation exports with it resetNavigation() + await deferredPending + await stopDeferred?.() + stopDeferred = undefined await shutdownTracing?.() shutdownTracing = undefined setActiveTraceIdProvider(() => undefined) diff --git a/packages/browser/src/logger.ts b/packages/browser/src/logger.ts new file mode 100644 index 0000000000..dc44a1e163 --- /dev/null +++ b/packages/browser/src/logger.ts @@ -0,0 +1,31 @@ +import { emitLog, type LogAttributeValue, Severity } from "./logs" + +type LogMethod = (message: string, attributes?: Readonly>) => void + +export interface MapleLogger { + readonly debug: LogMethod + readonly info: LogMethod + readonly warn: LogMethod + readonly error: LogMethod +} + +const method = + (level: keyof typeof Severity): LogMethod => + (message, attributes) => { + // Logging must never throw into the host app. + try { + emitLog({ + severityNumber: Severity[level], + severityText: level, + body: String(message), + attributes, + }) + } catch {} + } + +export const logger: MapleLogger = { + debug: method("DEBUG"), + info: method("INFO"), + warn: method("WARN"), + error: method("ERROR"), +} diff --git a/packages/browser/src/logs.browser.test.ts b/packages/browser/src/logs.browser.test.ts new file mode 100644 index 0000000000..51771ec12c --- /dev/null +++ b/packages/browser/src/logs.browser.test.ts @@ -0,0 +1,137 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import { resetConsentForTests, setConsent } from "@maple/browser-session" +import { context, trace } from "@opentelemetry/api" +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const exportedSpans: ReadableSpan[] = [] +const exportedLogs: ReadableLogRecord[] = [] +const exporter = (sink: T[]) => + class { + export(items: T[], callback: (result: { code: number }) => void): void { + sink.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + return Promise.resolve() + } + } +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ OTLPTraceExporter: exporter(exportedSpans) })) +vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ OTLPLogExporter: exporter(exportedLogs) })) + +const { MapleBrowser } = await import("./index") +const { resetLogsForTests } = await import("./logs") +const { resetNavigationForTests } = await import("./navigation") +const { resetReportedErrorsForTests } = await import("./errors") + +type InitConfig = Parameters[0] +const BASE: InitConfig = { + ingestKey: "k", + serviceName: "web", + endpoint: "https://ingest.test", + replay: { enabled: false }, + tracing: { instrumentFetch: false }, +} + +let handle: ReturnType | undefined +const stop = async (): Promise => { + await handle?.shutdown() + handle = undefined +} + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ) +}) + +afterEach(async () => { + await stop() + exportedSpans.length = 0 + exportedLogs.length = 0 + vi.unstubAllGlobals() + resetConsentForTests() + resetLogsForTests() + resetNavigationForTests() + resetReportedErrorsForTests() + MapleBrowser.identify(undefined) + trace.disable() + context.disable() +}) + +describe("MapleBrowser.logger", () => { + it("exports log records with severity, attributes, session and user", async () => { + handle = MapleBrowser.init({ ...BASE, user: { id: "user_1" } }) + MapleBrowser.logger.warn("cart went stale", { "cart.items": 3 }) + await stop() + + expect(exportedLogs).toHaveLength(1) + const [log] = exportedLogs + expect(log?.body).toBe("cart went stale") + expect(log?.severityText).toBe("WARN") + expect(log?.severityNumber).toBe(13) + expect(log?.attributes["cart.items"]).toBe(3) + expect(log?.attributes["user.id"]).toBe("user_1") + expect(typeof log?.attributes["session.id"]).toBe("string") + expect(log?.resource.attributes["service.name"]).toBe("web") + }) + + it("links a record to the span active when it was logged", async () => { + handle = MapleBrowser.init(BASE) + const tracer = trace.getTracer("test") + const spanContext = tracer.startActiveSpan("work", (span) => { + MapleBrowser.logger.info("inside") + span.end() + return span.spanContext() + }) + await stop() + + expect(exportedLogs[0]?.spanContext?.traceId).toBe(spanContext.traceId) + expect(exportedLogs[0]?.spanContext?.spanId).toBe(spanContext.spanId) + }) + + it("keeps records logged before init and exports them once it runs", async () => { + MapleBrowser.logger.info("early") + handle = MapleBrowser.init(BASE) + await stop() + expect(exportedLogs.map((log) => log.body)).toEqual(["early"]) + }) + + it("drops records while consent is withheld", async () => { + handle = MapleBrowser.init({ ...BASE, privacy: { requireConsent: true } }) + MapleBrowser.logger.info("before consent") + setConsent(true) + MapleBrowser.logger.info("after consent") + await stop() + expect(exportedLogs.map((log) => log.body)).toEqual(["after consent"]) + }) +}) + +describe("tracing.sampleRate", () => { + it("drops an unsampled session's spans but still exports its errors", async () => { + handle = MapleBrowser.init({ ...BASE, tracing: { instrumentFetch: false, sampleRate: 0 } }) + MapleBrowser.startNavigation("/a") + const error = new Error("boom") + await MapleBrowser.traced("loader", async () => {}).catch(() => {}) + MapleBrowser.endNavigation("/a") + MapleBrowser.captureException(error) + await stop() + + expect(exportedSpans.map((span) => span.name)).toEqual(["exception"]) + expect(exportedSpans[0]?.parentSpanContext).toBeUndefined() + }) + + it("exports everything at the default rate, with no sampling weight", async () => { + handle = MapleBrowser.init(BASE) + MapleBrowser.startNavigation("/a") + MapleBrowser.endNavigation("/a") + await stop() + expect(exportedSpans.map((span) => span.name)).toEqual(["pageload /a"]) + expect(exportedSpans[0]?.spanContext().traceState).toBeUndefined() + }) +}) diff --git a/packages/browser/src/logs.ts b/packages/browser/src/logs.ts new file mode 100644 index 0000000000..4f4877d85e --- /dev/null +++ b/packages/browser/src/logs.ts @@ -0,0 +1,84 @@ +// The eager half of the logs pipeline. Records queue here, stamped and linked +// 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" + +export type LogAttributeValue = string | number | boolean + +/** 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 { + readonly timestamp: number + readonly attributes: Readonly> +} + +type LogSink = (record: QueuedLogRecord) => void + +/** Held until the deferred chunk lands; beyond this, a page logging in a loop drops the oldest. */ +const MAX_QUEUED = 200 + +let queue: QueuedLogRecord[] = [] +let sink: LogSink | undefined +let getUserId: () => string | undefined = () => undefined + +export function emitLog(record: MapleLogRecord): void { + if (!hasConsent()) return + const sessionId = readSessionSink()?.sessionId + const userId = getUserId() + const attributes = { + ...record.attributes, + ...(sessionId !== undefined ? { "session.id": sessionId } : undefined), + ...(userId !== undefined ? { "user.id": userId } : undefined), + } + const queued: QueuedLogRecord = { + ...record, + attributes, + timestamp: record.timestamp ?? Date.now(), + spanContext: record.spanContext ?? trace.getSpanContext(context.active()), + } + if (sink) { + sink(queued) + return + } + queue.push(queued) + if (queue.length > MAX_QUEUED) queue.shift() +} + +/** Called by `init()`: where `user.id` comes from. */ +export function setLogIdentity(read: () => string | undefined): void { + getUserId = read +} + +/** Called by the deferred chunk: drains the queue into `next`, then sends straight to it. */ +export function attachLogSink(next: LogSink): void { + sink = next + const pending = queue + queue = [] + for (const record of pending) next(record) +} + +export function detachLogSink(): void { + sink = undefined + getUserId = () => undefined +} + +/** Test seam. */ +export function resetLogsForTests(): void { + queue = [] + detachLogSink() +} diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index c0db3a8688..3776d40e6a 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -44,6 +44,7 @@ const CONFIG = { captureUserEmail: true, respectDoNotTrack: false, propagateTraceHeaderCorsUrls: [], + tracingSampleRate: 1, sanitizeUrl: undefined, } diff --git a/packages/browser/src/sampling.test.ts b/packages/browser/src/sampling.test.ts new file mode 100644 index 0000000000..710fe37e3e --- /dev/null +++ b/packages/browser/src/sampling.test.ts @@ -0,0 +1,82 @@ +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, rejectionThreshold, SessionSampler, sessionRoll } from "./sampling" + +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("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) + const ids = Array.from({ length: 40 }, (_, i) => `session-${i}`) + const sampled = ids.filter((id) => sessionRoll(id) < 0.5) + expect(sampled.length).toBeGreaterThan(0) + expect(sampled.length).toBeLessThan(ids.length) + for (const id of ids) { + publishSessionSink(id) + const decisions = new Set([0, 1, 2].map(() => sampler.shouldSample(ROOT_CONTEXT).decision)) + expect(decisions.size).toBe(1) + const result = sampler.shouldSample(ROOT_CONTEXT) + if (sampled.includes(id)) { + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) + expect(result.traceState?.get("ot")).toBe("th:8") + } else { + expect(result.decision).toBe(SamplingDecision.NOT_RECORD) + } + } + }) + + it("follows the parent's decision, including a server-rendered one", () => { + const sampler = new SessionSampler(0) + expect(sampler.shouldSample(parent(TraceFlags.SAMPLED)).decision).toBe( + SamplingDecision.RECORD_AND_SAMPLED, + ) + expect(new SessionSampler(1).shouldSample(parent(TraceFlags.NONE)).decision).toBe( + SamplingDecision.NOT_RECORD, + ) + }) + + it("adds no th at rate 1, since every span is already weight 1", () => { + publishSessionSink("any") + const result = new SessionSampler(1).shouldSample(ROOT_CONTEXT) + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) + expect(result.traceState).toBeUndefined() + }) + + it("always samples a kept context, detaching it from an unsampled parent", () => { + const sampler = new SessionSampler(0) + expect(sampler.shouldSample(keepContext(ROOT_CONTEXT)).decision).toBe( + SamplingDecision.RECORD_AND_SAMPLED, + ) + const kept = keepContext(parent(TraceFlags.NONE)) + expect(trace.getSpanContext(kept)).toBeUndefined() + expect(sampler.shouldSample(kept).decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) + expect(trace.getSpanContext(keepContext(parent(TraceFlags.SAMPLED)))?.traceId).toBe(TRACE_ID) + }) +}) diff --git a/packages/browser/src/sampling.ts b/packages/browser/src/sampling.ts new file mode 100644 index 0000000000..01bcc6608c --- /dev/null +++ b/packages/browser/src/sampling.ts @@ -0,0 +1,76 @@ +// 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. +import { readSessionSink } from "@maple/browser-session" +import { + type Context, + createContextKey, + createTraceState, + isSpanContextValid, + trace, + TraceFlags, +} from "@opentelemetry/api" +import { type Sampler, SamplingDecision, type SamplingResult } from "@opentelemetry/sdk-trace-base" + +const KEEP = createContextKey("maple.sampling.keep") + +/** + * A context whose next span is always sampled, for errors. An unsampled parent + * is dropped first, so the error exports as a root rather than an orphan. + */ +export function keepContext(ctx: Context): Context { + const parent = trace.getSpanContext(ctx) + const base = parent && (parent.traceFlags & TraceFlags.SAMPLED) === 0 ? trace.deleteSpan(ctx) : ctx + 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 +} + +/** + * 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 { + const threshold = Math.round((1 - probability) * 2 ** 56) + return threshold.toString(16).padStart(14, "0").replace(/0+$/, "") || "0" +} + +export class SessionSampler implements Sampler { + private readonly threshold: string + + constructor(private readonly rate: number) { + this.threshold = rejectionThreshold(rate) + } + + shouldSample(ctx: Context): SamplingResult { + if (ctx.getValue(KEEP) === true) return { decision: SamplingDecision.RECORD_AND_SAMPLED } + const parent = trace.getSpanContext(ctx) + if (parent && isSpanContextValid(parent)) { + return { + decision: + (parent.traceFlags & TraceFlags.SAMPLED) === 0 + ? SamplingDecision.NOT_RECORD + : SamplingDecision.RECORD_AND_SAMPLED, + } + } + if (this.rate >= 1) return { decision: SamplingDecision.RECORD_AND_SAMPLED } + const sessionId = readSessionSink()?.sessionId + const roll = sessionId ? sessionRoll(sessionId) : Math.random() + if (roll >= this.rate) return { decision: SamplingDecision.NOT_RECORD } + return { + decision: SamplingDecision.RECORD_AND_SAMPLED, + traceState: createTraceState().set("ot", `th:${this.threshold}`), + } + } + + toString(): string { + return `MapleSessionSampler{${this.rate}}` + } +} diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index f7bab253e4..57e89278a0 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -53,6 +53,7 @@ const CONFIG = { captureUserEmail: true, respectDoNotTrack: false, propagateTraceHeaderCorsUrls: [], + tracingSampleRate: 1, sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index 5304bd0cc6..b20777cb68 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -24,6 +24,7 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { WebTracerProvider } from "@opentelemetry/sdk-trace-web" import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" import type { ResolvedConfig } from "./config" +import { SessionSampler } from "./sampling" import { SDK_NAME, SDK_VERSION } from "./version" /** Span attributes that carry a page or request URL. */ @@ -120,21 +121,8 @@ export function liveMapleTracer(name: string, version: string): Tracer | undefin return mapleProvider?.getTracer(name, version) } -/** - * Set up browser OTel tracing exporting to Maple's ingest. When - * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and - * their trace ids feed the session. Disable it when an external tracer (e.g. - * the Effect client SDK) already instruments requests — that tracer feeds the - * session via the published sink instead, and this avoids redundant duplicate - * network spans. Returns a shutdown function. - * - * `session.id` is deliberately **not** a resource attribute: the resource is - * fixed for the provider's lifetime, but sessions rotate under it (idle - * rotation, consent revoke→re-grant), so a resource-level id would attribute - * every post-rotation span to the ended session. `TraceIdCollector` stamps the - * live id per span instead. - */ -export function setupTracing(config: ResolvedConfig): () => Promise { +/** Resource attributes shared by every signal this SDK exports. */ +export function resourceAttributes(config: ResolvedConfig): Record { const attributes: Record = { [ATTR_SERVICE_NAME]: config.serviceName, "maple.sdk.type": "browser", @@ -156,7 +144,24 @@ export function setupTracing(config: ResolvedConfig): () => Promise { attributes["deployment.environment"] = config.environment attributes["deployment.environment.name"] = config.environment } + return attributes +} +/** + * Set up browser OTel tracing exporting to Maple's ingest. When + * `tracingInstrumentFetch` is true, fetch() calls are auto-instrumented and + * their trace ids feed the session. Disable it when an external tracer (e.g. + * the Effect client SDK) already instruments requests — that tracer feeds the + * session via the published sink instead, and this avoids redundant duplicate + * network spans. Returns a shutdown function. + * + * `session.id` is deliberately **not** a resource attribute: the resource is + * fixed for the provider's lifetime, but sessions rotate under it (idle + * rotation, consent revoke→re-grant), so a resource-level id would attribute + * every post-rotation span to the ended session. `TraceIdCollector` stamps the + * live id per span instead. + */ +export function setupTracing(config: ResolvedConfig): () => Promise { const exporter = new ConsentSpanExporter( new OTLPTraceExporter({ url: `${config.endpoint}/v1/traces`, @@ -167,7 +172,8 @@ export function setupTracing(config: ResolvedConfig): () => Promise { ) const provider = new WebTracerProvider({ - resource: resourceFromAttributes(attributes), + resource: resourceFromAttributes(resourceAttributes(config)), + sampler: new SessionSampler(config.tracingSampleRate), // The id only — the rest of the identity (email, group) belongs on the // session row, not stamped onto every span on the hot path. spanProcessors: [ From 43a9cc9ef8dc8090bd23ee7424c123d0037746cf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:11:05 +0200 Subject: [PATCH 2/2] fix(browser): keep sampled decisions consistent downstream, never lose a traced failure - Sampled roots now carry ot=rv next to ot=th, derived from the same session roll, and the decision is rv >= th. A downstream consistent-probability sampler reaches the same answer instead of re-deciding from the trace id. - A failure inside traced() in an unsampled session was recorded on a span that is never exported. It is now reported as its own kept exception span, so "error spans are always exported" holds for traced() too; the shared dedupe still makes a later captureException a no-op. --- packages/browser/src/logs.browser.test.ts | 15 +++++++++++++ packages/browser/src/navigation.ts | 8 +++++-- packages/browser/src/sampling.test.ts | 16 ++++++++++++-- packages/browser/src/sampling.ts | 26 ++++++++++++++++++----- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/logs.browser.test.ts b/packages/browser/src/logs.browser.test.ts index 51771ec12c..0245c1ba97 100644 --- a/packages/browser/src/logs.browser.test.ts +++ b/packages/browser/src/logs.browser.test.ts @@ -126,6 +126,21 @@ describe("tracing.sampleRate", () => { expect(exportedSpans[0]?.parentSpanContext).toBeUndefined() }) + it("still exports a failure inside an unsampled traced() call, as its own error span", async () => { + handle = MapleBrowser.init({ ...BASE, tracing: { instrumentFetch: false, sampleRate: 0 } }) + const error = new Error("loader failed") + await expect( + MapleBrowser.traced("loader /a", async () => { + throw error + }), + ).rejects.toBe(error) + // Rethrown to the app, then reported again by a boundary: still one error. + MapleBrowser.captureException(error) + await stop() + expect(exportedSpans.map((span) => span.name)).toEqual(["loader /a"]) + expect(exportedSpans[0]?.events.some((event) => event.name === "exception")).toBe(true) + }) + it("exports everything at the default rate, with no sampling weight", async () => { handle = MapleBrowser.init(BASE) MapleBrowser.startNavigation("/a") diff --git a/packages/browser/src/navigation.ts b/packages/browser/src/navigation.ts index 9d3c63dacd..947ab5e9fd 100644 --- a/packages/browser/src/navigation.ts +++ b/packages/browser/src/navigation.ts @@ -19,7 +19,7 @@ import { type SpanContext, trace, } from "@opentelemetry/api" -import { recordFailure } from "./errors" +import { captureException, recordFailure } from "./errors" import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -100,7 +100,11 @@ export async function traced(name: string, fn: () => Promise, options: Tra try { return await fn() } catch (error) { - if (isFailure(options, error)) recordFailure(span, error) + if (isFailure(options, error)) { + // An unsampled span drops what it records: report the failure on its own, which is always kept. + if (span.isRecording()) recordFailure(span, error) + else captureException(error, { name }) + } throw error } finally { span.end() diff --git a/packages/browser/src/sampling.test.ts b/packages/browser/src/sampling.test.ts index 710fe37e3e..e6257ecd31 100644 --- a/packages/browser/src/sampling.test.ts +++ b/packages/browser/src/sampling.test.ts @@ -2,7 +2,7 @@ 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, rejectionThreshold, SessionSampler, sessionRoll } from "./sampling" +import { keepContext, randomnessValue, rejectionThreshold, SessionSampler, sessionRoll } from "./sampling" const TRACE_ID = "0af7651916cd43dd8448eb211c80319c" const parent = (flags: number) => @@ -22,6 +22,15 @@ describe("rejectionThreshold", () => { }) }) +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") @@ -45,7 +54,10 @@ describe("SessionSampler", () => { const result = sampler.shouldSample(ROOT_CONTEXT) if (sampled.includes(id)) { expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED) - expect(result.traceState?.get("ot")).toBe("th:8") + const ot = result.traceState?.get("ot") ?? "" + expect(ot).toMatch(/^th:8;rv:[0-9a-f]{14}$/) + // A consistent-probability sampler downstream keeps it too: rv >= th. + expect(Number.parseInt(ot.split("rv:")[1] ?? "", 16)).toBeGreaterThanOrEqual(2 ** 55) } else { expect(result.decision).toBe(SamplingDecision.NOT_RECORD) } diff --git a/packages/browser/src/sampling.ts b/packages/browser/src/sampling.ts index 01bcc6608c..a2e6847d90 100644 --- a/packages/browser/src/sampling.ts +++ b/packages/browser/src/sampling.ts @@ -33,20 +33,35 @@ export function sessionRoll(sessionId: string): number { 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 { - const threshold = Math.round((1 - probability) * 2 ** 56) - return threshold.toString(16).padStart(14, "0").replace(/0+$/, "") || "0" + 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) } shouldSample(ctx: Context): SamplingResult { @@ -61,12 +76,13 @@ export class SessionSampler implements Sampler { } } if (this.rate >= 1) return { decision: SamplingDecision.RECORD_AND_SAMPLED } + if (this.rate <= 0) return { decision: SamplingDecision.NOT_RECORD } const sessionId = readSessionSink()?.sessionId - const roll = sessionId ? sessionRoll(sessionId) : Math.random() - if (roll >= this.rate) return { decision: SamplingDecision.NOT_RECORD } + const rv = randomnessValue(sessionId ? sessionRoll(sessionId) : Math.random()) + if (rv < this.thresholdValue) return { decision: SamplingDecision.NOT_RECORD } return { decision: SamplingDecision.RECORD_AND_SAMPLED, - traceState: createTraceState().set("ot", `th:${this.threshold}`), + traceState: createTraceState().set("ot", `th:${this.threshold};rv:${hex56(rv)}`), } }