Skip to content

feat(browser): XHR spans and page load timing - #1147

Merged
Makisuo merged 4 commits into
feat/browser-sdk-error-filtersfrom
feat/browser-sdk-xhr-pageload
Sep 29, 2026
Merged

Makisuo merged 4 commits into
feat/browser-sdk-error-filtersfrom
feat/browser-sdk-xhr-pageload

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Part 3 of the browser SDK stack. Based on the error filters PR.

What changes

XHR spans (tracing.instrumentXhr, default true)

  • @opentelemetry/instrumentation-xml-http-request, configured like fetch: ingest calls ignored, traceparent only to same-origin or propagateTraceHeaderCorsUrls, URLs redacted, and spans still waiting on resource timing ended on pagehide.
  • axios and other XHR clients no longer drop out of the browser → backend trace.

One HTTP status rule for fetch and XHR

  • The fetch instrumentation (old-semconv default) leaves 4xx/5xx responses Unset. The XHR instrumentation marks every status ≥ 400 Error, and every Error span becomes an issue in error_events_mv, so enabling it as-is would have opened an issue for every 404.
  • HttpStatusExporter clears an Error that was set only because of the response status (client span, numeric error.type, no exception event). Network failures and recorded exceptions keep their Error. The status can't be reset in the instrumentation hook, since setStatus(Unset) is a no-op in the SDK.
  • A later PR in the stack makes the range configurable (errors.captureHttpStatus).

Replay link fix

  • The XHR instrumentation starts its span in open(), but the replay network capture only opened its trace-id slot around send(), so XHR rows in the session transcript would not link to their trace. It now opens one around open() as well (packages/browser-session, also used by the Effect SDK; the change is generic).

Page load timing

  • The document's pageload span now starts at navigation start (performance.timeOrigin), not when the app's JS reached startNavigation.
  • Once the page has loaded, child spans are built from the Navigation Timing entry: documentFetch (with dns, connect, request, response under it, span names matching the upstream document-load instrumentation), domProcessing and loadEvent. Phases that didn't happen are skipped.
  • These are child spans, not span events, since the spec is moving away from span events.
  • The code lives in the deferred chunk; navigation hands it the pageload span through a small hook, so it costs the eager bundle nothing.

Eager budget 42 → 43 kB for the XHR instrumentation and status policy, which must patch before the app's first request.

Testing

  • HttpStatusExporter unit tests (status-only Error cleared; network failure, recorded exception and non-client spans kept).
  • Browser tests: an XHR span exported and ended on pagehide; the replay capture linking an XHR to a span its tracer started in open() (fails without the fix); document timing parented and timed against the real navigation entry, and not repeated for later navigations.
  • Existing navigation tests now scope their span-name assertions to navigation spans.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

- XMLHttpRequest is instrumented like fetch (tracing.instrumentXhr, default
  true), with the same ingest ignore list, traceparent CORS allowlist, URL
  redaction and pagehide end for spans still waiting on resource timing.
  axios and older clients no longer drop out of the browser -> backend trace.
- An export-time HTTP status policy gives fetch and XHR one rule: a
  response status alone never makes a client span Error. The XHR
  instrumentation marks every status >= 400 Error, and every Error span
  becomes an issue, so without this each 404 would have opened one. Network
  failures and recorded exceptions are kept.
- The replay network capture now opens its trace-id slot around XHR open()
  too, since the XHR instrumentation starts its span there; replay network
  rows for XHRs link to their traces again.
- The document's pageload span starts at navigation start, and gets child
  spans from the Navigation Timing entry: documentFetch (with dns, connect,
  request, response), domProcessing and loadEvent. They are recorded in the
  deferred chunk, handed the pageload span through a small hook.
- Eager budget 42 -> 43 kB for the XHR instrumentation and status policy.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 58 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e36235eb-d231-4532-b2a2-80889e2fd06d

📥 Commits

