Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string \| RegExp>` | `[]` | 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). |
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
7 changes: 5 additions & 2 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -148,6 +153,7 @@ export interface ResolvedConfig {
readonly propagateTraceHeaderCorsUrls: ReadonlyArray<string | RegExp>
readonly tracingSampleRate: number
readonly errorFilters: ErrorFilterOptions
readonly webVitals: boolean
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly maskAllInputs: boolean
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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()))
}
Expand Down
46 changes: 46 additions & 0 deletions packages/browser/src/deferred/web-vitals.ts
Original file line number Diff line number Diff line change
@@ -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?.(),
})
}
Comment thread
Makisuo marked this conversation as resolved.

/** 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
}
}
2 changes: 2 additions & 0 deletions packages/browser/src/logs.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const CONFIG = {
tracingSampleRate: 1,
tracingInstrumentXhr: false,
errorFilters: {},
webVitals: false,
sanitizeUrl: undefined,
}

Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/tracing.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const CONFIG = {
tracingSampleRate: 1,
tracingInstrumentXhr: false,
errorFilters: {},
webVitals: false,
sanitizeUrl: undefined,
}

Expand Down
49 changes: 49 additions & 0 deletions packages/browser/src/web-vitals-untraced.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return Promise.resolve()
}
shutdown(): Promise<void> {
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()
})
})
74 changes: 74 additions & 0 deletions packages/browser/src/web-vitals.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return Promise.resolve()
}
shutdown(): Promise<void> {
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<void> {
return Promise.resolve()
}
shutdown(): Promise<void> {
return Promise.resolve()
}
},
}))

const { MapleBrowser } = await import("./index")

let handle: ReturnType<typeof MapleBrowser.init> | 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()
})
})
Loading