diff --git a/apps/landing/src/content/docs/session-replay/browser-sdk.md b/apps/landing/src/content/docs/session-replay/browser-sdk.md
index 97b2459e1..ebdaea687 100644
--- a/apps/landing/src/content/docs/session-replay/browser-sdk.md
+++ b/apps/landing/src/content/docs/session-replay/browser-sdk.md
@@ -1,11 +1,11 @@
---
title: "Browser SDK"
-description: "Instrument a website with OpenTelemetry tracing, error capture and session replay using the @maple-dev/browser SDK."
+description: "Instrument a website with OpenTelemetry tracing, logs, Web Vitals, error capture and session replay using the @maple-dev/browser SDK."
group: "Session Replay"
order: 1
---
-`@maple-dev/browser` adds OpenTelemetry tracing, error capture and session replay to a website in one package. Every span and every replay event carries the same `session.id`, so a trace links to the replay that produced it, and a replay links to its traces.
+`@maple-dev/browser` adds OpenTelemetry tracing, logs, Web Vitals, error capture and session replay to a website in one package. Everything it sends is OpenTelemetry (OTLP traces and logs), apart from the replay recording itself. Every span and every replay event carries the same `session.id`, so a trace links to the replay that produced it, and a replay links to its traces.
Browsers
@@ -51,32 +51,47 @@ The SDK is best-effort. A telemetry network failure never throws into your app.
Every field accepted by `MapleBrowser.init`:
-| Option | Type | Default | Description |
-| -------------------------------------- | ------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `serviceName` | `string` | none | **Required.** Service name reported on traces and stored on replay sessions. |
-| `ingestKey` | `string` | none | Public ingest key (`maple_pk_...`), sent as `Authorization: Bearer`. Leave it unset only when `endpoint` points at your own proxy that adds the key. |
-| `region` | `"us"` \| `"eu"` | `"us"` | Region of your Maple organization. `"us"` sends to `https://ingest.maple.dev`, `"eu"` to `https://ingest.eu.maple.dev`. Ignored when `endpoint` is set. |
-| `endpoint` | `string` | from `region` | Ingest base URL. Overrides `region`. Use it for a proxy. |
-| `serviceNamespace` | `string` | none | Logical group this service belongs to, sent as the `service.namespace` resource attribute on traces. |
-| `serviceVersion` | `string` | none | Service version or commit SHA, attached to traces. |
-| `environment` | `string` | none | Deployment environment, for example `"production"`. |
-| `user` | `object` | none | End-user identity: `id`, `email`, `username`, `groupId`, `groupName`, `traits`. Attached to the session and to browser spans. See [Identifying users](#identifying-users). |
-| `userId` | `string` | none | **Deprecated.** A bare user id. Use `user` instead. When both are set, `user` wins. |
-| `tracing.enabled` | `boolean` | `true` | Enable OpenTelemetry browser tracing. |
-| `tracing.instrumentFetch` | `boolean` | `true` | Create spans for `fetch()` calls. Set `false` when another tracer (such as the Effect client SDK) already instruments requests, to avoid duplicate network spans. |
-| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled promise rejections as error spans. Turn it off only when another tool owns the page's global error handlers. |
-| `tracing.propagateTraceHeaderCorsUrls` | `Array
` | `[]` | Cross-origin URLs whose `fetch()` and XHR requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). |
-| `replay.enabled` | `boolean` | `true` | Enable session recording. |
-| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. See [Sampling](#sampling). |
-| `privacy.maskAllInputs` | `boolean` | `true` | Mask all ` ` values in the recording. |
-| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the recording, and omit captured click-target text from session events. |
-| `privacy.sanitizeUrl` | `(url: string) => string` | none | Rewrite every URL before it leaves the page. See [Redacting URLs](#redacting-urls). |
-| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage and cookie) so unique and returning visitors can be counted. Turning it off also deletes any id already stored. |
-| `privacy.crossSubdomainCookie` | `boolean` | `true` | Scope the visitor-id cookie to the registered domain so sibling subdomains share it. See [Linking a marketing site to your app](#linking-a-marketing-site-to-your-app). |
-| `privacy.cookieDomain` | `string` | probed | Explicit cookie `Domain=` (no leading dot). `""` forces a host-only cookie. |
-| `privacy.requireConsent` | `boolean` | `false` | Capture nothing until `MapleBrowser.setConsent(true)`. See [Consent](#consent). |
-| `privacy.captureUserEmail` | `boolean` | `true` | Store the email passed to `identify()`. |
-| `privacy.respectDoNotTrack` | `boolean` | `false` | Treat `navigator.doNotTrack` like Global Privacy Control (suppresses the persistent visitor id). |
+| Option | Type | Default | Description |
+| -------------------------------------- | -------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `serviceName` | `string` | none | **Required.** Service name reported on traces and stored on replay sessions. |
+| `ingestKey` | `string` | none | Public ingest key (`maple_pk_...`), sent as `Authorization: Bearer`. Leave it unset only when `endpoint` points at your own proxy that adds the key. |
+| `region` | `"us"` \| `"eu"` | `"us"` | Region of your Maple organization. `"us"` sends to `https://ingest.maple.dev`, `"eu"` to `https://ingest.eu.maple.dev`. Ignored when `endpoint` is set. |
+| `endpoint` | `string` | from `region` | Ingest base URL. Overrides `region`. Use it for a proxy. |
+| `serviceNamespace` | `string` | none | Logical group this service belongs to, sent as the `service.namespace` resource attribute on traces. |
+| `serviceVersion` | `string` | none | Service version or commit SHA, attached to traces. |
+| `environment` | `string` | none | Deployment environment, for example `"production"`. |
+| `user` | `object` | none | End-user identity: `id`, `email`, `username`, `groupId`, `groupName`, `traits`. Attached to the session and to browser spans. See [Identifying users](#identifying-users). |
+| `userId` | `string` | none | **Deprecated.** A bare user id. Use `user` instead. When both are set, `user` wins. |
+| `tracing.enabled` | `boolean` | `true` | Enable OpenTelemetry browser tracing. |
+| `tracing.instrumentFetch` | `boolean` | `true` | Create spans for `fetch()` calls. Set `false` when another tracer (such as the Effect client SDK) already instruments requests, to avoid duplicate network spans. |
+| `tracing.instrumentXhr` | `boolean` | `true` | Create spans for `XMLHttpRequest` calls (axios and older clients). Turn it off for the same reason as `instrumentFetch`. |
+| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled promise rejections as error spans. Turn it off only when another tool owns the page's global error handlers. |
+| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` and XHR requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). |
+| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Error spans are always exported. See [Sampling](#sampling). |
+| `tracing.captureHeaders` | `{ request?, response? }` | none | Header names to record on `fetch`/XHR spans. See [Request and response detail](#request-and-response-detail). |
+| `tracing.longFrames` | `boolean` | `false` | Span main-thread frames of 100ms or more. See [Jank](#jank). |
+| `tracing.slowInteractions` | `boolean` | `false` | Span interactions of 200ms or more. See [Jank](#jank). |
+| `errors` | `object` | see [Filtering errors](#filtering-errors) | `ignore`, `denyUrls`, `allowUrls`, `beforeCapture`, `defaultFilters` and `captureHttpStatus`. |
+| `breadcrumbs` | `boolean` | `true` | Keep the last clicks, inputs, navigations and console lines, and send them with the next error. See [Breadcrumbs](#breadcrumbs). |
+| `webVitals` | `boolean` | `true` | Report Core Web Vitals. See [Web Vitals](#web-vitals). |
+| `logs.captureConsole` | `Array<"debug" \| "log" \| "info" \| "warn" \| "error">` | `[]` | Console levels sent as logs as they happen. See [Logs](#logs). |
+| `reporting.csp` | `boolean` | `true` | Content Security Policy violations as logs. See [Browser reports](#browser-reports). |
+| `reporting.browserReports` | `boolean` | `false` | Browser deprecation and intervention reports as logs. |
+| `transport.offline` | `boolean` | `false` | Keep batches that could not be sent and send them later. See [Offline](#offline). |
+| `replay.enabled` | `boolean` | `true` | Enable session recording. |
+| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. See [Sampling](#sampling). |
+| `replay.onErrorSampleRate` | `number` | `0` | Fraction of the sessions not recorded that keep the last minute in memory and upload it only if an error happens. See [Replay on error](#replay-on-error). |
+| `replay.canvasFps` | `number` | off | Record `` content at this many frames per second. Never with `privacy.maskAllText`. |
+| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep request and response bodies of these URLs in the replay. See [Request and response detail](#request-and-response-detail). |
+| `privacy.maskAllInputs` | `boolean` | `true` | Mask all ` ` values in the recording. |
+| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the recording, and omit captured click-target text from session events. |
+| `privacy.sanitizeUrl` | `(url: string) => string` | none | Rewrite every URL before it leaves the page. See [Redacting URLs](#redacting-urls). |
+| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage and cookie) so unique and returning visitors can be counted. Turning it off also deletes any id already stored. |
+| `privacy.crossSubdomainCookie` | `boolean` | `true` | Scope the visitor-id cookie to the registered domain so sibling subdomains share it. See [Linking a marketing site to your app](#linking-a-marketing-site-to-your-app). |
+| `privacy.cookieDomain` | `string` | probed | Explicit cookie `Domain=` (no leading dot). `""` forces a host-only cookie. |
+| `privacy.requireConsent` | `boolean` | `false` | Capture nothing until `MapleBrowser.setConsent(true)`. See [Consent](#consent). |
+| `privacy.captureUserEmail` | `boolean` | `true` | Store the email passed to `identify()`. |
+| `privacy.respectDoNotTrack` | `boolean` | `false` | Treat `navigator.doNotTrack` like Global Privacy Control (suppresses the persistent visitor id). |
A fully-specified call:
@@ -210,6 +225,44 @@ The same error object is recorded once, even if your code reports it and then re
A cross-origin script that throws shows up in the browser as a bare "Script error." with no details, and the SDK skips it. Add the `crossorigin` attribute to the script tag to get the real error.
+`error.cause` chains and the errors inside an `AggregateError` (up to five) are added to the stack trace as `Caused by:` blocks, after the error's own frames.
+
+### Filtering errors
+
+Filters run before an error is recorded, so a dropped error costs nothing and never opens an issue. They apply to the global handlers and to `captureException`.
+
+```ts
+MapleBrowser.init({
+ // ...
+ errors: {
+ ignore: ["ChunkLoadError", /^AbortError: /], // matched against "Name: message"
+ denyUrls: [/widgets\.example\.net/], // matched against the top frame's script URL
+ allowUrls: [/^https:\/\/app\.example\.com\//], // errors with no stack frames are kept
+ beforeCapture: (error, { source, originalError }) => !error.message.includes("401"),
+ },
+})
+```
+
+Errors thrown by browser extensions and the harmless `ResizeObserver loop` notices are dropped by default. Set `errors.defaultFilters: false` to keep them.
+
+### Failed HTTP requests
+
+`fetch` and `XMLHttpRequest` spans follow the OpenTelemetry HTTP conventions for client requests: a response with a 4xx or 5xx status marks the span as an error, with `error.type` set to the status code (`"404"`, `"503"`), so it opens an issue. Issues group by status code.
+
+To count fewer statuses, narrow the list (the default is `[[400, 599]]`). A status left out is not an error:
+
+```ts
+errors: {
+ captureHttpStatus: [[500, 599], 429]
+} // a 404 from a search box is expected here
+```
+
+A request that gets no response at all (offline, DNS, CORS, or a timeout from `AbortSignal.timeout()`) is always an error. A request your code aborts with its own `AbortController` is not.
+
+### Breadcrumbs
+
+The SDK keeps the last 50 clicks, inputs, navigations and console lines in memory. Nothing is sent until an error is recorded. Then the trail is sent as OpenTelemetry log records linked to the error, so you see what the user did right before it on the error's trace. Each breadcrumb is sent once. Clicks and inputs are recorded as a short selector (`button#save`), never with input values. Turn it off with `breadcrumbs: false`.
+
## Connect browser and backend traces
For a request to the same origin as the page, the `fetch()` span sends a W3C `traceparent` header, and your backend's span joins the same trace. For a request to another origin, such as `https://api.example.com` from `https://app.example.com`, the header is not sent unless you list the URL:
@@ -228,6 +281,60 @@ Your API must also allow the header in its CORS configuration. Add `traceparent`
Your backend must be instrumented with OpenTelemetry and read `traceparent`, which every OpenTelemetry HTTP server instrumentation does.
+## Logs
+
+`MapleBrowser.logger` writes OpenTelemetry log records, each linked to the span that was active when it was written and to the session:
+
+```ts
+MapleBrowser.logger.info("checkout started", { "cart.items": 3 })
+MapleBrowser.logger.error("payment declined", { "payment.provider": "card" })
+```
+
+To send console output as logs, list the levels: `logs: { captureConsole: ["warn", "error"] }`. Calls before `init()` are queued.
+
+## Web Vitals
+
+LCP, CLS, INP, FCP and TTFB are reported as OpenTelemetry log events named `browser.web_vital`, with the `browser.web_vital.name`, `value`, `delta`, `rating`, `id` and `navigation_type` attributes from the OpenTelemetry browser conventions, plus `url.path`. Each is linked to the page load's trace when you use [navigation spans](#navigation-and-data-loading-spans). CLS, INP and LCP are final when the page is hidden, so they arrive then. Turn them off with `webVitals: false`.
+
+## Jank
+
+Two opt-in options show where the main thread got stuck:
+
+```ts
+tracing: { longFrames: true, slowInteractions: true }
+```
+
+- `longFrames` records every frame of 100ms or more as a `longAnimationFrame` span, with the script that ran longest (file, function and what invoked it, such as `BUTTON#save.onclick`). Browsers without the Long Animation Frames API record `longtask` spans instead.
+- `slowInteractions` records every interaction of 200ms or more as an `interaction click` (or `keydown`, ...) span, split into input delay, processing and presentation time, with the element that was used.
+
+Both nest under the open navigation span, and include what happened before the SDK finished loading.
+
+## Request and response detail
+
+List the headers to record on `fetch` and XHR spans:
+
+```ts
+tracing: { captureHeaders: { request: ["x-request-id"], response: ["x-cache", "server-timing"] } }
+```
+
+They are stored as `http.request.header.` and `http.response.header.`. `authorization`, `proxy-authorization`, `cookie` and `set-cookie` are never recorded, even when listed. XHR spans get response headers only, and a cross-origin response only exposes the headers its server lists in `Access-Control-Expose-Headers`.
+
+Request and response bodies can be kept in the session replay's network events, for the URLs you list only:
+
+```ts
+replay: {
+ networkBodies: {
+ urls: [/^https:\/\/api\.example\.com\/checkout/]
+ }
+}
+```
+
+Only text and JSON bodies are kept, cut to `maxLength` characters (1,000 by default, which is also the most Maple stores), and nothing is kept with `privacy.maskAllText`. Bodies can contain personal data, so list only endpoints whose payloads you are allowed to record.
+
+## Browser reports
+
+Content Security Policy violations are sent as `maple.browser.csp_violation` warning logs, with the directive, the blocked URL and, when known, the script location. Set `reporting.browserReports: true` to also get the browser's deprecation and intervention reports. These are logs, not errors, so they never open an issue. Each kind of report is sent once per page.
+
## Navigation and data-loading spans
Three calls turn one click into one trace: a navigation span, the data-loading spans under it, the `fetch()` spans those make, and the backend spans behind them. Call them from your router's hooks. The [frontend guides](/docs/frontend) show where for each framework.
@@ -246,9 +353,12 @@ MapleBrowser.endNavigation("/projects/:id")
```
- `startNavigation(path)` opens a `pageload` span on the first call and a `navigate` span on every later one, with the path as `url.path`. A navigation still open is ended with `app.navigation.interrupted: true`. The page load joins the server render's trace when the document response has a `Server-Timing: traceparent;desc="…"` entry or the page a ` ` tag.
+- The page load starts at the browser's navigation start, and once the page has loaded it gets child spans for fetching the HTML (`documentFetch`, with `dns`, `connect`, `request` and `response` under it), `domProcessing` and `loadEvent`.
- `endNavigation(route?)` renames the span to `navigate ` (or `pageload `) and ends it. It does nothing when no navigation is open.
- `traced(name, fn, options?)` runs `fn` in a span under the open navigation and returns its result unchanged. A throw is recorded on the span, marks it `Error`, and is rethrown; `isFailure` returning `false` leaves the span `Ok`. An error `traced` recorded isn't reported again by `captureException` or the global handlers.
+Using React Router or TanStack Router? `instrumentReactRouter` and `instrumentTanStackRouter` from `@maple-dev/browser/react` make these calls for you. See [React](#react).
+
The browser has no async context: only requests `fn` starts before its first `await` nest under its span. Start independent requests together, with `Promise.all`.
All three do nothing on the server, before `init()`, with tracing disabled or before consent is granted; `traced` then only runs `fn`. A page load that happened before consent isn't traced later: the next navigation is a `navigate` span. Leaving the page or calling `shutdown()` ends an open navigation as interrupted, so it still exports.
@@ -341,6 +451,22 @@ MapleBrowser.init({
A value outside `0` to `1` is clamped, with a console warning.
+`tracing.sampleRate` samples traces the same way. The decision is made once per session, so a sampled session keeps all of its traces and its replay never links to a missing one. Errors reported as their own spans (uncaught errors, unhandled rejections, `captureException` and failures inside `traced()`) are always sent. Failed request spans follow the session's sampling like any other span. Sampled traces carry their sampling rate so request counts in Maple stay accurate.
+
+### Replay on error
+
+`replay.onErrorSampleRate` covers the sessions `replay.sampleRate` leaves out. Those sessions record into memory only, keeping roughly the last minute, and upload nothing. When an error is recorded, the buffered minute is uploaded and the rest of the session is recorded normally, including its later page loads.
+
+```ts
+replay: { sampleRate: 0.05, onErrorSampleRate: 1 } // 5% of sessions, plus every session with an error
+```
+
+These sessions download the recorder like recorded ones, and keep up to 4 MB in memory.
+
+## Offline
+
+The SDK retries a failed send for a few seconds. With `transport: { offline: true }`, a batch of spans or logs that still could not be sent is kept in IndexedDB and sent again when the browser is back online or on the next page load. At most 100 batches are kept, for up to 24 hours, and revoking consent deletes them.
+
## Session size limit
A single session records at most **1 GiB** of decompressed replay data. Past that, the recording is cut at a chunk boundary: earlier chunks stay playable, later ones are dropped, and the session is still listed with its metadata and linked traces.
@@ -386,6 +512,33 @@ import { App } from "./App"
createRoot(document.getElementById("root")!).render( )
```
+### React
+
+`@maple-dev/browser/react` adds an error boundary, a handler for React 19's root error options, and router integrations that record navigations for you:
+
+```tsx
+import { createBrowserRouter, RouterProvider } from "react-router"
+import { instrumentReactRouter, mapleReactErrorHandler, MapleErrorBoundary } from "@maple-dev/browser/react"
+
+const router = createBrowserRouter(routes)
+instrumentReactRouter(router) // or instrumentTanStackRouter(router)
+
+createRoot(document.getElementById("root")!, {
+ onCaughtError: mapleReactErrorHandler(),
+ onUncaughtError: mapleReactErrorHandler(),
+}).render(
+ Try again }>
+
+ ,
+)
+```
+
+- `MapleErrorBoundary` reports a render error once, with the component stack, and renders `fallback`.
+- `instrumentReactRouter` works with data routers (`createBrowserRouter`). A navigation starts when the router starts loading and ends when its loaders finish, named after the route (`navigate /projects/:id`).
+- `instrumentTanStackRouter` does the same, named after the route's full path (`navigate /projects/$projectId`). Changing only search params is not a navigation.
+
+With a router integration, don't call `startNavigation` and `endNavigation` yourself.
+
### Next.js
Next.js exposes `NEXT_PUBLIC_*` variables through `process.env`, not `import.meta.env`. Initialize from a client component and render it in the root layout:
diff --git a/bun.lock b/bun.lock
index 84f0d2834..70f181451 100644
--- a/bun.lock
+++ b/bun.lock
@@ -639,7 +639,7 @@
},
"packages/browser": {
"name": "@maple-dev/browser",
- "version": "0.9.0",
+ "version": "0.10.1",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
@@ -762,7 +762,7 @@
},
"packages/effect-sdk": {
"name": "@maple-dev/effect-sdk",
- "version": "0.9.0",
+ "version": "0.9.1",
"dependencies": {
"std-env": "^4.0.0",
},
diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md
index 49d598b17..65066be5f 100644
--- a/docs/browser-sdk.md
+++ b/docs/browser-sdk.md
@@ -294,22 +294,24 @@ Errors thrown from browser extensions (`chrome-extension://`, `moz-extension://`
### Failed HTTP requests
-A `fetch` or `XMLHttpRequest` response status is not an error on its own: a 404 from a search box
-is usually expected. List the statuses that should become errors (and so issues) in
-`errors.captureHttpStatus`:
+`fetch` and `XMLHttpRequest` spans follow the HTTP semantic conventions for client spans: a 4xx or
+5xx response makes the span `Error`, with `error.type` set to the status code (`"404"`, `"503"`)
+and no status description, so it opens an issue. Issues group by service and status code.
+
+To count fewer statuses, narrow `errors.captureHttpStatus` (default `[[400, 599]]`). A status left
+out has its `Error` cleared:
```ts
MapleBrowser.init({
// ...
- errors: { captureHttpStatus: [[500, 599], 429] },
+ errors: { captureHttpStatus: [[500, 599], 429] }, // a 404 from a search box is expected here
})
```
-A matching span gets status `Error`, `error.type` set to the status code (per the HTTP semantic
-conventions) and `error.message` like `POST https://api.example.com/users/42 -> 503`, without the
-query string. Issues group by status and request, with ids in the path redacted. Network failures
-(no response at all: offline, DNS, CORS, a timeout) are always errors, with `error.type` set to
-what failed (`TypeError` for `fetch`, `error` or `timeout` for XHR). An aborted request is not.
+Network failures (no response at all: offline, DNS, CORS, or a timeout, including one from `AbortSignal.timeout()`) are always errors, with
+`error.type` set to what failed (`TypeError` for `fetch`, `error` or `timeout` for XHR). A request your code
+aborts with its own `AbortController` is not. No `error.message` is set: it is deprecated in the conventions, and the status
+code already says what went wrong.
### Breadcrumbs
@@ -488,7 +490,7 @@ memory.
`session.id`), so a sampled session keeps every one of its traces and its replay never links to a
dropped one. Errors reported as their own spans (uncaught errors, unhandled rejections and
`captureException`) are always exported, whatever the rate; request spans of an unsampled session
-are not, including ones `errors.captureHttpStatus` would have marked.
+are not, including failed requests.
```ts
MapleBrowser.init({
diff --git a/packages/browser/README.md b/packages/browser/README.md
index cbc0e663c..da91182d9 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -68,8 +68,9 @@ fingerprint to one contentless issue that buries the real ones. Add
### Failed HTTP requests
-`errors: { captureHttpStatus: [[500, 599]] }` makes those `fetch`/XHR responses errors, typed by
-status. By default a response status alone is not an error; a network failure always is.
+`fetch`/XHR responses with a 4xx or 5xx status are errors, typed by status, as the HTTP semantic
+conventions say for client spans. Narrow that with `errors: { captureHttpStatus: [[500, 599]] }`.
+A network failure is always an error.
### Breadcrumbs
diff --git a/packages/browser/package.json b/packages/browser/package.json
index ea627e274..b556aa9da 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -1,6 +1,6 @@
{
"name": "@maple-dev/browser",
- "version": "0.9.0",
+ "version": "0.10.1",
"description": "Maple browser SDK — OpenTelemetry tracing and rrweb session replay in one package. Every span and replay event shares a session id for trace↔replay correlation.",
"keywords": [
"maple",
diff --git a/packages/browser/src/http-status.test.ts b/packages/browser/src/http-status.test.ts
index fb40da522..569d26123 100644
--- a/packages/browser/src/http-status.test.ts
+++ b/packages/browser/src/http-status.test.ts
@@ -29,71 +29,55 @@ const finish = (
return result
}
-describe("HttpStatusExporter", () => {
- it("clears an Error set only because of the response status", () => {
- const span = finish((s) => {
- s.setAttribute("http.response.status_code", 404)
- s.setAttribute("error.type", "404")
- s.setStatus({ code: SpanStatusCode.ERROR })
- })
- expect(span.status.code).toBe(SpanStatusCode.UNSET)
- expect(span.attributes["error.type"]).toBeUndefined()
- expect(span.attributes["http.response.status_code"]).toBe(404)
- expect(span.spanContext().spanId).toMatch(/^[0-9a-f]{16}$/)
+describe("HttpStatusExporter, by default", () => {
+ it("keeps 4xx and 5xx client spans Error, typed by the status, with no description", () => {
+ for (const status of [404, 503]) {
+ const span = finish((s) => {
+ s.setAttribute("http.request.method", "POST")
+ s.setAttribute("url.full", "https://api.test/users/42")
+ s.setAttribute("http.response.status_code", status)
+ })
+ expect(span.status).toEqual({ code: SpanStatusCode.ERROR })
+ expect(span.attributes["error.type"]).toBe(String(status))
+ expect(span.attributes["error.message"]).toBeUndefined()
+ }
})
- it("keeps network failures, recorded exceptions, and non-client spans", () => {
+ it("leaves 2xx/3xx, recorded exceptions, network failures and non-client spans alone", () => {
+ const ok = finish((s) => s.setAttribute("http.response.status_code", 204))
+ expect(ok.status.code).toBe(SpanStatusCode.UNSET)
+
const network = finish((s) => {
- s.setAttribute("error.type", "timeout")
- s.setStatus({ code: SpanStatusCode.ERROR, message: "timeout" })
+ s.setAttribute("error.type", "TypeError")
+ s.setStatus({ code: SpanStatusCode.ERROR })
})
expect(network.status.code).toBe(SpanStatusCode.ERROR)
+ expect(network.attributes["error.message"]).toBeUndefined()
const withException = finish((s) => {
- s.setAttribute("error.type", "500")
+ s.setAttribute("http.response.status_code", 500)
s.recordException(new Error("boom"))
s.setStatus({ code: SpanStatusCode.ERROR })
})
expect(withException.status.code).toBe(SpanStatusCode.ERROR)
const internal = finish((s) => {
- s.setAttribute("error.type", "500")
- s.setStatus({ code: SpanStatusCode.ERROR })
+ s.setAttribute("http.response.status_code", 500)
}, SpanKind.INTERNAL)
- expect(internal.status.code).toBe(SpanStatusCode.ERROR)
+ expect(internal.status.code).toBe(SpanStatusCode.UNSET)
})
})
-describe("errors.captureHttpStatus", () => {
- const capturing = tracerWith([[500, 599], 429])
+describe("errors.captureHttpStatus, narrowed", () => {
+ const serverErrors = tracerWith([[500, 599], 429])
- it("makes a listed status an Error, typed by the status and described by the request", () => {
- const span = finish(
- (s) => {
- s.setAttribute("http.request.method", "POST")
- s.setAttribute("url.full", "https://api.test/users/42?token=REDACTED")
- s.setAttribute("http.response.status_code", 503)
- },
- SpanKind.CLIENT,
- capturing,
- )
- expect(span.status.code).toBe(SpanStatusCode.ERROR)
- expect(span.attributes["error.type"]).toBe("503")
- expect(span.attributes["error.message"]).toBe("POST https://api.test/users/42 -> 503")
- })
-
- it("matches single codes and old-semconv attributes, and leaves the rest alone", () => {
- const limited = finish(
- (s) => {
- s.setAttribute("http.method", "GET")
- s.setAttribute("http.url", "https://api.test/search")
- s.setAttribute("http.status_code", 429)
- },
- SpanKind.CLIENT,
- capturing,
- )
+ it("keeps listed statuses Error, including old-semconv attributes", () => {
+ const limited = finish((s) => s.setAttribute("http.status_code", 429), SpanKind.CLIENT, serverErrors)
+ expect(limited.status.code).toBe(SpanStatusCode.ERROR)
expect(limited.attributes["error.type"]).toBe("429")
+ })
+ it("clears the Error an instrumentation set for a status left out", () => {
const notFound = finish(
(s) => {
s.setAttribute("http.response.status_code", 404)
@@ -101,20 +85,9 @@ describe("errors.captureHttpStatus", () => {
s.setStatus({ code: SpanStatusCode.ERROR })
},
SpanKind.CLIENT,
- capturing,
+ serverErrors,
)
expect(notFound.status.code).toBe(SpanStatusCode.UNSET)
- })
-
- it("keeps a network failure an error and describes it like a status error", () => {
- const failed = finish((s) => {
- s.setAttribute("http.request.method", "POST")
- s.setAttribute("url.full", "https://api.test/orders?id=7")
- s.setAttribute("http.response.status_code", 0)
- s.setAttribute("error.type", "TypeError")
- s.setStatus({ code: SpanStatusCode.ERROR, message: "Failed to fetch" })
- })
- expect(failed.status.code).toBe(SpanStatusCode.ERROR)
- expect(failed.attributes["error.message"]).toBe("POST https://api.test/orders -> TypeError")
+ expect(notFound.attributes["error.type"]).toBeUndefined()
})
})
diff --git a/packages/browser/src/http-status.ts b/packages/browser/src/http-status.ts
index 751100c1c..6180f0ac1 100644
--- a/packages/browser/src/http-status.ts
+++ b/packages/browser/src/http-status.ts
@@ -1,9 +1,10 @@
-// The OTel adapter for the shared HTTP status policy. The fetch instrumentation
-// leaves 4xx/5xx responses Unset; the XHR one marks every status >= 400 Error,
-// and every Error span becomes an issue, so both are brought to the one rule.
+// The OTel adapter for the shared HTTP status policy. The fetch and XHR
+// instrumentations already mark 4xx/5xx client spans Error, as the HTTP semantic
+// conventions say; this applies `errors.captureHttpStatus` when an app narrows it.
import {
+ DEFAULT_ERROR_STATUS,
type HttpStatusRange,
- httpStatusError,
+ httpErrorType,
inStatusRanges,
type ReadAttribute,
responseStatus,
@@ -48,7 +49,7 @@ function withStatus(span: ReadableSpan, status: SpanStatus, attributes: Attribut
export class HttpStatusExporter implements SpanExporter {
constructor(
private readonly inner: SpanExporter,
- private readonly captureStatus: ReadonlyArray = [],
+ private readonly captureStatus: ReadonlyArray = DEFAULT_ERROR_STATUS,
) {}
private apply(span: ReadableSpan): ReadableSpan {
@@ -63,24 +64,9 @@ export class HttpStatusExporter implements SpanExporter {
return withStatus(
span,
{ code: SpanStatusCode.ERROR },
- { ...span.attributes, ...httpStatusError(read, status) },
+ { ...span.attributes, ...httpErrorType(status) },
)
}
- const errorType = span.attributes["error.type"]
- if (
- span.kind === SpanKind.CLIENT &&
- span.status.code === SpanStatusCode.ERROR &&
- typeof errorType === "string" &&
- !HTTP_STATUS.test(errorType) &&
- span.attributes["error.message"] === undefined &&
- !span.events.some((event) => event.name === "exception")
- ) {
- // A network failure (`TypeError`, `error`, `timeout`): the same message shape as a status error.
- return withStatus(span, span.status, {
- ...span.attributes,
- "error.message": httpStatusError(read, errorType)["error.message"],
- })
- }
if (!isStatusOnlyError(span)) return span
const { "error.type": _errorType, ...attributes } = span.attributes
return withStatus(span, { code: SpanStatusCode.UNSET }, attributes)
diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts
index 72bdeee6f..b1e214bac 100644
--- a/packages/browser/src/tracing.browser.test.ts
+++ b/packages/browser/src/tracing.browser.test.ts
@@ -276,7 +276,25 @@ describe("setupTracing unload flush", () => {
expect(exported[0]?.status.code).toBe(2)
expect(exported[0]?.attributes["error.type"]).toBe("TypeError")
- expect(exported[0]?.attributes["error.message"]).toBe(`GET ${url} -> TypeError`)
+ // error.message is deprecated in the conventions; error.type carries the class of failure.
+ expect(exported[0]?.attributes["error.message"]).toBeUndefined()
+ })
+
+ it("counts a fetch that timed out through AbortSignal.timeout() as an error", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout"] })
+ const poll = { interval: 0 }
+ shutdown = setupTracing({ ...CONFIG, tracingInstrumentFetch: true })
+ const url = URL.createObjectURL(new Blob(["slow"]))
+ const signal = AbortSignal.abort(new DOMException("signal timed out", "TimeoutError"))
+
+ await expect(fetch(url, { signal })).rejects.toThrow("signal timed out")
+ await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1), poll)
+ window.dispatchEvent(new Event("pagehide"))
+ await vi.waitFor(() => expect(exported).toHaveLength(1), poll)
+
+ expect(exported[0]?.status.code).toBe(2)
+ expect(exported[0]?.attributes["error.type"]).toBe("TimeoutError")
+ URL.revokeObjectURL(url)
})
it("does not count a fetch aborted with a custom reason as a network failure", async () => {
diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts
index bb34e7fe4..1c7d28c67 100644
--- a/packages/browser/src/tracing.ts
+++ b/packages/browser/src/tracing.ts
@@ -277,11 +277,12 @@ export function setupTracing(config: ResolvedConfig): () => Promise {
)
} else if (
result instanceof Error &&
- result.name !== "AbortError" &&
- !request.signal?.aborted
+ (isTimeout(result, request.signal) ||
+ (result.name !== "AbortError" && !request.signal?.aborted))
) {
// A rejected fetch (offline, DNS, CORS) ends with status 0 and no error; XHR marks its own.
- // An abort rejects with its reason, whatever that is, so the signal decides.
+ // An abort rejects with its reason, whatever that is, so the signal decides; a
+ // timeout (`AbortSignal.timeout()`) is a failure, not the app changing its mind.
span.setStatus({ code: SpanStatusCode.ERROR, message: result.message })
span.setAttribute("error.type", result.name)
}
@@ -336,6 +337,12 @@ export function setupTracing(config: ResolvedConfig): () => Promise {
}
}
+/** `AbortSignal.timeout()` aborts with, and rejects the fetch with, a `TimeoutError` DOMException. */
+function isTimeout(error: Error, signal: AbortSignal | null | undefined): boolean {
+ const reason: unknown = signal?.reason
+ return error.name === "TimeoutError" || (reason instanceof DOMException && reason.name === "TimeoutError")
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
diff --git a/packages/browser/src/version.ts b/packages/browser/src/version.ts
index 16b9b88a8..55cf47173 100644
--- a/packages/browser/src/version.ts
+++ b/packages/browser/src/version.ts
@@ -8,7 +8,7 @@
// JSON import would force every consuming bundler to handle JSON modules.
// `version.test.ts` asserts it stays in sync with `package.json`, so a
// hand-bump that forgets it fails CI instead of shipping.
-export const SDK_VERSION = "0.9.0"
+export const SDK_VERSION = "0.10.1"
/** The `x-maple-sdk` value this build sends. */
export const SDK_NAME = "maple-browser"
diff --git a/packages/effect-sdk/package.json b/packages/effect-sdk/package.json
index 77b45f9c5..78f932c45 100644
--- a/packages/effect-sdk/package.json
+++ b/packages/effect-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@maple-dev/effect-sdk",
- "version": "0.9.0",
+ "version": "0.9.1",
"description": "Maple observability SDK for Effect applications",
"keywords": [
"effect",
diff --git a/packages/effect-sdk/src/version.ts b/packages/effect-sdk/src/version.ts
index d5b2b12c2..05b2af54e 100644
--- a/packages/effect-sdk/src/version.ts
+++ b/packages/effect-sdk/src/version.ts
@@ -6,7 +6,7 @@
// browsers, and a JSON import would force every consuming bundler to handle
// JSON modules. `version.test.ts` asserts this stays in sync with
// `package.json`, so a hand-bump that forgets it fails CI instead of shipping.
-export const SDK_VERSION = "0.9.0"
+export const SDK_VERSION = "0.9.1"
/**
* `x-maple-sdk` value for the browser client entry — the header equivalent of
diff --git a/packages/sdk-core/src/error-filters.ts b/packages/sdk-core/src/error-filters.ts
index cb9c6c86b..fa9d53f86 100644
--- a/packages/sdk-core/src/error-filters.ts
+++ b/packages/sdk-core/src/error-filters.ts
@@ -22,8 +22,9 @@ export interface ErrorFilterOptions {
readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean
/**
* HTTP response statuses that make a client request span an error (and so an
- * issue), e.g. `[[500, 599]]`. Default none: a response status alone is not
- * an error, and a network failure always is.
+ * issue). Default `[[400, 599]]`, as the HTTP semantic conventions say for
+ * client spans; narrow it, e.g. `[[500, 599]]`, to count fewer. A network
+ * failure is always an error.
*/
readonly captureHttpStatus?: ReadonlyArray
/**
diff --git a/packages/sdk-core/src/http-status.ts b/packages/sdk-core/src/http-status.ts
index 2720713f0..2c21636d8 100644
--- a/packages/sdk-core/src/http-status.ts
+++ b/packages/sdk-core/src/http-status.ts
@@ -1,6 +1,8 @@
// One rule for when an HTTP client span is an error, whichever SDK or
-// instrumentation made it: a response status is an error only when the app
-// lists it in `errors.captureHttpStatus`; a network failure always is.
+// instrumentation made it. By default it is the HTTP semantic conventions' rule:
+// a 4xx or 5xx response makes a client span Error, with `error.type` set to the
+// status code and no status description. `errors.captureHttpStatus` narrows the
+// statuses that count. A network failure is always an error.
/** A status code, or an inclusive `[from, to]` range. */
export type HttpStatusRange = number | readonly [number, number]
@@ -15,6 +17,9 @@ export type AttributeValue =
/** Reads one attribute of the span being classified. */
export type ReadAttribute = (key: string) => AttributeValue | undefined
+/** The statuses the HTTP semantic conventions make a client span Error for: every 4xx and 5xx. */
+export const DEFAULT_ERROR_STATUS: ReadonlyArray = [[400, 599]]
+
export const inStatusRanges = (status: number, ranges: ReadonlyArray): boolean =>
ranges.some((range) =>
typeof range === "number" ? range === status : status >= range[0] && status <= range[1],
@@ -29,15 +34,10 @@ export function responseStatus(read: ReadAttribute): number | undefined {
}
/**
- * The `error.type` / `error.message` pair for a listed status or a network
- * failure (`TypeError`, `timeout`), e.g. `POST https://api.example.com/users/42 -> 503`.
- * The query is dropped; ids in the path are redacted by the issue fingerprint.
+ * The `error.type` for a counted status or a network failure (`TypeError`,
+ * `timeout`), per the HTTP semantic conventions. No message: `error.message` is
+ * deprecated, and the status code already says what went wrong.
*/
-export function httpStatusError(
- read: ReadAttribute,
- status: number | string,
-): { readonly "error.type": string; readonly "error.message": string } {
- const method = read("http.request.method") ?? read("http.method") ?? "GET"
- const url = String(read("url.full") ?? read("http.url") ?? "").replace(/[?#].*$/, "")
- return { "error.type": String(status), "error.message": `${String(method)} ${url} -> ${status}` }
+export function httpErrorType(status: number | string): { readonly "error.type": string } {
+ return { "error.type": String(status) }
}
diff --git a/packages/sdk-core/src/http.test.ts b/packages/sdk-core/src/http.test.ts
index f8938613a..22ffa8358 100644
--- a/packages/sdk-core/src/http.test.ts
+++ b/packages/sdk-core/src/http.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"
import { filterHeaderAttribute, resolveHeaderCapture } from "./http-headers"
import {
type AttributeValue,
- httpStatusError,
+ DEFAULT_ERROR_STATUS,
+ httpErrorType,
inStatusRanges,
type ReadAttribute,
responseStatus,
@@ -26,15 +27,15 @@ describe("http status policy", () => {
expect(responseStatus(attributes({}))).toBeUndefined()
})
- it("describes the failure by method and URL without the query", () => {
- const read = attributes({
- "http.request.method": "POST",
- "url.full": "https://api.test/users/42?token=x",
- })
- expect(httpStatusError(read, 503)).toEqual({
- "error.type": "503",
- "error.message": "POST https://api.test/users/42 -> 503",
- })
+ it("counts every 4xx and 5xx by default, as the HTTP conventions say for client spans", () => {
+ expect(inStatusRanges(400, DEFAULT_ERROR_STATUS)).toBe(true)
+ expect(inStatusRanges(599, DEFAULT_ERROR_STATUS)).toBe(true)
+ expect(inStatusRanges(399, DEFAULT_ERROR_STATUS)).toBe(false)
+ })
+
+ it("types the error by the status code alone", () => {
+ expect(httpErrorType(503)).toEqual({ "error.type": "503" })
+ expect(httpErrorType("TypeError")).toEqual({ "error.type": "TypeError" })
})
})
diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts
index da3a24391..2aabc6427 100644
--- a/packages/sdk-core/src/index.ts
+++ b/packages/sdk-core/src/index.ts
@@ -7,7 +7,7 @@ export { frameUrls, makeErrorFilter } from "./error-filters"
export type { HeaderCapture, HeaderCaptureOptions } from "./http-headers"
export { filterHeaderAttribute, resolveHeaderCapture } from "./http-headers"
export type { AttributeValue, HttpStatusRange, ReadAttribute } from "./http-status"
-export { httpStatusError, inStatusRanges, responseStatus } from "./http-status"
+export { DEFAULT_ERROR_STATUS, httpErrorType, inStatusRanges, responseStatus } from "./http-status"
export type { EmitLog, LogAttributeValue, SignalLogRecord, SpanLink } from "./log-record"
export { Severity, severityOf } from "./log-record"
export type {