Reviewing files that changed from the base of the PR and between c095d88 and 36fdc83.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • docs/browser-sdk.md
  • packages/browser-session/src/replay/capture/network.browser.test.ts
  • packages/browser-session/src/replay/capture/network.ts
  • packages/browser/README.md
  • packages/browser/package.json
  • packages/browser/scripts/size.ts
  • packages/browser/src/config.ts
  • packages/browser/src/deferred/document-timing.ts
  • packages/browser/src/deferred/index.ts
  • packages/browser/src/http-status.test.ts
  • packages/browser/src/http-status.ts
  • packages/browser/src/logs.browser.test.ts
  • packages/browser/src/navigation.browser.test.ts
  • packages/browser/src/navigation.test.ts
  • packages/browser/src/navigation.ts
  • packages/browser/src/tracing.browser.test.ts
  • packages/browser/src/tracing.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The new startTime: performance.timeOrigin for pageload interacts with ConsentSpanExporter's start-time filter, and I could not read consentAllowedSince to confirm it.
quality 90/100 · 1 warning · tests partial · risk medium · 3/3 new units observable

Warning

This review ended early; what follows is what it established.

Adds XHR auto-instrumentation (new tracing.instrumentXhr config, on by default) and a HttpStatusExporter that clears status-only Error spans, plus document load timing spans in the deferred chunk. The XHR and HTTP-status work looks sound; the new page-load start time needs one check against the consent flush filter.

  • startNavigation starts the first pageload span at performance.timeOrigin
  • New HttpStatusExporter clears Error status set only by a 4xx/5xx response
  • instrumentXhr / tracingInstrumentXhr registers XMLHttpRequestInstrumentation
  • recordDocumentTiming spans documentFetch, dns/connect/request/response and domProcessing/loadEvent

Findings

Warning · F1 · pageload at performance.timeOrigin is filtered out by the consent start-time check

correctness · packages/browser/src/navigation.ts:91-92

ConsentSpanExporter.export keeps only spans whose startTime is at or after consentAllowedSince() (tracing.ts:81). Starting the first pageload span at performance.timeOrigin gives it a start time before any consent granted during that page load, so the span is dropped at flush and the first page load loses its root trace — the document timing and loader spans it parents then export as an orphaned trace. Confirm against consentAllowedSince and, if it is the grant time, clamp the start time to the consent grant (or exempt pageload from the filter).

What was checked
  • withoutStatusError copies every ReadableSpan field the OTLP exporter reads (spanContext, parent, resource, scope, dropped counts)
  • XHR capture slot logic in network.ts (open/send) reassigns traceId before the loadend listener reads it, so the closure sees the updated id
  • spanPhases skips zero/negative phases, so a reused connection emits no dns/connect span
Observability coverage: 3 of 3 changes observable
Change Kind Observable Evidence
XHR client spans via XMLHttpRequestInstrumentation client yes OTel instrumentation registered with the explicit provider (tracing.ts:260)
document load timing spans (documentFetch, phases) internal/client yes started on Maple's tracer under the pageload span (deferred/document-timing.ts:44-62)
HTTP status policy on exported client spans span processing yes HttpStatusExporter wrapped inside ConsentSpanExporter (tracing.ts:168)
Copy all findings (1)
Findings from an automated review of commit 469db42d5e17941658d16bb8c12d9e1f5e1dda3a. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · correctness · packages/browser/src/navigation.ts:91-92
`pageload` at `performance.timeOrigin` is filtered out by the consent start-time check
`ConsentSpanExporter.export` keeps only spans whose `startTime` is at or after `consentAllowedSince()` (tracing.ts:81). Starting the first `pageload` span at `performance.timeOrigin` gives it a start time before any consent granted during that page load, so the span is dropped at flush and the first page load loses its root trace — the document timing and loader spans it parents then export as an orphaned trace. Confirm against `consentAllowedSince` and, if it is the grant time, clamp the start time to the consent grant (or exempt `pageload` from the filter).

469db42 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/browser/src/navigation.ts Outdated
With privacy.requireConsent, consent granted during the page load made the
pageload span (started at navigation start) begin before the grant, and the
consent exporter dropped it. It now starts at the later of the two.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The whole status policy rests on the XHR instrumentation setting a CLIENT kind and a numeric error.type, which I could not read at the installed version.
quality 90/100 · 1 warning · tests covered · risk medium · 2/2 new units observable

