Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 181 additions & 28 deletions apps/landing/src/content/docs/session-replay/browser-sdk.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 12 additions & 10 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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({
Expand Down
5 changes: 3 additions & 2 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
89 changes: 31 additions & 58 deletions packages/browser/src/http-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,92 +29,65 @@ 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)
s.setAttribute("error.type", "404")
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()
})
})
28 changes: 7 additions & 21 deletions packages/browser/src/http-status.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<HttpStatusRange> = [],
private readonly captureStatus: ReadonlyArray<HttpStatusRange> = DEFAULT_ERROR_STATUS,
) {}

private apply(span: ReadableSpan): ReadableSpan {
Expand All @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion packages/browser/src/tracing.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
13 changes: 10 additions & 3 deletions packages/browser/src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,12 @@ export function setupTracing(config: ResolvedConfig): () => Promise<void> {
)
} 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)
}
Expand Down Expand Up @@ -336,6 +337,12 @@ export function setupTracing(config: ResolvedConfig): () => Promise<void> {
}
}

/** `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, "\\$&")
}
2 changes: 1 addition & 1 deletion packages/browser/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion packages/effect-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/effect-sdk/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk-core/src/error-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpStatusRange>
/**
Expand Down
24 changes: 12 additions & 12 deletions packages/sdk-core/src/http-status.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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<HttpStatusRange> = [[400, 599]]

export const inStatusRanges = (status: number, ranges: ReadonlyArray<HttpStatusRange>): boolean =>
ranges.some((range) =>
typeof range === "number" ? range === status : status >= range[0] && status <= range[1],
Expand All @@ -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) }
}
Loading
Loading