From 59c5f2c9174718c577f40455edced4f8c36253d9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:15:40 +0200 Subject: [PATCH 1/2] feat(browser): Core Web Vitals as browser.web_vital log events LCP, CLS, INP, FCP and TTFB, reported with the web-vitals library, each as an OTel log-based event named browser.web_vital with the browser.web_vital.{name,value,delta,id,rating,navigation_type} attributes from the browser semantic conventions, plus url.path. Events, not OTel metrics: a MeterProvider and exporter would add another SDK to the page and lose the per-page, per-trace detail. Aggregates come from the warehouse. Each event carries session.id and links to the document's pageload span, which the deferred chunk now remembers. The reporter registers before the logs pipeline so vitals reported on page hide are emitted before its flush listener runs. web-vitals has no unsubscribe, so it registers once per page and shutdown only gates it. On by default; webVitals: false turns it off. Lives in the deferred chunk (deferred budget 8 -> 12 kB); eager is unchanged. --- bun.lock | 1 + docs/browser-sdk.md | 20 +++++ packages/browser/README.md | 8 +- packages/browser/package.json | 3 +- packages/browser/scripts/size.ts | 7 +- packages/browser/src/config.ts | 7 ++ packages/browser/src/deferred/index.ts | 18 ++++- packages/browser/src/deferred/web-vitals.ts | 42 +++++++++++ packages/browser/src/logs.browser.test.ts | 2 + packages/browser/src/navigation.test.ts | 1 + packages/browser/src/tracing.browser.test.ts | 1 + .../browser/src/web-vitals.browser.test.ts | 74 +++++++++++++++++++ 12 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 packages/browser/src/deferred/web-vitals.ts create mode 100644 packages/browser/src/web-vitals.browser.test.ts diff --git a/bun.lock b/bun.lock index 938b418d4..9b1e1a67b 100644 --- a/bun.lock +++ b/bun.lock @@ -653,6 +653,7 @@ "@opentelemetry/sdk-trace-web": "^2.10.0", "@opentelemetry/semantic-conventions": "^1.43.0", "rrweb": "^2.0.0-alpha.18", + "web-vitals": "^6.2.2", }, "devDependencies": { "@maple/browser-session": "workspace:*", diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 0f9210b37..c8bdb809a 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -55,6 +55,7 @@ Every field accepted by `MapleBrowser.init`: | `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). | +| `webVitals` | `boolean` | `true` | Report Core Web Vitals as `browser.web_vital` log events. See [Web Vitals](#web-vitals). | | `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. | | `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). | @@ -301,6 +302,25 @@ run. Once the page has loaded, the SDK adds child spans from the Navigation Timi Phases that didn't happen (a reused connection has no `dns` or `connect`) are skipped. +## Web Vitals + +LCP, CLS, INP, FCP and TTFB are reported with the `web-vitals` library, each as an OpenTelemetry +log-based event named `browser.web_vital`, following the browser semantic conventions: + +| Attribute | Example | +| ----------------------------------- | -------------------------------- | +| `browser.web_vital.name` | `lcp` | +| `browser.web_vital.value` | `1830.4` (ms; CLS is unitless) | +| `browser.web_vital.delta` | `1830.4` | +| `browser.web_vital.id` | `v5-1727600000000-1234567890123` | +| `browser.web_vital.rating` | `good` | +| `browser.web_vital.navigation_type` | `navigate` | +| `url.path` | `/projects/42` | + +Each event carries `session.id` and is linked to the page's `pageload` span when your router calls +`startNavigation`. CLS, INP and LCP settle when the page is hidden, so they arrive then. Turn them off +with `webVitals: false`. + ## Tracing across origins `fetch` and `XMLHttpRequest` spans send the W3C `traceparent` header to same-origin requests only. When your API lives diff --git a/packages/browser/README.md b/packages/browser/README.md index e88e62f35..37601d0c5 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -74,7 +74,7 @@ Bundled, minified and gzipped, as your bundler would ship it: | ---------------- | ------- | --------------------------------------------------------- | | **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()` | +| **deferred** | ~9 kB | logs SDK, Web Vitals, document timing, 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 @@ -128,6 +128,12 @@ Calls before `init()` are queued. MapleBrowser.logger.info("checkout started", { "cart.items": 3 }) ``` +## Web Vitals + +LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry log events +(browser semantic conventions), linked to the `pageload` span and the session. Opt out with +`webVitals: false`. + ## Trace sampling `tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per diff --git a/packages/browser/package.json b/packages/browser/package.json index d4a1ee152..9c2b600b0 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -53,7 +53,8 @@ "@opentelemetry/sdk-trace-base": "^2.10.0", "@opentelemetry/sdk-trace-web": "^2.10.0", "@opentelemetry/semantic-conventions": "^1.43.0", - "rrweb": "^2.0.0-alpha.18" + "rrweb": "^2.0.0-alpha.18", + "web-vitals": "^6.2.2" }, "devDependencies": { "@maple/browser-session": "workspace:*", diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index c3a21a4c1..8eda0f54f 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -32,8 +32,11 @@ const BUDGET = { * core). Was 38 for navigation spans. */ eager: 43, - /** Every page load, after `init()`: the OTel logs SDK and exporter, document timing. */ - deferred: 8, + /** + * Every page load, after `init()`: the OTel logs SDK and exporter, document + * timing, and `web-vitals` (~3.3 kB, 8 -> 12). + */ + deferred: 12, lazy: 68, /** * Our own eager code, with OpenTelemetry and rrweb left external. diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 3720af844..109c9d396 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -80,6 +80,11 @@ export interface MapleBrowserConfig { */ readonly sampleRate?: number } + /** + * Report Core Web Vitals (LCP, CLS, INP, FCP, TTFB) as `browser.web_vital` + * log events. Default true. + */ + readonly webVitals?: boolean /** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */ readonly errors?: ErrorFilterOptions readonly replay?: { @@ -148,6 +153,7 @@ export interface ResolvedConfig { readonly propagateTraceHeaderCorsUrls: ReadonlyArray readonly tracingSampleRate: number readonly errorFilters: ErrorFilterOptions + readonly webVitals: boolean readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -213,6 +219,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [], tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate), errorFilters: config.errors ?? {}, + webVitals: config.webVitals ?? true, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), maskAllInputs: config.privacy?.maskAllInputs ?? true, diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts index 0c1f6ccd8..98646e326 100644 --- a/packages/browser/src/deferred/index.ts +++ b/packages/browser/src/deferred/index.ts @@ -1,13 +1,27 @@ // 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" import type { ResolvedConfig } from "../config" import { onDocumentPageload } from "../navigation" import { recordDocumentTiming } from "./document-timing" import { startLogs } from "./logs" +import { startWebVitals } from "./web-vitals" export function startDeferred(config: ResolvedConfig): () => Promise { - onDocumentPageload(recordDocumentTiming) - const stops = [startLogs(config), async () => onDocumentPageload(undefined)] + let pageload: SpanContext | undefined + onDocumentPageload((tracer, span) => { + pageload = span.spanContext() + recordDocumentTiming(tracer, 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 stops = [ + startLogs(config), + async () => { + stopVitals() + onDocumentPageload(undefined) + }, + ] return async () => { await Promise.all(stops.map((stop) => stop())) } diff --git a/packages/browser/src/deferred/web-vitals.ts b/packages/browser/src/deferred/web-vitals.ts new file mode 100644 index 000000000..9ea4dc8a8 --- /dev/null +++ b/packages/browser/src/deferred/web-vitals.ts @@ -0,0 +1,42 @@ +// 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. +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" + +// web-vitals has no unsubscribe: register once per page, and gate reporting instead. +let registered = false +let pageload: (() => SpanContext | undefined) | undefined + +function report(metric: Metric): void { + if (!pageload) return + emitLog({ + eventName: "browser.web_vital", + severityNumber: Severity.INFO, + severityText: "INFO", + attributes: { + "browser.web_vital.name": metric.name.toLowerCase(), + "browser.web_vital.value": metric.value, + "browser.web_vital.delta": metric.delta, + "browser.web_vital.id": metric.id, + "browser.web_vital.rating": metric.rating, + "browser.web_vital.navigation_type": metric.navigationType, + "url.path": scrubUrl(location.pathname), + }, + spanContext: pageload(), + }) +} + +/** Report vitals, linked to the document's pageload span when there is one. Returns a stop. */ +export function startWebVitals(getPageload: () => SpanContext | undefined): () => void { + pageload = getPageload + if (!registered) { + registered = true + for (const on of [onCLS, onFCP, onINP, onLCP, onTTFB]) on(report) + } + return () => { + pageload = undefined + } +} diff --git a/packages/browser/src/logs.browser.test.ts b/packages/browser/src/logs.browser.test.ts index 47b833949..55bd6e3a8 100644 --- a/packages/browser/src/logs.browser.test.ts +++ b/packages/browser/src/logs.browser.test.ts @@ -35,6 +35,8 @@ const BASE: InitConfig = { endpoint: "https://ingest.test", replay: { enabled: false }, tracing: { instrumentFetch: false }, + // Covered in web-vitals.browser.test.ts; vitals report once per page and would leak across tests. + webVitals: false, } /** Document timing spans (`documentFetch`, `dns`, ...) are covered by their own tests. */ diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index 1c3b68416..929f94766 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -47,6 +47,7 @@ const CONFIG = { tracingSampleRate: 1, tracingInstrumentXhr: false, errorFilters: {}, + webVitals: false, sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 9532a34e9..6ff37bef5 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -56,6 +56,7 @@ const CONFIG = { tracingSampleRate: 1, tracingInstrumentXhr: false, errorFilters: {}, + webVitals: false, sanitizeUrl: undefined, } diff --git a/packages/browser/src/web-vitals.browser.test.ts b/packages/browser/src/web-vitals.browser.test.ts new file mode 100644 index 000000000..ba95bf02a --- /dev/null +++ b/packages/browser/src/web-vitals.browser.test.ts @@ -0,0 +1,74 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" +import { afterEach, describe, expect, it, vi } from "vitest" + +const exportedLogs: ReadableLogRecord[] = [] +vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ + OTLPLogExporter: class { + export(items: ReadableLogRecord[], callback: (result: { code: number }) => void): void { + exportedLogs.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + return Promise.resolve() + } + }, +})) +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ + OTLPTraceExporter: class { + export(_spans: unknown[], callback: (result: { code: number }) => void): void { + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + return Promise.resolve() + } + }, +})) + +const { MapleBrowser } = await import("./index") + +let handle: ReturnType | undefined +afterEach(async () => { + await handle?.shutdown() + handle = undefined +}) + +describe("web vitals", () => { + it("reports browser.web_vital events linked to the pageload span", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ) + handle = MapleBrowser.init({ + ingestKey: "k", + serviceName: "web", + endpoint: "https://ingest.test", + replay: { enabled: false }, + tracing: { instrumentFetch: false }, + }) + MapleBrowser.startNavigation("/a") + MapleBrowser.endNavigation("/a") + await import("./deferred") + // TTFB reports once the page has loaded, which the test page long has. + await new Promise((resolve) => setTimeout(resolve, 50)) + await handle.shutdown() + handle = undefined + + const vitals = exportedLogs.filter((log) => log.eventName === "browser.web_vital") + const ttfb = vitals.find((log) => log.attributes["browser.web_vital.name"] === "ttfb") + expect(ttfb).toBeDefined() + expect(typeof ttfb?.attributes["browser.web_vital.value"]).toBe("number") + expect(["good", "needs-improvement", "poor"]).toContain(ttfb?.attributes["browser.web_vital.rating"]) + expect(ttfb?.attributes["browser.web_vital.id"]).toMatch(/^v\d+-/) + expect(ttfb?.attributes["url.path"]).toBe(location.pathname) + expect(typeof ttfb?.attributes["session.id"]).toBe("string") + expect(ttfb?.spanContext?.traceId).toMatch(/^[0-9a-f]{32}$/) + vi.unstubAllGlobals() + }) +}) From e47e51f3065c4ccd5a9567ec64053d051c734f46 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:13:34 +0200 Subject: [PATCH 2/2] fix(browser): report web vitals whether or not a pageload span exists report() returned early without a pageload span, so vitals were dropped with tracing disabled and before the app's first startNavigation (TTFB and FCP usually settle then). Only the trace link is conditional now. --- packages/browser/src/deferred/web-vitals.ts | 8 ++- .../src/web-vitals-untraced.browser.test.ts | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/browser/src/web-vitals-untraced.browser.test.ts diff --git a/packages/browser/src/deferred/web-vitals.ts b/packages/browser/src/deferred/web-vitals.ts index 9ea4dc8a8..96a268a17 100644 --- a/packages/browser/src/deferred/web-vitals.ts +++ b/packages/browser/src/deferred/web-vitals.ts @@ -8,10 +8,11 @@ import { emitLog, Severity } from "../logs" // web-vitals has no unsubscribe: register once per page, and gate reporting instead. let registered = false +let reporting = false let pageload: (() => SpanContext | undefined) | undefined function report(metric: Metric): void { - if (!pageload) return + if (!reporting) return emitLog({ eventName: "browser.web_vital", severityNumber: Severity.INFO, @@ -25,18 +26,21 @@ function report(metric: Metric): void { "browser.web_vital.navigation_type": metric.navigationType, "url.path": scrubUrl(location.pathname), }, - spanContext: pageload(), + // Only the trace link needs a pageload span; tracing may be off, or the app not navigating yet. + spanContext: 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 pageload = getPageload if (!registered) { registered = true for (const on of [onCLS, onFCP, onINP, onLCP, onTTFB]) on(report) } return () => { + reporting = false pageload = undefined } } diff --git a/packages/browser/src/web-vitals-untraced.browser.test.ts b/packages/browser/src/web-vitals-untraced.browser.test.ts new file mode 100644 index 000000000..c494720f2 --- /dev/null +++ b/packages/browser/src/web-vitals-untraced.browser.test.ts @@ -0,0 +1,49 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" +import { describe, expect, it, vi } from "vitest" + +const exportedLogs: ReadableLogRecord[] = [] +vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ + OTLPLogExporter: class { + export(items: ReadableLogRecord[], callback: (result: { code: number }) => void): void { + exportedLogs.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + return Promise.resolve() + } + }, +})) + +const { MapleBrowser } = await import("./index") + +// Its own file: web-vitals reports each metric once per page. +describe("web vitals without tracing", () => { + it("still reports vitals, just without a trace link", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ) + const handle = MapleBrowser.init({ + ingestKey: "k", + serviceName: "web", + endpoint: "https://ingest.test", + replay: { enabled: false }, + tracing: { enabled: false }, + }) + await import("./deferred") + await new Promise((resolve) => setTimeout(resolve, 50)) + await handle.shutdown() + vi.unstubAllGlobals() + + const ttfb = exportedLogs.find( + (log) => + log.eventName === "browser.web_vital" && log.attributes["browser.web_vital.name"] === "ttfb", + ) + expect(ttfb).toBeDefined() + expect(ttfb?.spanContext).toBeUndefined() + }) +})