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..96a268a17 --- /dev/null +++ b/packages/browser/src/deferred/web-vitals.ts @@ -0,0 +1,46 @@ +// 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 reporting = false +let pageload: (() => SpanContext | undefined) | undefined + +function report(metric: Metric): void { + if (!reporting) 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), + }, + // 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/logs.browser.test.ts b/packages/browser/src/logs.browser.test.ts index e8f926a20..2936d1dc1 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-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() + }) +}) 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() + }) +})