diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index d8d63263f..8ebc37937 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -58,6 +58,8 @@ Every field accepted by `MapleBrowser.init`: | `webVitals` | `boolean` | `true` | Report Core Web Vitals as `browser.web_vital` log events. See [Web Vitals](#web-vitals). | | `breadcrumbs` | `boolean` | `true` | Keep the last clicks, inputs, navigations and console lines, and export them with the next error. See [Breadcrumbs](#breadcrumbs). | | `logs.captureConsole` | `ConsoleLevel[]` | `[]` | Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. | +| `reporting.csp` | `boolean` | `true` | Content Security Policy violations as `maple.browser.csp_violation` WARN logs. See [Browser reports](#browser-reports). | +| `reporting.browserReports` | `boolean` | `false` | Browser deprecation and intervention reports as `maple.browser.report` WARN logs. | | `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. | | `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. | | `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). | @@ -283,6 +285,24 @@ Errors thrown from browser extensions (`chrome-extension://`, `moz-extension://` `safari-web-extension://`) and the benign `ResizeObserver loop` notices are dropped by default. Set `errors.defaultFilters: false` to keep them. +### 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`: + +```ts +MapleBrowser.init({ + // ... + errors: { captureHttpStatus: [[500, 599], 429] }, +}) +``` + +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) are always errors. + ### Breadcrumbs The SDK keeps the last 50 clicks, inputs, navigations and console lines in memory. Nothing is sent @@ -339,6 +359,17 @@ Each event carries `session.id` and is linked to the page's `pageload` span when `startNavigation`. CLS, INP and LCP settle when the page is hidden, so they arrive then. Turn them off with `webVitals: false`. +## Browser reports + +Content Security Policy violations are reported as `maple.browser.csp_violation` WARN log events with +`maple.csp.effective_directive`, `maple.csp.blocked_uri`, `maple.csp.disposition`, `url.full` and, +when the browser knows it, `code.file.path` / `code.line.number`. Set `reporting.browserReports: true` +to also get deprecation and intervention reports as `maple.browser.report` events. Both are logs, +not errors, so they never open an issue. + +Each kind of report is sent once per page (a blocked image in a loop is one report), up to 50 kinds. +Where the browser supports `ReportingObserver`, reports from before the SDK loaded are included. + ## Tracing across origins `fetch` and `XMLHttpRequest` spans send the W3C `traceparent` header to same-origin requests only. When your API lives diff --git a/packages/browser/README.md b/packages/browser/README.md index b31e7f905..e1ba2eca0 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -66,6 +66,11 @@ stack and no filename. Those are dropped rather than recorded: they all fingerprint to one contentless issue that buries the real ones. Add `crossorigin` to the script tag to get the real error instead. +### 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. + ### Breadcrumbs The last 50 clicks, inputs, navigations and console lines are kept in memory and exported, as OTel diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index 8eda0f54f..9ed05a7f6 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -62,9 +62,10 @@ const BUDGET = { * * 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue * (~0.5 kB), both needed before the deferred chunk lands. 17 for error - * filters and cause chains (~0.8 kB), which run on the capture path. + * filters and cause chains (~0.8 kB), which run on the capture path. 17.5 + * for `errors.captureHttpStatus`, applied by the span exporter. */ - firstParty: 17, + firstParty: 17.5, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 73cc830c5..d7a90e599 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -96,6 +96,12 @@ export interface MapleBrowserConfig { /** Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. Default none. */ readonly captureConsole?: ReadonlyArray } + readonly reporting?: { + /** Content Security Policy violations as `maple.browser.csp_violation` WARN logs. Default true. */ + readonly csp?: boolean + /** Browser deprecation and intervention reports as `maple.browser.report` WARN logs. Default false. */ + readonly browserReports?: boolean + } /** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */ readonly errors?: ErrorFilterOptions readonly replay?: { @@ -167,6 +173,8 @@ export interface ResolvedConfig { readonly webVitals: boolean readonly breadcrumbs: boolean readonly captureConsole: ReadonlyArray + readonly reportCsp: boolean + readonly reportBrowser: boolean readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -235,6 +243,8 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { webVitals: config.webVitals ?? true, breadcrumbs: config.breadcrumbs ?? true, captureConsole: config.logs?.captureConsole ?? [], + reportCsp: config.reporting?.csp ?? true, + reportBrowser: config.reporting?.browserReports ?? false, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), maskAllInputs: config.privacy?.maskAllInputs ?? true, diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts index 3ddeb818c..3f1c3eea4 100644 --- a/packages/browser/src/deferred/index.ts +++ b/packages/browser/src/deferred/index.ts @@ -7,6 +7,7 @@ import { onDocumentPageload } from "../navigation" import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs" import { recordDocumentTiming } from "./document-timing" import { startLogs } from "./logs" +import { startReports } from "./reports" import { startWebVitals } from "./web-vitals" export function startDeferred(config: ResolvedConfig): () => Promise { @@ -22,6 +23,7 @@ export function startDeferred(config: ResolvedConfig): () => Promise { captureConsole: config.captureConsole, }) setErrorRecordedHook(flushBreadcrumbs) + const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser }) const stops = [ startLogs(config), async () => { @@ -29,6 +31,7 @@ export function startDeferred(config: ResolvedConfig): () => Promise { onDocumentPageload(undefined) setErrorRecordedHook(undefined) stopBreadcrumbs() + stopReports() }, ] return async () => { diff --git a/packages/browser/src/deferred/reports.browser.test.ts b/packages/browser/src/deferred/reports.browser.test.ts new file mode 100644 index 000000000..14965dda4 --- /dev/null +++ b/packages/browser/src/deferred/reports.browser.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { attachLogSink, resetLogsForTests } from "../logs" +import { startReports } from "./reports" + +// The module under test emits through the eager queue; record what reaches it. +const emitted: Array<{ eventName?: string; body?: string; attributes: Record }> = [] + +afterEach(() => { + emitted.length = 0 + resetLogsForTests() + vi.unstubAllGlobals() +}) + +const violation = (blockedURI: string) => + new SecurityPolicyViolationEvent("securitypolicyviolation", { + blockedURI, + effectiveDirective: "img-src", + violatedDirective: "img-src", + originalPolicy: "img-src 'self'", + disposition: "enforce", + documentURI: location.href, + statusCode: 200, + sourceFile: `${location.origin}/app.js`, + lineNumber: 12, + columnNumber: 4, + }) + +describe("startReports", () => { + it("reports a CSP violation once per kind as a WARN log event", () => { + attachLogSink((record) => emitted.push(record)) + vi.stubGlobal("ReportingObserver", undefined) + const stop = startReports({ csp: true, browserReports: false }) + document.dispatchEvent(violation("https://tracker.test/pixel.gif")) + document.dispatchEvent(violation("https://tracker.test/pixel.gif")) + stop() + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.eventName).toBe("maple.browser.csp_violation") + expect(emitted[0]?.body).toBe("img-src blocked https://tracker.test/pixel.gif") + expect(emitted[0]?.attributes["maple.csp.disposition"]).toBe("enforce") + expect(emitted[0]?.attributes["code.line.number"]).toBe(12) + }) + + it("reports nothing when turned off", () => { + attachLogSink((record) => emitted.push(record)) + vi.stubGlobal("ReportingObserver", undefined) + const stop = startReports({ csp: false, browserReports: false }) + document.dispatchEvent(violation("https://tracker.test/pixel.gif")) + stop() + expect(emitted).toEqual([]) + }) + + it("reads CSP reports through ReportingObserver where it exists", async () => { + attachLogSink((record) => emitted.push(record)) + const stop = startReports({ csp: true, browserReports: false }) + const meta = document.createElement("meta") + meta.httpEquiv = "Content-Security-Policy" + meta.content = "img-src 'none'" + document.head.append(meta) + const img = document.createElement("img") + img.src = "https://blocked.test/a.png" + document.body.append(img) + await vi.waitFor(() => + expect(emitted.some((record) => record.eventName === "maple.browser.csp_violation")).toBe(true), + ) + stop() + img.remove() + }) +}) diff --git a/packages/browser/src/deferred/reports.ts b/packages/browser/src/deferred/reports.ts new file mode 100644 index 000000000..5ccec5d8c --- /dev/null +++ b/packages/browser/src/deferred/reports.ts @@ -0,0 +1,133 @@ +// Content Security Policy violations and browser deprecation/intervention +// reports, as WARN log events. They are worth seeing but are not errors in the +// app's code, so they never become issues. +import { scrubUrl } from "@maple/browser-session" +import { emitLog, type LogAttributeValue, Severity } from "../logs" + +export interface ReportOptions { + /** Content Security Policy violations. */ + readonly csp: boolean + /** Deprecation and intervention reports from the browser. */ + readonly browserReports: boolean +} + +/** Repeats of one report (a blocked image in a loop) are sent once per page, up to this many kinds. */ +const MAX_REPORTS = 50 + +/** The report fields this module reads, parsed out of a report body or a DOM event. */ +interface ReportFields { + readonly effectiveDirective?: string | undefined + readonly blockedURL?: string | undefined + readonly disposition?: string | undefined + readonly sourceFile?: string | undefined + readonly lineNumber?: number | undefined + readonly columnNumber?: number | undefined + readonly id?: string | undefined + readonly message?: string | undefined +} + +/** Report bodies expose their fields as getters that only `toJSON` serializes. */ +function parseReportBody(body: unknown): ReportFields { + const raw: unknown = body === null || body === undefined ? undefined : JSON.parse(JSON.stringify(body)) + const fields = new Map(typeof raw === "object" && raw !== null ? Object.entries(raw) : []) + const str = (key: string): string | undefined => { + const value = fields.get(key) + return typeof value === "string" && value !== "" ? value : undefined + } + const int = (key: string): number | undefined => { + const value = fields.get(key) + return typeof value === "number" && value > 0 ? value : undefined + } + return { + effectiveDirective: str("effectiveDirective") ?? str("violatedDirective"), + blockedURL: str("blockedURL") ?? str("blockedURI"), + disposition: str("disposition"), + sourceFile: str("sourceFile"), + lineNumber: int("lineNumber"), + columnNumber: int("columnNumber"), + id: str("id"), + message: str("message"), + } +} + +function sourceAttributes(fields: ReportFields): Record { + return { + ...(fields.sourceFile ? { "code.file.path": scrubUrl(fields.sourceFile) } : undefined), + ...(fields.lineNumber ? { "code.line.number": fields.lineNumber } : undefined), + ...(fields.columnNumber ? { "code.column.number": fields.columnNumber } : undefined), + } +} + +export function startReports(options: ReportOptions): () => void { + if (!options.csp && !options.browserReports) return () => {} + const seen = new Set() + const once = (key: string): boolean => { + if (seen.has(key) || seen.size >= MAX_REPORTS) return false + seen.add(key) + return true + } + + const csp = (fields: ReportFields): void => { + const directive = fields.effectiveDirective ?? "unknown" + const blocked = scrubUrl(fields.blockedURL ?? "inline") + if (!once(`csp ${directive} ${blocked}`)) return + emitLog({ + eventName: "maple.browser.csp_violation", + severityNumber: Severity.WARN, + severityText: "WARN", + body: `${directive} blocked ${blocked}`, + attributes: { + "maple.csp.effective_directive": directive, + "maple.csp.blocked_uri": blocked, + ...(fields.disposition ? { "maple.csp.disposition": fields.disposition } : undefined), + "url.full": scrubUrl(location.href), + ...sourceAttributes(fields), + }, + }) + } + + const browserReport = (type: string, fields: ReportFields): void => { + const id = fields.id ?? "unknown" + if (!once(`${type} ${id}`)) return + emitLog({ + eventName: "maple.browser.report", + severityNumber: Severity.WARN, + severityText: "WARN", + body: fields.message ?? `${type}: ${id}`, + attributes: { "maple.report.type": type, "maple.report.id": id, ...sourceAttributes(fields) }, + }) + } + + if (typeof ReportingObserver === "function") { + const types = [ + ...(options.csp ? ["csp-violation"] : []), + ...(options.browserReports ? ["deprecation", "intervention"] : []), + ] + // Buffered: reports from before this chunk loaded are delivered too. + const observer = new ReportingObserver( + (reports) => { + for (const report of reports) { + const fields = parseReportBody(report.body) + if (report.type === "csp-violation") csp(fields) + else browserReport(report.type ?? "report", fields) + } + }, + { types, buffered: true }, + ) + observer.observe() + return () => observer.disconnect() + } + if (!options.csp) return () => {} + // No ReportingObserver: CSP violations still fire as a DOM event. + const onViolation = (event: SecurityPolicyViolationEvent): void => + csp({ + effectiveDirective: event.effectiveDirective, + blockedURL: event.blockedURI || undefined, + disposition: event.disposition, + sourceFile: event.sourceFile, + lineNumber: event.lineNumber, + columnNumber: event.columnNumber, + }) + document.addEventListener("securitypolicyviolation", onViolation) + return () => document.removeEventListener("securitypolicyviolation", onViolation) +} diff --git a/packages/browser/src/error-filters.ts b/packages/browser/src/error-filters.ts index 77ecd8390..7621ef7fa 100644 --- a/packages/browser/src/error-filters.ts +++ b/packages/browser/src/error-filters.ts @@ -1,6 +1,8 @@ // Client-side error filtering: runs before an error span exists, so a dropped // error costs nothing and never reaches an issue. +import type { HttpStatusRange } from "./http-status" + export type ErrorSource = "captureException" | "window.onerror" | "unhandledrejection" export interface ErrorFilterHint { @@ -19,6 +21,12 @@ export interface ErrorFilterOptions { readonly denyUrls?: ReadonlyArray /** Return `false` to drop the error. Runs after the lists; if it throws, the error is kept. */ readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean + /** + * HTTP response statuses that make a `fetch`/XHR 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. + */ + readonly captureHttpStatus?: ReadonlyArray /** * Drop errors thrown from browser extensions and the benign `ResizeObserver * loop` notices. Default true. diff --git a/packages/browser/src/http-status.test.ts b/packages/browser/src/http-status.test.ts index 67873f60b..ef26e9816 100644 --- a/packages/browser/src/http-status.test.ts +++ b/packages/browser/src/http-status.test.ts @@ -9,16 +9,19 @@ import { describe, expect, it } from "vitest" import { HttpStatusExporter } from "./http-status" const exported = new InMemorySpanExporter() -const tracer = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new HttpStatusExporter(exported))], -}).getTracer("test") +const tracerWith = (captureStatus?: ConstructorParameters[1]) => + new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new HttpStatusExporter(exported, captureStatus))], + }).getTracer("test") +const tracer = tracerWith() const finish = ( build: (span: ReturnType) => void, kind = SpanKind.CLIENT, + using = tracer, ): ReadableSpan => { exported.reset() - const span = tracer.startSpan("GET", { kind }) + const span = using.startSpan("GET", { kind }) build(span) span.end() const [result] = exported.getFinishedSpans() @@ -60,3 +63,46 @@ describe("HttpStatusExporter", () => { expect(internal.status.code).toBe(SpanStatusCode.ERROR) }) }) + +describe("errors.captureHttpStatus", () => { + const capturing = 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, + ) + expect(limited.attributes["error.type"]).toBe("429") + + const notFound = finish( + (s) => { + s.setAttribute("http.response.status_code", 404) + s.setAttribute("error.type", "404") + s.setStatus({ code: SpanStatusCode.ERROR }) + }, + SpanKind.CLIENT, + capturing, + ) + expect(notFound.status.code).toBe(SpanStatusCode.UNSET) + }) +}) diff --git a/packages/browser/src/http-status.ts b/packages/browser/src/http-status.ts index cb6fe6ec3..01a1aec07 100644 --- a/packages/browser/src/http-status.ts +++ b/packages/browser/src/http-status.ts @@ -1,12 +1,36 @@ // One rule for when an HTTP client span is an error, whichever instrumentation // made it. The fetch instrumentation leaves 4xx/5xx responses Unset; the XHR // one marks every status >= 400 Error, and every Error span becomes an issue. -// A response status alone is not an error here; a network failure still is. -import { type Attributes, SpanKind, SpanStatusCode } from "@opentelemetry/api" +// A response status is an error only when the app lists it in +// `errors.captureHttpStatus`; a network failure always is. +import { type Attributes, type SpanStatus, SpanKind, SpanStatusCode } from "@opentelemetry/api" import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base" const HTTP_STATUS = /^\d{3}$/ +/** A status code, or an inclusive `[from, to]` range. */ +export type HttpStatusRange = number | readonly [number, number] + +const statusOf = (span: ReadableSpan): number | undefined => { + const value = span.attributes["http.response.status_code"] ?? span.attributes["http.status_code"] + return typeof value === "number" ? value : undefined +} + +const inRanges = (status: number, ranges: ReadonlyArray): boolean => + ranges.some((range) => + typeof range === "number" ? range === status : status >= range[0] && status <= range[1], + ) + +/** `GET https://api.example.com/users/42 -> 500`, without the query: ids are redacted by the issue fingerprint. */ +function failureMessage(span: ReadableSpan, status: number): string { + const method = span.attributes["http.request.method"] ?? span.attributes["http.method"] ?? "GET" + const url = String(span.attributes["url.full"] ?? span.attributes["http.url"] ?? "").replace( + /[?#].*$/, + "", + ) + return `${String(method)} ${url} -> ${status}` +} + /** An Error set only because of the response status: `error.type` is the status code itself. */ function isStatusOnlyError(span: ReadableSpan): boolean { return ( @@ -17,8 +41,7 @@ function isStatusOnlyError(span: ReadableSpan): boolean { ) } -function withoutStatusError(span: ReadableSpan): ReadableSpan { - const { "error.type": _errorType, ...attributes } = span.attributes +function withStatus(span: ReadableSpan, status: SpanStatus, attributes: Attributes): ReadableSpan { return { name: span.name, kind: span.kind, @@ -26,8 +49,8 @@ function withoutStatusError(span: ReadableSpan): ReadableSpan { parentSpanContext: span.parentSpanContext, startTime: span.startTime, endTime: span.endTime, - status: { code: SpanStatusCode.UNSET }, - attributes: attributes satisfies Attributes, + status, + attributes, links: span.links, events: span.events, duration: span.duration, @@ -41,11 +64,33 @@ function withoutStatusError(span: ReadableSpan): ReadableSpan { } export class HttpStatusExporter implements SpanExporter { - constructor(private readonly inner: SpanExporter) {} + constructor( + private readonly inner: SpanExporter, + private readonly captureStatus: ReadonlyArray = [], + ) {} + + private apply(span: ReadableSpan): ReadableSpan { + const status = statusOf(span) + if (span.kind === SpanKind.CLIENT && status !== undefined && inRanges(status, this.captureStatus)) { + if (span.events.some((event) => event.name === "exception")) return span + return withStatus( + span, + { code: SpanStatusCode.ERROR }, + { + ...span.attributes, + "error.type": String(status), + "error.message": failureMessage(span, status), + }, + ) + } + if (!isStatusOnlyError(span)) return span + const { "error.type": _errorType, ...attributes } = span.attributes + return withStatus(span, { code: SpanStatusCode.UNSET }, attributes) + } export(spans: ReadableSpan[], callback: (result: { code: number; error?: Error }) => void): void { this.inner.export( - spans.map((span) => (isStatusOnlyError(span) ? withoutStatusError(span) : span)), + spans.map((span) => this.apply(span)), callback, ) } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e46fe8954..582d8e2df 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -15,6 +15,7 @@ export type { export type { ConsoleLevel, MapleBrowserConfig } from "./config" export type { ErrorFilterHint, ErrorFilterOptions, ErrorSource } from "./error-filters" export type { CaptureExceptionOptions } from "./errors" +export type { HttpStatusRange } from "./http-status" export type { MapleBrowserHandle } from "./init" export type { LogAttributeValue } from "./logs" export type { MapleLogger } from "./logger" diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index f2cb13ae9..5f0f38e79 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -50,6 +50,8 @@ const CONFIG = { webVitals: false, breadcrumbs: false, captureConsole: [], + reportCsp: false, + reportBrowser: false, sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 5acf5ddae..7506dfe01 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -59,6 +59,8 @@ const CONFIG = { webVitals: false, breadcrumbs: false, captureConsole: [], + reportCsp: false, + reportBrowser: false, sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index b41a982c8..b23794ae2 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -172,6 +172,7 @@ export function setupTracing(config: ResolvedConfig): () => Promise { // cannot set `user-agent`, so ingest reads the SDK from the latter. headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }), }), + config.errorFilters.captureHttpStatus, ), )