Adds XHR auto-instrumentation (default on), an exporter that clears status-only client Errors so 404s open no issue, a replay trace-id fix for spans started in open(), and Navigation Timing child spans under pageload. Structurally sound; the status range is the one thing to settle before merge.

  • XMLHttpRequestInstrumentation registered alongside fetch, gated by tracing.instrumentXhr
  • HttpStatusExporter rewrites status-only client Error spans to Unset
  • pageload starts at timeOrigin, clamped to consentAllowedSince()
  • Deferred chunk adds documentFetch, domProcessing, loadEvent children

Findings

Warning · F2 · Status policy clears Error on 5xx client spans too, not just 4xx

correctness · packages/browser/src/http-status.ts:15

isStatusOnlyError matches any numeric three-digit error.type, so a server failure (error.type "500", no exception event, client kind) is rewritten to Unset and — since only Error spans reach error_events_mv — a broken API call opens no issue. The stated goal was to stop 404s from opening one; the guard needs >= 500 (or the 4xx range) to keep server errors visible until the follow-up PR makes the range configurable.

Restrict the rewrite to the client-error range, e.g. `const status = Number(span.attributes["error.type"]); return Number.isInteger(status) && status >= 400 && status < 500`.
What was checked
  • Document timing cannot double-record: documentLoad is cleared at the first startNavigation (navigation.ts:84-97), and pendingPageload is consumed once
  • Shared requestOptions.applyCustomAttributesOnSpan is safe for both instrumentations: noteSettled reads only the span and prunes ended entries (tracing.ts:238)
  • The clamp cannot produce a span starting after its end: consentAllowedSince() is Date.now() at the grant or 0 when consent is not required (consent.ts:154)
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
XMLHttpRequest auto-instrumentation (client spans) outbound call yes tracing.ts:260 registers XMLHttpRequestInstrumentation, which emits client spans with peer/URL attributes
Document timing child spans from the Navigation Timing entry background work yes deferred/document-timing.ts:37,44 starts real spans parented to pageload
Copy all findings (1)
Findings from an automated review of commit 1920320ba1b719205741a0b94b03499188332c75. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F2 · Warning · correctness · packages/browser/src/http-status.ts:15
Status policy clears Error on 5xx client spans too, not just 4xx
`isStatusOnlyError` matches any numeric three-digit `error.type`, so a server failure (`error.type` `"500"`, no exception event, client kind) is rewritten to `Unset` and — since only Error spans reach `error_events_mv` — a broken API call opens no issue. The stated goal was to stop 404s from opening one; the guard needs `>= 500` (or the 4xx range) to keep server errors visible until the follow-up PR makes the range configurable.
Suggested fix: Restrict the rewrite to the client-error range, e.g. `const status = Number(span.attributes["error.type"]); return Number.isInteger(status) && status >= 400 && status < 500`.

1920320 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@Makisuo
Makisuo added this pull request to stack #1161 September 29, 2026 21:22
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The new document-timing child spans start before any consent grant and are dropped by the consent filter, untested in that configuration.
quality 90/100 · 1 warning · tests partial · risk medium · 2/2 new units observable

Adds XHR auto-instrumentation with a shared HTTP status policy, a replay trace-id link for XHR, and Navigation-Timing child spans under pageload. Safe to merge apart from the page-load breakdown being dropped on consent-gated installs.

  • XMLHttpRequestInstrumentation shares fetch's ignore/propagate/settle options
  • HttpStatusExporter unsets an Error set only by response status
  • pageload starts at clamped navigation start; document timing spans added
  • Replay capture records the trace id started in XHR open()

Still open from earlier reviews

What was checked
  • pageload start clamp (navigation.ts:92) is followed by the consent test at navigation.browser.test.ts:728
  • ConsentSpanExporter filters on each span's own start time (tracing.ts:80)
  • Replay link reads __mapleTraceId ?? activeTraceId() (network.ts:72) and its test fails without the open() capture
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
XHR auto-instrumentation outbound call yes XMLHttpRequestInstrumentation registered on Maple's provider (tracing.ts:260)
Document page-load child spans background work yes documentFetch/dns/connect/request/response/domProcessing/loadEvent started under pageload (deferred/document-timing.ts:37,44)

36fdc83 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@Makisuo
Makisuo merged commit 8074c74 into main Sep 29, 2026
43 checks passed
@Makisuo
Makisuo deleted the feat/browser-sdk-xhr-pageload branch September 29, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant