Skip to content

Commit 75694d2

Browse files
committed
Allow Web Vitals metrics reconfiguration
1 parent 22cd768 commit 75694d2

6 files changed

Lines changed: 80 additions & 49 deletions

File tree

packages/logfire-browser/src/index.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,6 @@ vi.mock('@opentelemetry/sdk-trace-web', () => ({
240240
}))
241241

242242
vi.mock('./webVitals', () => ({
243-
assertBrowserWebVitalsMetricsCanStart: () => undefined,
244243
startBrowserWebVitals: async (options: unknown) => mocks.startBrowserWebVitals(options),
245244
}))
246245

packages/logfire-browser/src/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import type { RUMOptions } from './browserSession'
6363
import type { BrowserMetricsOptions, BrowserWebVitalsMetricOptions } from './browserMetrics'
6464
import { BrowserSessionReplayState, startBrowserSessionReplay } from './sessionReplay'
6565
import type { BrowserSessionReplayOptions } from './sessionReplay'
66-
import { assertBrowserWebVitalsMetricsCanStart, startBrowserWebVitals } from './webVitals'
66+
import { startBrowserWebVitals } from './webVitals'
6767
import type { BrowserWebVitalsOptions } from './webVitals'
6868
import { LogfireSpanProcessor } from './LogfireSpanProcessor'
6969
export { DiagLogLevel } from '@opentelemetry/api'
@@ -285,9 +285,6 @@ export function configure(options: LogfireConfigOptions): () => Promise<void> {
285285
if (webVitalsMetricOptions !== undefined && browserMetricsOptions === undefined) {
286286
throw new Error('logfire-browser: rum.webVitals.metrics requires top-level metrics.metricUrl')
287287
}
288-
if (webVitalsMetricOptions !== undefined) {
289-
assertBrowserWebVitalsMetricsCanStart()
290-
}
291288
const browserSessionOptions = resolveBrowserSessionOptions(options.rum, sessionReplayOptions)
292289

293290
const apiConfig: LogfireApiConfigOptions = {

packages/logfire-browser/src/webVitals.test.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ describe('browser Web Vitals reporting', () => {
221221
expect(metricRecorder.shutdown).toHaveBeenCalledTimes(1)
222222
})
223223

224-
it('uses the first metric recorder when duplicate startup is requested', async () => {
224+
it('uses the latest metric recorder when duplicate startup is requested', async () => {
225225
const firstMetricRecorder = createMetricRecorder()
226226
const secondMetricRecorder = createMetricRecorder()
227227
const metric = createMetric('FCP', { firstByteToFCP: 85, loadState: 'dom-interactive', timeToFirstByte: 42 })
@@ -233,16 +233,53 @@ describe('browser Web Vitals reporting', () => {
233233
for (const name of webVitalNames) {
234234
expect(mocks.registrations[name]).toHaveLength(1)
235235
}
236-
expect(firstMetricRecorder.record).toHaveBeenCalledWith(metric)
237-
expect(secondMetricRecorder.record).not.toHaveBeenCalled()
236+
expect(firstMetricRecorder.record).not.toHaveBeenCalled()
237+
expect(secondMetricRecorder.record).toHaveBeenCalledWith(metric)
238238
})
239239

240-
it('rejects enabling metrics after Web Vitals already started without metrics', async () => {
240+
it('attaches a metric recorder after Web Vitals already started without metrics', async () => {
241+
const metricRecorder = createMetricRecorder()
242+
const beforeRecorder = createMetric('FCP', { firstByteToFCP: 85, loadState: 'dom-interactive', timeToFirstByte: 42 })
243+
const afterRecorder = createMetric('TTFB', { waitingDuration: 1 })
244+
241245
await startBrowserWebVitals()
246+
report('FCP', beforeRecorder)
242247

243-
await expect(startBrowserWebVitals({ metricRecorder: createMetricRecorder() })).rejects.toThrow(
244-
'Web Vitals were already started without metrics'
245-
)
248+
await startBrowserWebVitals({ metricRecorder })
249+
report('TTFB', afterRecorder)
250+
251+
for (const name of webVitalNames) {
252+
expect(mocks.registrations[name]).toHaveLength(1)
253+
}
254+
expect(metricRecorder.record).toHaveBeenCalledTimes(1)
255+
expect(metricRecorder.record).toHaveBeenCalledWith(afterRecorder)
256+
})
257+
258+
it('does not let an older handle shutdown clear a newer metric recorder', async () => {
259+
const firstMetricRecorder = createMetricRecorder()
260+
const secondMetricRecorder = createMetricRecorder()
261+
const metric = createMetric('LCP', {
262+
resourceLoadDelay: 10,
263+
resourceLoadDuration: 20,
264+
target: '#hero img',
265+
timeToFirstByte: 40,
266+
})
267+
268+
const firstHandle = await startBrowserWebVitals({ metricRecorder: firstMetricRecorder })
269+
const secondHandle = await startBrowserWebVitals({ metricRecorder: secondMetricRecorder })
270+
271+
await firstHandle.shutdown()
272+
report('LCP', metric)
273+
274+
expect(firstMetricRecorder.shutdown).toHaveBeenCalledTimes(1)
275+
expect(firstMetricRecorder.record).not.toHaveBeenCalled()
276+
expect(secondMetricRecorder.record).toHaveBeenCalledWith(metric)
277+
278+
await secondHandle.shutdown()
279+
report('LCP', metric)
280+
281+
expect(secondMetricRecorder.shutdown).toHaveBeenCalledTimes(1)
282+
expect(secondMetricRecorder.record).toHaveBeenCalledTimes(1)
246283
})
247284

248285
it('creates a span with base attributes for each report', async () => {

packages/logfire-browser/src/webVitals.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,26 @@ interface BrowserWebVitalsStartOptions extends BrowserWebVitalsOptions {
5252
metricRecorder?: BrowserWebVitalsMetricRecorder
5353
}
5454

55+
let startupPromise: Promise<void> | undefined
56+
let currentMetricRecorder: BrowserWebVitalsMetricRecorder | undefined
57+
5558
function createHandle(metricRecorder: BrowserWebVitalsMetricRecorder | undefined): BrowserWebVitalsHandle {
59+
let shutdownCalled = false
5660
return {
5761
async shutdown() {
62+
if (shutdownCalled) {
63+
return Promise.resolve()
64+
}
65+
shutdownCalled = true
5866
metricRecorder?.shutdown()
67+
if (currentMetricRecorder === metricRecorder) {
68+
currentMetricRecorder = undefined
69+
}
5970
return Promise.resolve()
6071
},
6172
}
6273
}
6374

64-
const noopHandle = createHandle(undefined)
65-
66-
let startupPromise: Promise<BrowserWebVitalsHandle> | undefined
67-
let startupHasMetricRecorder = false
68-
6975
function setPrimitiveAttribute(attributes: Attributes, key: string, value: unknown): void {
7076
if (typeof value === 'string' || typeof value === 'boolean') {
7177
attributes[key] = value
@@ -194,44 +200,36 @@ function reportWebVital(metric: MetricWithAttribution, metricRecorder: BrowserWe
194200
reportWebVitalMetric(metric, metricRecorder)
195201
}
196202

197-
function registerWebVitals(webVitals: WebVitalsAttributionModule, options: BrowserWebVitalsStartOptions = {}): BrowserWebVitalsHandle {
203+
function registerWebVitals(webVitals: WebVitalsAttributionModule, options: BrowserWebVitalsStartOptions = {}): void {
198204
const reportOptions = createBaseReportOptions(options)
199205
const report = (metric: MetricWithAttribution) => {
200-
reportWebVital(metric, options.metricRecorder)
206+
reportWebVital(metric, currentMetricRecorder)
201207
}
202208
webVitals.onLCP(report, reportOptions)
203209
webVitals.onINP(report, createInpReportOptions(options))
204210
webVitals.onCLS(report, reportOptions)
205211
webVitals.onFCP(report, reportOptions)
206212
webVitals.onTTFB(report, reportOptions)
207-
return createHandle(options.metricRecorder)
208-
}
209-
210-
export function assertBrowserWebVitalsMetricsCanStart(): void {
211-
if (startupPromise !== undefined && !startupHasMetricRecorder) {
212-
throw new Error(
213-
'logfire-browser: Web Vitals were already started without metrics in this page lifecycle; configure rum.webVitals.metrics before the first Web Vitals startup'
214-
)
215-
}
216213
}
217214

218215
export async function startBrowserWebVitals(options: BrowserWebVitalsStartOptions = {}): Promise<BrowserWebVitalsHandle> {
219216
if (options.metricRecorder !== undefined) {
220-
assertBrowserWebVitalsMetricsCanStart()
217+
currentMetricRecorder = options.metricRecorder
221218
}
222219

223220
startupPromise ??= import('web-vitals/attribution')
224-
.then((webVitals) => registerWebVitals(webVitals, options))
221+
.then((webVitals) => {
222+
registerWebVitals(webVitals, options)
223+
})
225224
.catch((error: unknown) => {
226225
diag.error('logfire-browser: failed to start Web Vitals reporting', error)
227-
return noopHandle
228226
})
229-
startupHasMetricRecorder ||= options.metricRecorder !== undefined
230227

231-
return startupPromise
228+
await startupPromise
229+
return createHandle(options.metricRecorder)
232230
}
233231

234232
export function resetBrowserWebVitalsForTests(): void {
235233
startupPromise = undefined
236-
startupHasMetricRecorder = false
234+
currentMetricRecorder = undefined
237235
}

plans/022-browser-rum-web-vitals-metrics.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,10 @@ automatic route-template extraction, or per-route soft-navigation Web Vitals.
133133
- Web Vitals callbacks cannot be unregistered and should not be registered more
134134
than once per page lifecycle. Metric recording must reuse PRP 021 callback
135135
registration rather than calling `onLCP()`, `onINP()`, etc. again.
136-
- The current `startBrowserWebVitals()` implementation is guarded by a
137-
module-level startup promise. If a page first configures Web Vitals without
138-
metrics and later reconfigures with metrics, later metric enablement cannot
139-
add new observers. Either reject this transition clearly or design the module
140-
to register sinks before first startup and keep later cleanup no-op.
136+
- `startBrowserWebVitals()` is guarded by a module-level startup promise because
137+
Web Vitals observers are page-lifetime callbacks. Do not add duplicate
138+
observers on reconfigure; keep metric recording behind a mutable recorder
139+
reference so later configure calls can attach or replace the active sink.
141140
- Browser metrics should use a local `MeterProvider`. Do not set the global
142141
OpenTelemetry meter provider from `@pydantic/logfire-browser` unless a future
143142
explicit option asks for that. Replacing the global provider can break app
@@ -442,8 +441,8 @@ environment issue in the execution summary.
442441
- [x] Metric attributes are low-cardinality and exclude session/exact
443442
URL/selector/raw entry attributes.
444443
- [x] Existing Web Vital spans still include PRP 021 attributes.
445-
- [x] Duplicate Web Vitals startup does not duplicate observers or metric
446-
records.
444+
- [x] Duplicate Web Vitals startup does not duplicate observers, and later
445+
metric startup can attach or replace the active metric recorder.
447446
- [x] Browser example builds after metric config/docs changes.
448447

449448
## Clarifications
@@ -473,8 +472,9 @@ environment issue in the execution summary.
473472
proxy/example in this PRP so user-perspective metric testing is possible.
474473
- Q: What happens if Web Vitals were already started without metrics and a
475474
later configure call tries to enable Web Vitals metrics in the same page
476-
lifecycle? -> A: Reject this transition clearly. Web Vitals observers are
477-
page-lifetime callbacks and should not be re-registered.
475+
lifecycle? -> A: Do not re-register Web Vitals observers. Attach the later
476+
metric recorder through the module-level mutable recorder reference; metrics
477+
emitted before the recorder exists are not backfilled.
478478
- Q: What metric instrument shape should be used? -> A: Use one histogram per
479479
Web Vital, with unit `ms` for LCP, INP, FCP, and TTFB, and unit `1` for CLS.
480480

reports/browser-rum-replay-follow-ups.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ page URLs, so custom-event redaction alone does not fully solve URL privacy.
5050

5151
## Web Vitals Reconfiguration Lifecycle
5252

53-
`webVitals.ts` intentionally uses module-level startup state because the
54-
`web-vitals` library registers page-lifecycle observers without a public
55-
unregister API. After shutdown, a second `configure()` call in the same page can
56-
reuse the original startup promise and fail to attach a new metrics recorder.
57-
Decide whether to support HMR/reconfigure by introducing a mutable recorder
58-
reference, or document Web Vitals startup as one-shot per page lifecycle.
53+
Addressed in this branch: `webVitals.ts` still uses module-level startup state
54+
because the `web-vitals` library registers page-lifecycle observers without a
55+
public unregister API, but the callbacks now read a mutable metric recorder
56+
reference. A later `configure()` call can attach or replace the active recorder
57+
without duplicate observers. Metrics emitted before a recorder exists are not
58+
backfilled.
5959

6060
## Replay Session Expiry During Replay-only Activity
6161

0 commit comments

Comments
 (0)