@lesto/observability 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rum.ts ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Browser RUM — the spans the BROWSER emits, stitched to the SERVER trace.
3
+ *
4
+ * ARCHITECTURE.md §7 promises "browser spans stitch to the server trace — the
5
+ * thing no JS meta-framework ships." This module is the browser half of that
6
+ * promise: a `PerformanceObserver`-driven client that turns the browser's own
7
+ * timing records (navigation phases, resource fetches, Core Web Vitals) into
8
+ * Lesto spans, and — crucially — ADOPTS the server request's `traceId` so a page
9
+ * load lands UNDER the same `http.request` span the server already emitted. One
10
+ * trace, UI → API → DB.
11
+ *
12
+ * The join is a `<meta name="lesto-traceparent" content="00-<traceId>-<spanId>-01">`
13
+ * the SSR layer injects: the server stamps the request span's ids into the
14
+ * document, the browser reads them ({@link readTraceparentMeta}), and every span
15
+ * minted here uses that `traceId` and parents on that `spanId`. Absent the meta
16
+ * (a static page, a non-traced app), the browser roots its OWN trace instead —
17
+ * never crashing, just unparented.
18
+ *
19
+ * The same two rules the client-error beacon lives by hold here:
20
+ *
21
+ * - **Bounded sampling.** A page must not turn every load into a span POST, so
22
+ * the session is gated by a configurable rate (default the same conservative
23
+ * {@link DEFAULT_RUM_SAMPLE_RATE} the beacon uses). The gate is `random() <
24
+ * rate`, with `random` injected so the rate is provable in a test.
25
+ * - **No PII, ever.** A span carries only same-origin resource URLs (stripped
26
+ * to path), W3C-standard timing numbers, and Web-Vital values — never a query
27
+ * string, never a cross-origin URL, never a header. An outbound `traceparent`
28
+ * is stamped on SAME-ORIGIN fetches only ({@link wrapFetch}), so the trace id
29
+ * never leaks to a third party.
30
+ *
31
+ * This file is BROWSER code: it touches `performance`, `PerformanceObserver`,
32
+ * `document`, and `fetch`, all feature-detected so it is a silent no-op where
33
+ * they are absent (SSR, an old browser, a test without jsdom). It takes no node
34
+ * dependency, so `@lesto/assets` can inline it into the synthesized client entry.
35
+ */
36
+
37
+ import { formatTraceparent, parseTraceparent } from "./traceparent";
38
+ import type { Traceparent } from "./traceparent";
39
+
40
+ /** Where finished browser spans POST — the receiver lives in `@lesto/web`. */
41
+ export const BROWSER_SPANS_PATH = "/__lesto/browser-spans";
42
+
43
+ /** The SSR-injected meta tag the browser reads its inbound traceparent from. */
44
+ export const TRACEPARENT_META_NAME = "lesto-traceparent";
45
+
46
+ /**
47
+ * The default sampling rate: 10% of sessions emit browser spans.
48
+ *
49
+ * The same conservative default the client-error beacon takes, and for the same
50
+ * reason — RUM exists to SAMPLE the field, not to mirror every navigation into a
51
+ * span POST. An operator who wants denser data raises it; one watching a flood
52
+ * lowers it.
53
+ */
54
+ export const DEFAULT_RUM_SAMPLE_RATE = 0.1;
55
+
56
+ /**
57
+ * One browser span, in the flat shape the receiver normalizes and the exporter
58
+ * ships. A structural subset of `@lesto/observability`'s `SpanData` — the fields
59
+ * a browser can author honestly: ids, name, the two epoch-ms timestamps, a small
60
+ * PII-free attribute bag, and an OTLP status code (0 unset / 1 ok / 2 error).
61
+ *
62
+ * The browser does NOT speak the OTLP envelope; it POSTs these flat records and
63
+ * the server-side receiver maps them onto the existing exporter, so they share
64
+ * one collector and one trace id with the server spans.
65
+ */
66
+ export interface BrowserSpan {
67
+ /** The trace this span belongs to — the SERVER trace's id when the meta is present. */
68
+ readonly traceId: string;
69
+
70
+ /** This span's own id (16 hex — the width the OTLP wire and traceparent both use). */
71
+ readonly spanId: string;
72
+
73
+ /** The span this one points back at — the server request span, or a browser parent. */
74
+ readonly parentSpanId?: string;
75
+
76
+ /** The span name, e.g. `browser.navigation`, `browser.resource`, `browser.web_vital`. */
77
+ readonly name: string;
78
+
79
+ /** Epoch-ms start (derived from the performance entry's high-res time + the time origin). */
80
+ readonly startedAt: number;
81
+
82
+ /** Epoch-ms end. Equal to `startedAt` for a point-in-time vital with no duration. */
83
+ readonly endedAt: number;
84
+
85
+ /** A small PII-free attribute bag (timing numbers, a same-origin path, a vital value). */
86
+ readonly attributes: Record<string, number | string>;
87
+
88
+ /** OTLP status: 0 unset, 1 ok, 2 error. A browser span is `ok` (1) unless noted. */
89
+ readonly status: 0 | 1 | 2;
90
+ }
91
+
92
+ /** The exact JSON body the browser POSTs to {@link BROWSER_SPANS_PATH}. */
93
+ export interface BrowserSpansPayload {
94
+ /** Schema version, so the receiver can evolve the wire without a flag day. */
95
+ readonly v: 1;
96
+
97
+ /** The trace these spans belong to — the join key with the server `http.request` span. */
98
+ readonly traceId: string;
99
+
100
+ readonly spans: readonly BrowserSpan[];
101
+ }
102
+
103
+ /** The minimal `PerformanceObserver` surface this module drives (feature-detected). */
104
+ interface PerfObserverLike {
105
+ observe(options: { type: string; buffered?: boolean }): void;
106
+
107
+ disconnect(): void;
108
+ }
109
+
110
+ /** One performance entry, narrowed to the cross-type fields RUM reads off any entry. */
111
+ interface PerfEntryLike {
112
+ readonly entryType: string;
113
+ readonly name: string;
114
+ readonly startTime: number;
115
+ readonly duration: number;
116
+ }
117
+
118
+ /** A navigation-timing entry's phase marks (a `PerformanceNavigationTiming`, structurally). */
119
+ interface NavEntryLike extends PerfEntryLike {
120
+ readonly domainLookupStart: number;
121
+ readonly domainLookupEnd: number;
122
+ readonly connectStart: number;
123
+ readonly connectEnd: number;
124
+ readonly secureConnectionStart: number;
125
+ readonly requestStart: number;
126
+ readonly responseStart: number;
127
+ readonly responseEnd: number;
128
+ readonly domContentLoadedEventEnd: number;
129
+ readonly loadEventEnd: number;
130
+ }
131
+
132
+ /** A resource-timing entry, structurally (`initiatorType` distinguishes a script/fetch/img). */
133
+ interface ResourceEntryLike extends PerfEntryLike {
134
+ readonly initiatorType: string;
135
+ }
136
+
137
+ /** A layout-shift entry, structurally — `value` is ONE shift's score (CLS is the cumulative sum of these, which RUM does not compute here). */
138
+ interface LayoutShiftLike extends PerfEntryLike {
139
+ readonly value: number;
140
+ readonly hadRecentInput: boolean;
141
+ }
142
+
143
+ /**
144
+ * The browser globals RUM needs, injected so a test drives them without a real
145
+ * browser. Each is optional: an absent one means "this capability is missing",
146
+ * and {@link startBrowserRum} degrades to a silent no-op rather than throwing.
147
+ */
148
+ export interface RumEnvironment {
149
+ /** Reads the inbound traceparent meta — the SSR-injected `<meta name="lesto-traceparent">`. */
150
+ readonly readTraceparent: () => Traceparent | undefined;
151
+
152
+ /** The high-res clock origin (`performance.timeOrigin`) — added to entry times for epoch ms. */
153
+ readonly timeOrigin: number;
154
+
155
+ /** Constructs a `PerformanceObserver` over `callback`, or `undefined` if unsupported. */
156
+ readonly createObserver:
157
+ | ((callback: (entries: readonly PerfEntryLike[]) => void) => PerfObserverLike)
158
+ | undefined;
159
+
160
+ /** Where finished spans go — defaults to a same-origin POST; injected for tests. */
161
+ readonly send: (payload: BrowserSpansPayload) => void;
162
+
163
+ /** The sampling source — `Math.random` in the browser, a stub in tests. */
164
+ readonly random: () => number;
165
+
166
+ /** A fresh 16-hex span id. The browser draws from `crypto`; a test injects a counter. */
167
+ readonly randomSpanId: () => string;
168
+
169
+ /** A fresh 32-hex trace id, used only when no inbound traceparent roots the trace. */
170
+ readonly randomTraceId: () => string;
171
+ }
172
+
173
+ /** Knobs the synthesized client entry passes through; all have safe defaults. */
174
+ export interface RumOptions {
175
+ /** Fraction of sessions that emit spans, in `[0, 1]`. Defaults to {@link DEFAULT_RUM_SAMPLE_RATE}. */
176
+ readonly sampleRate?: number;
177
+
178
+ /** The browser environment, injected for tests. Defaults to {@link browserRumEnvironment}. */
179
+ readonly environment?: RumEnvironment;
180
+ }
181
+
182
+ /**
183
+ * The entry types RUM observes. `navigation` is the page load's phase timeline;
184
+ * `resource` is each sub-resource fetch (island chunk, data fetch, image);
185
+ * `largest-contentful-paint` (LCP) is a Core Web Vital. `layout-shift` entries are
186
+ * emitted RAW (one span per eligible shift) — RUM does not sum them into the
187
+ * cumulative CLS metric. `first-input` (INP's seed) is observed too. Each is
188
+ * `buffered: true` so an entry that fired before the observer attached is still
189
+ * delivered.
190
+ */
191
+ const OBSERVED_TYPES = [
192
+ "navigation",
193
+ "resource",
194
+ "largest-contentful-paint",
195
+ "layout-shift",
196
+ "first-input",
197
+ ] as const;
198
+
199
+ /**
200
+ * Decide whether this session emits, gated by the bounded rate.
201
+ *
202
+ * `rate <= 0` never emits; `rate >= 1` always does; in between it is a single
203
+ * `random() < rate` draw. Mirrors the beacon's `shouldSample` so the two
204
+ * browser-side gates behave identically. Exported so the gate is unit-testable.
205
+ */
206
+ export function shouldSampleRum(rate: number, random: () => number): boolean {
207
+ if (rate <= 0) return false;
208
+ if (rate >= 1) return true;
209
+
210
+ return random() < rate;
211
+ }
212
+
213
+ /**
214
+ * Read the inbound traceparent the SSR layer injected as a meta tag.
215
+ *
216
+ * Looks up `<meta name="lesto-traceparent" content="00-…">` in the live document
217
+ * and parses its content through the SAME strict `parseTraceparent` the server
218
+ * uses — a malformed or absent meta yields `undefined`, and the browser roots its
219
+ * own trace. Guarded for `document` so it is a safe no-op outside a browser.
220
+ */
221
+ export function readTraceparentMeta(): Traceparent | undefined {
222
+ if (typeof document === "undefined") return undefined;
223
+
224
+ const meta = document.querySelector(`meta[name="${TRACEPARENT_META_NAME}"]`);
225
+
226
+ // A meta with no content attribute reads as null → parseTraceparent's absent case.
227
+ const content = meta?.getAttribute("content") ?? undefined;
228
+
229
+ return parseTraceparent(content);
230
+ }
231
+
232
+ /** Strip a URL to its same-origin path, or `undefined` if it is cross-origin/unparseable. */
233
+ function sameOriginPath(url: string, origin: string): string | undefined {
234
+ let parsed: URL;
235
+
236
+ try {
237
+ parsed = new URL(url, origin);
238
+ } catch {
239
+ // A URL the parser rejects carries no path we can trust — drop it entirely.
240
+ return undefined;
241
+ }
242
+
243
+ // Cross-origin resources are never recorded: their path could carry a third
244
+ // party's identifiers, and we never want RUM to widen the PII surface.
245
+ if (parsed.origin !== origin) return undefined;
246
+
247
+ // Path only — the query string is exactly where per-user values hide.
248
+ return parsed.pathname;
249
+ }
250
+
251
+ /**
252
+ * The span-building core: pure, given an inbound trace (or none) and the ids.
253
+ *
254
+ * Holds the trace context (the adopted server trace id + the request span as
255
+ * parent, or a freshly-rooted browser trace) and turns each performance entry
256
+ * into a {@link BrowserSpan}. Pure and injectable so every mapping branch is
257
+ * unit-tested without a `PerformanceObserver`.
258
+ */
259
+ export class BrowserTracer {
260
+ /** The trace every span here joins — the server's, or a fresh browser root. */
261
+ readonly traceId: string;
262
+
263
+ /** The parent every top-level browser span points back at — the server request span, or none. */
264
+ private readonly serverSpanId: string | undefined;
265
+
266
+ private readonly timeOrigin: number;
267
+
268
+ private readonly origin: string;
269
+
270
+ private readonly randomSpanId: () => string;
271
+
272
+ constructor(options: {
273
+ readonly inbound: Traceparent | undefined;
274
+ readonly timeOrigin: number;
275
+ readonly origin: string;
276
+ readonly randomSpanId: () => string;
277
+ readonly randomTraceId: () => string;
278
+ }) {
279
+ // An inbound traceparent (the SSR meta) joins the SERVER trace and parents on
280
+ // the request span; absent, we root a fresh browser trace with no parent.
281
+ this.traceId = options.inbound?.traceId ?? options.randomTraceId();
282
+ this.serverSpanId = options.inbound?.parentId;
283
+ this.timeOrigin = options.timeOrigin;
284
+ this.origin = options.origin;
285
+ this.randomSpanId = options.randomSpanId;
286
+ }
287
+
288
+ /** A high-res entry time (ms since `timeOrigin`) → epoch ms, rounded for the wire. */
289
+ private epoch(highRes: number): number {
290
+ return Math.round(this.timeOrigin + highRes);
291
+ }
292
+
293
+ /** Mint one span parented on the server request span (or unparented when rooted). */
294
+ private span(
295
+ name: string,
296
+ startTime: number,
297
+ endTime: number,
298
+ attributes: Record<string, number | string>,
299
+ ): BrowserSpan {
300
+ return {
301
+ traceId: this.traceId,
302
+ spanId: this.randomSpanId(),
303
+ ...(this.serverSpanId === undefined ? {} : { parentSpanId: this.serverSpanId }),
304
+ name,
305
+ startedAt: this.epoch(startTime),
306
+ endedAt: this.epoch(endTime),
307
+ attributes,
308
+ status: 1,
309
+ };
310
+ }
311
+
312
+ /** Map a navigation-timing entry to one `browser.navigation` span carrying its phase marks. */
313
+ navigationSpan(entry: NavEntryLike): BrowserSpan {
314
+ // Each phase is a duration (ms), authored by the browser — pure timing, no PII.
315
+ // A TLS handshake that never happened (plain HTTP) reports secureConnectionStart
316
+ // as 0, so we only record the TLS phase when it actually occurred.
317
+ const tlsMs =
318
+ entry.secureConnectionStart > 0 ? entry.connectEnd - entry.secureConnectionStart : 0;
319
+
320
+ return this.span("browser.navigation", entry.startTime, entry.loadEventEnd, {
321
+ "browser.dns_ms": entry.domainLookupEnd - entry.domainLookupStart,
322
+ "browser.tcp_ms": entry.connectEnd - entry.connectStart,
323
+ "browser.tls_ms": tlsMs,
324
+ "browser.request_ms": entry.responseStart - entry.requestStart,
325
+ "browser.response_ms": entry.responseEnd - entry.responseStart,
326
+ "browser.dom_ms": entry.domContentLoadedEventEnd - entry.responseEnd,
327
+ "browser.load_ms": entry.loadEventEnd - entry.startTime,
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Map a resource-timing entry to one `browser.resource` span, or `undefined`
333
+ * when the resource is cross-origin (its path is dropped, so there is nothing
334
+ * PII-safe to record). The island chunk and data fetch this captures are the
335
+ * UI→API hop the trace is meant to show.
336
+ */
337
+ resourceSpan(entry: ResourceEntryLike): BrowserSpan | undefined {
338
+ const path = sameOriginPath(entry.name, this.origin);
339
+
340
+ if (path === undefined) return undefined;
341
+
342
+ return this.span("browser.resource", entry.startTime, entry.startTime + entry.duration, {
343
+ "browser.resource_path": path,
344
+ "browser.initiator": entry.initiatorType,
345
+ "browser.duration_ms": Math.round(entry.duration),
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Map a Web-Vital entry (LCP / first-input for INP / a raw layout-shift) to a
351
+ * point-in-time `browser.web_vital` span. A vital is a measurement, not a window,
352
+ * so its span is zero-width (start == end) and carries the value as an attribute.
353
+ */
354
+ vitalSpan(name: string, value: number, at: number): BrowserSpan {
355
+ return this.span("browser.web_vital", at, at, {
356
+ "browser.vital": name,
357
+ "browser.value": Math.round(value),
358
+ });
359
+ }
360
+ }
361
+
362
+ /** The default same-origin POST: fire-and-forget, `keepalive` so it survives unload. */
363
+ export function defaultSendBrowserSpans(payload: BrowserSpansPayload): void {
364
+ // `keepalive` lets the POST outlive a navigation away (the spans still land);
365
+ // a rejected fetch is swallowed — a dead RUM beacon must never throw on a page.
366
+ void fetch(BROWSER_SPANS_PATH, {
367
+ method: "POST",
368
+ keepalive: true,
369
+ headers: { "content-type": "application/json" },
370
+ body: JSON.stringify(payload),
371
+ }).catch(() => {});
372
+ }
373
+
374
+ /**
375
+ * Wrap a `fetch` so SAME-ORIGIN requests carry an outbound `traceparent`.
376
+ *
377
+ * This is the UI→API propagation seam: an island/data fetch made through the
378
+ * wrapped `fetch` stamps a W3C `traceparent` built from the browser trace's id +
379
+ * a fresh child span id, so the SERVER handler joins the SAME trace the page is
380
+ * already part of. The header is added ONLY for same-origin requests — stamping
381
+ * it cross-origin would leak the trace id to a third party — and never overwrites
382
+ * a `traceparent` the caller already set.
383
+ *
384
+ * `traceId` comes from the page's {@link BrowserTracer}; `spanId` is freshly
385
+ * minted per call so each request points the server at its own child span.
386
+ * `origin` and the `fetch` impl are injected so the same-origin decision and the
387
+ * wrapping are unit-testable without a browser.
388
+ */
389
+ export function wrapFetch(options: {
390
+ readonly traceId: string;
391
+ readonly origin: string;
392
+ readonly randomSpanId: () => string;
393
+ readonly fetchImpl: typeof fetch;
394
+ }): typeof fetch {
395
+ const { traceId, origin, randomSpanId, fetchImpl } = options;
396
+
397
+ const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
398
+ // Resolve the request URL the same way the platform does, tolerating a
399
+ // `Request` object, a `URL`, or a string. A URL we cannot parse is left
400
+ // untouched — we never want the wrapper to break a fetch it can't classify.
401
+ let url: URL | undefined;
402
+
403
+ try {
404
+ const raw = typeof input === "object" && "url" in input ? input.url : String(input);
405
+
406
+ url = new URL(raw, origin);
407
+ } catch {
408
+ url = undefined;
409
+ }
410
+
411
+ // Cross-origin (or unparseable): pass through verbatim — never stamp the trace
412
+ // id where it could leak to a third party.
413
+ if (url === undefined || url.origin !== origin) return fetchImpl(input, init);
414
+
415
+ // `fetch(input, init)` builds the request's headers from `init.headers` when
416
+ // present, else from a Request `input` — it does NOT merge the two. Mirror that
417
+ // so `fetch(new Request(url, { headers }))` keeps its own headers instead of
418
+ // losing them all to the traceparent stamp.
419
+ const headers = new Headers(
420
+ typeof input === "object" && "url" in input && init?.headers === undefined
421
+ ? input.headers
422
+ : init?.headers,
423
+ );
424
+
425
+ // Never overwrite a traceparent the caller already set — they own propagation.
426
+ // The outbound parent-id is THIS request's fresh child span; the server then
427
+ // points its `http.request` span back at it, joining the page's trace.
428
+ if (!headers.has("traceparent")) {
429
+ headers.set("traceparent", formatTraceparent(traceId, randomSpanId()));
430
+ }
431
+
432
+ return fetchImpl(input, { ...init, headers });
433
+ };
434
+
435
+ return wrapped as typeof fetch;
436
+ }
437
+
438
+ /** Build the default browser environment from the live globals (feature-detected). */
439
+ export function browserRumEnvironment(): RumEnvironment {
440
+ // `PerformanceObserver` is the capability gate: absent (an old browser, SSR, a
441
+ // test without jsdom), `createObserver` is undefined and RUM is a silent no-op.
442
+ const hasObserver =
443
+ typeof PerformanceObserver !== "undefined" && typeof performance !== "undefined";
444
+
445
+ const createObserver = hasObserver
446
+ ? (callback: (entries: readonly PerfEntryLike[]) => void): PerfObserverLike => {
447
+ const observer = new PerformanceObserver((list) => {
448
+ callback(list.getEntries() as unknown as readonly PerfEntryLike[]);
449
+ });
450
+
451
+ return observer as unknown as PerfObserverLike;
452
+ }
453
+ : undefined;
454
+
455
+ return {
456
+ readTraceparent: readTraceparentMeta,
457
+ timeOrigin: typeof performance === "undefined" ? 0 : performance.timeOrigin,
458
+ createObserver,
459
+ send: defaultSendBrowserSpans,
460
+ random: Math.random,
461
+ randomSpanId: () => randomHex(16),
462
+ randomTraceId: () => randomHex(32),
463
+ };
464
+ }
465
+
466
+ /**
467
+ * A random lowercase-hex id of `chars` length, drawn from `crypto` when present.
468
+ *
469
+ * The browser ships `crypto.getRandomValues`; we draw `chars/2` bytes and render
470
+ * them hex. Where `crypto` is absent (an ancient runtime) we fall back to
471
+ * `Math.random` — RUM ids are correlation keys, not security tokens, so a weaker
472
+ * source is acceptable for the fallback rather than failing the whole pipeline.
473
+ */
474
+ function randomHex(chars: number): string {
475
+ const bytes = chars / 2;
476
+
477
+ const cryptoApi = typeof crypto === "undefined" ? undefined : crypto;
478
+
479
+ if (cryptoApi?.getRandomValues !== undefined) {
480
+ const buffer = new Uint8Array(bytes);
481
+
482
+ cryptoApi.getRandomValues(buffer);
483
+
484
+ return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("");
485
+ }
486
+
487
+ let out = "";
488
+
489
+ for (let i = 0; i < bytes; i++) {
490
+ out += Math.floor(Math.random() * 256)
491
+ .toString(16)
492
+ .padStart(2, "0");
493
+ }
494
+
495
+ return out;
496
+ }
497
+
498
+ /**
499
+ * The capability the dispatcher reads off each performance entry — which span
500
+ * builder it maps to. Pure routing, factored out so the observe loop stays flat.
501
+ */
502
+ function spanForEntry(tracer: BrowserTracer, entry: PerfEntryLike): BrowserSpan | undefined {
503
+ if (entry.entryType === "navigation") {
504
+ return tracer.navigationSpan(entry as NavEntryLike);
505
+ }
506
+
507
+ if (entry.entryType === "resource") {
508
+ return tracer.resourceSpan(entry as ResourceEntryLike);
509
+ }
510
+
511
+ if (entry.entryType === "largest-contentful-paint") {
512
+ return tracer.vitalSpan("LCP", entry.startTime, entry.startTime);
513
+ }
514
+
515
+ if (entry.entryType === "first-input") {
516
+ // INP's seed: the first input's processing delay is its `duration`. The vital
517
+ // is recorded at the input's start time, valued by that delay.
518
+ return tracer.vitalSpan("INP", entry.duration, entry.startTime);
519
+ }
520
+
521
+ // A layout-shift WITHOUT recent input is CLS-eligible; one right after an input
522
+ // is user-initiated and excluded by spec (an excluded shift yields no span). We
523
+ // emit each eligible shift's RAW score under a `layout-shift` label — not a
524
+ // `CLS` one — because the cumulative metric is a session-windowed sum this
525
+ // dispatcher does not compute. (×1000 so a sub-unit score survives Math.round.)
526
+ const shift = entry as LayoutShiftLike;
527
+
528
+ if (shift.hadRecentInput) return undefined;
529
+
530
+ return tracer.vitalSpan("layout-shift", shift.value * 1000, shift.startTime);
531
+ }
532
+
533
+ /**
534
+ * Start browser RUM: observe the page's performance, build spans under the server
535
+ * trace, and POST them.
536
+ *
537
+ * The lifecycle:
538
+ *
539
+ * 1. Gate on the sample rate — an unsampled session does nothing (no observer,
540
+ * no cost), exactly the bounded posture the beacon takes.
541
+ * 2. Feature-detect `PerformanceObserver` — absent, return a no-op disposer.
542
+ * 3. Read the SSR-injected traceparent meta; build a {@link BrowserTracer} that
543
+ * adopts the server trace id (or roots a fresh one).
544
+ * 4. Observe navigation/resource/web-vital entries (buffered, so pre-attach
545
+ * entries still arrive), map each to a span, and POST batches.
546
+ *
547
+ * Returns a disposer that flushes any pending spans and disconnects the observer
548
+ * — called on page hide so the last batch is not lost. Always returns a function,
549
+ * even on the no-op paths, so a caller can dispose unconditionally.
550
+ */
551
+ export function startBrowserRum(options: RumOptions = {}): () => void {
552
+ const environment = options.environment ?? browserRumEnvironment();
553
+
554
+ const rate = options.sampleRate ?? DEFAULT_RUM_SAMPLE_RATE;
555
+
556
+ // Bounded sampling: an unsampled session is a true no-op — no observer attaches.
557
+ if (!shouldSampleRum(rate, environment.random)) return () => {};
558
+
559
+ const { createObserver } = environment;
560
+
561
+ // No `PerformanceObserver` (old browser, SSR, a bare test): nothing to observe.
562
+ if (createObserver === undefined) return () => {};
563
+
564
+ const inbound = environment.readTraceparent();
565
+
566
+ const origin = typeof location === "undefined" ? "http://localhost" : location.origin;
567
+
568
+ const tracer = new BrowserTracer({
569
+ inbound,
570
+ timeOrigin: environment.timeOrigin,
571
+ origin,
572
+ randomSpanId: environment.randomSpanId,
573
+ randomTraceId: environment.randomTraceId,
574
+ });
575
+
576
+ // The pending batch — flushed when the observer fires and once on dispose, so a
577
+ // late entry (a layout shift just before the page hides) is not stranded.
578
+ let pending: BrowserSpan[] = [];
579
+
580
+ const flush = (): void => {
581
+ if (pending.length === 0) return;
582
+
583
+ const spans = pending;
584
+ pending = [];
585
+
586
+ environment.send({ v: 1, traceId: tracer.traceId, spans });
587
+ };
588
+
589
+ const observer = createObserver((entries) => {
590
+ for (const entry of entries) {
591
+ const span = spanForEntry(tracer, entry);
592
+
593
+ if (span !== undefined) pending.push(span);
594
+ }
595
+
596
+ flush();
597
+ });
598
+
599
+ for (const type of OBSERVED_TYPES) {
600
+ // A browser that lacks one entry type throws on `observe` for it; we tolerate
601
+ // that per-type so the supported types still attach (a partial-support browser
602
+ // still emits the vitals it can, rather than RUM failing wholesale).
603
+ try {
604
+ observer.observe({ type, buffered: true });
605
+ } catch {
606
+ // unsupported entry type on this browser — skip it, keep the rest
607
+ }
608
+ }
609
+
610
+ return () => {
611
+ flush();
612
+
613
+ observer.disconnect();
614
+ };
615
+ }
package/src/time.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Clock } from "./types";
2
+
3
+ /**
4
+ * Time, made injectable.
5
+ *
6
+ * The default clock reads the real wall clock in epoch milliseconds; tests pass
7
+ * a frozen clock instead, so every `startedAt` / `endedAt` is deterministic.
8
+ */
9
+
10
+ export const systemClock: Clock = () => Date.now();