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
22 changes: 22 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Every field accepted by `MapleBrowser.init`:
| `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). |
| `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). |
| `tracing.captureHeaders` | `{ request?, response? }` | none | Header names recorded on `fetch`/XHR spans as `http.request.header.<name>` / `http.response.header.<name>`. See [Request and response detail](#request-and-response-detail). |
| `replay.canvasFps` | `number` | off | Record `<canvas>` content at this many frames per second. |
| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep text request/response bodies of these URLs on replay network events. |
Expand Down Expand Up @@ -498,6 +500,26 @@ Sampled traces carry the W3C `tracestate` threshold (`ot=th:…`), so Maple weig
inverse of the rate and request counts stay realistic. A trace joined from a server-rendered
`traceparent` follows the server's decision instead.

## Jank

Two opt-in span sources show where the main thread got stuck:

```ts
tracing: { longFrames: true, slowInteractions: true }
```

- `longFrames` spans every frame of 100ms or more as `longAnimationFrame`, with the script that ran
longest as `code.file.path` / `code.function.name`, its `maple.browser.script.invoker` (e.g.
`BUTTON#save.onclick`) and `maple.browser.script.duration_ms`, plus
`maple.browser.frame.blocking_duration_ms`. Browsers without the Long Animation Frames API report
`longtask` spans instead, without script attribution.
- `slowInteractions` spans every interaction of 200ms or more (INP's "needs improvement" line) as
`interaction <event>`, named after the event whose handlers ran longest, with
`maple.browser.interaction.input_delay_ms`, `processing_ms`, `presentation_ms` and `target`.

Both nest under the open navigation span when there is one, include what happened before the SDK
finished loading, and follow `tracing.sampleRate`.

## Request and response detail

Headers go on the spans, as the HTTP semantic conventions define them. List the ones you want:
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-session/src/capture/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function installInteractionCapture(emit: Emit, maskAllText: boolean): ()
}

/** A short, human-readable selector: tag + #id + .first-class. */
function selectorOf(el: Element): string {
export function selectorOf(el: Element): string {
const tag = el.tagName.toLowerCase()
const id = el.id ? `#${el.id}` : ""
const cls =
Expand Down
1 change: 1 addition & 0 deletions packages/browser-session/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ export {
warnIfKeylessMapleIngest,
} from "./platform/region"
export { redactUrl, scrubUrl } from "./platform/url-privacy"
export { selectorOf } from "./capture/interactions"
7 changes: 4 additions & 3 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ const BUDGET = {
*/
eager: 44,
/**
* Every page load, after `init()`: the OTel logs SDK and exporter, document
* timing, and `web-vitals` (~3.3 kB, 8 -> 12).
* Every page load, after `init()`, off the critical path: the OTel logs SDK
* and exporter, document timing, `web-vitals` (~3.3 kB), breadcrumbs,
* reports, the offline queue and long-frame/interaction spans.
*/
deferred: 12,
deferred: 14,
lazy: 68,
/**
* Our own eager code, with OpenTelemetry and rrweb left external.
Expand Down
11 changes: 11 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export interface MapleBrowserConfig {
readonly request?: ReadonlyArray<string>
readonly response?: ReadonlyArray<string>
}
/**
* Span main-thread frames of 100ms or more (`longAnimationFrame`, with the
* script that ran longest; `longtask` where that API is missing). Default false.
*/
readonly longFrames?: boolean
/** Span interactions of 200ms or more (`interaction click`, ...), split into input delay, processing and presentation. Default false. */
readonly slowInteractions?: boolean
}
/**
* Report Core Web Vitals (LCP, CLS, INP, FCP, TTFB) as `browser.web_vital`
Expand Down Expand Up @@ -228,6 +235,8 @@ export interface ResolvedConfig {
| { readonly urls: ReadonlyArray<string | RegExp>; readonly maxLength: number }
| undefined
readonly captureHeaders: HeaderCapture
readonly longFrames: boolean
readonly slowInteractions: boolean
readonly maskAllInputs: boolean
readonly maskAllText: boolean
readonly persistVisitorId: boolean
Expand Down Expand Up @@ -310,6 +319,8 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
}
: undefined,
captureHeaders: resolveHeaderCapture(config.tracing?.captureHeaders),
longFrames: config.tracing?.longFrames ?? false,
slowInteractions: config.tracing?.slowInteractions ?? false,
replayOnErrorSampleRate:
config.replay?.onErrorSampleRate === undefined
? 0
Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs"
import { recordDocumentTiming } from "./document-timing"
import { startLogs } from "./logs"
import { startOfflineQueue } from "./offline"
import { startPerf } from "./perf"
import { startReports } from "./reports"
import { startWebVitals } from "./web-vitals"

Expand All @@ -26,6 +27,7 @@ export function startDeferred(config: ResolvedConfig): () => Promise<void> {
})
const stopErrorListener = onErrorRecorded(flushBreadcrumbs)
const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser })
const stopPerf = startPerf({ longFrames: config.longFrames, slowInteractions: config.slowInteractions })
const offline = config.offlineQueue ? startOfflineQueue(config) : undefined
attachSpanStash(offline?.stashSpans)
const stops = [
Expand All @@ -38,6 +40,7 @@ export function startDeferred(config: ResolvedConfig): () => Promise<void> {
stopReports()
attachSpanStash(undefined)
offline?.stop()
stopPerf()
},
]
return async () => {
Expand Down
155 changes: 155 additions & 0 deletions packages/browser/src/deferred/perf.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
import { userEvent } from "vitest/browser"
import { afterEach, describe, expect, it, vi } from "vitest"

const exported: ReadableSpan[] = []
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
OTLPTraceExporter: class {
export(spans: ReadableSpan[], callback: (result: { code: number }) => void): void {
exported.push(...spans)
callback({ code: 0 })
}
forceFlush(): Promise<void> {
return Promise.resolve()
}
shutdown(): Promise<void> {
return Promise.resolve()
}
},
}))

const { MapleBrowser } = await import("../index")
const { onLongFrame } = await import("./perf")

class ScriptTimingStub {
constructor(
private readonly values: {
duration: number
invoker: string
sourceURL: string
sourceFunctionName: string
},
) {}
get duration(): number {
return this.values.duration
}
get invoker(): string {
return this.values.invoker
}
get sourceURL(): string {
return this.values.sourceURL
}
get sourceFunctionName(): string {
return this.values.sourceFunctionName
}
}
const scriptTiming = (duration: number, invoker: string, sourceURL: string, sourceFunctionName = "") =>
new ScriptTimingStub({ duration, invoker, sourceURL, sourceFunctionName })

const busy = (ms: number): void => {
const until = performance.now() + ms
while (performance.now() < until) {
// Block the main thread, like a slow handler would.
}
}

let handle: ReturnType<typeof MapleBrowser.init> | undefined
afterEach(async () => {
await handle?.shutdown()
handle = undefined
vi.restoreAllMocks()
exported.length = 0
document.body.replaceChildren()
vi.unstubAllGlobals()
})

const init = async (): Promise<void> => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}")),
)
handle = MapleBrowser.init({
ingestKey: "k",
serviceName: "web",
endpoint: "https://ingest.test",
replay: { enabled: false },
webVitals: false,
breadcrumbs: false,
tracing: { instrumentFetch: false, instrumentXhr: false, longFrames: true, slowInteractions: true },
})
await import("./index")
await new Promise((resolve) => setTimeout(resolve, 0))
}

const stop = async (): Promise<void> => {
await handle?.shutdown()
handle = undefined
}

describe("slow interactions", () => {
it("spans a slow click once, named after the event whose handler ran long", async () => {
await init()
const button = document.createElement("button")
button.id = "save"
button.textContent = "Save"
button.addEventListener("click", () => busy(250))
document.body.append(button)
await userEvent.click(button)
// Event timing entries are delivered after the next paint.
await new Promise((resolve) => setTimeout(resolve, 500))
await stop()

const interactions = exported.filter((span) => span.name.startsWith("interaction "))
expect(interactions.map((span) => span.name)).toEqual(["interaction click"])
expect(interactions[0]?.attributes["maple.browser.interaction.target"]).toBe("button#save")
expect(
Number(interactions[0]?.attributes["maple.browser.interaction.processing_ms"]),
).toBeGreaterThanOrEqual(200)
})
})

describe("long frames", () => {
it("falls back to long tasks where Long Animation Frames are missing", async () => {
const supported = PerformanceObserver.supportedEntryTypes.filter(
(type) => type !== "long-animation-frame",
)
vi.spyOn(PerformanceObserver, "supportedEntryTypes", "get").mockReturnValue(supported)
await init()
busy(150)
await new Promise((resolve) => setTimeout(resolve, 300))
await stop()
expect(exported.map((span) => span.name)).toContain("longtask")
})

it("names the longest script of a long animation frame", async () => {
await init()
const entry = {
name: "long-animation-frame",
entryType: "long-animation-frame",
startTime: 100,
duration: 180,
toJSON: () => ({}),
blockingDuration: 130,
// Real PerformanceScriptTiming fields are prototype getters, not own properties.
scripts: [
scriptTiming(20, "a", "https://app.test/a.js"),
scriptTiming(150, "BUTTON#save.onclick", "https://app.test/checkout.js?token=x", "submit"),
],
}
onLongFrame(entry)
await stop()
// Buffered real frames (from earlier busy loops) may be reported too; find this one.
const frame = exported.find(
(span) =>
span.name === "longAnimationFrame" && span.attributes["code.function.name"] === "submit",
)
expect(frame?.attributes).toMatchObject({
"maple.browser.frame.blocking_duration_ms": 130,
"code.file.path": "https://app.test/checkout.js?token=REDACTED",
"code.function.name": "submit",
"maple.browser.script.invoker": "BUTTON#save.onclick",
"maple.browser.script.duration_ms": 150,
})
})
})
Loading
Loading