Skip to content
Closed
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
20 changes: 20 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,26 @@ 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`.

## User feedback

`MapleBrowser.sendFeedback` records what a user tells you, from your own feedback form:

```ts
MapleBrowser.sendFeedback({
message: form.message, // required
email: form.email, // optional; dropped when privacy.captureUserEmail is false
name: form.name, // optional
attributes: { "feedback.category": "bug" },
})
```

It is sent as a `maple.user_feedback` OpenTelemetry log event carrying `session.id`, `user.email` /
`user.name`, `maple.feedback.has_replay` and, when the user hit an error earlier in the page,
`maple.feedback.error_trace_id`. The event is linked to that error's span, so it shows up on the
error's trace. Sending feedback also keeps a buffered replay (see
[Replay on error](#replay-on-error)), since a user reporting a problem is as good a signal as an
error. It returns `false` when nothing was sent (an empty message, or no consent).

## Browser reports

Content Security Policy violations are reported as `maple.browser.csp_violation` WARN log events with
Expand Down
9 changes: 9 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ Calls before `init()` are queued.
MapleBrowser.logger.info("checkout started", { "cart.items": 3 })
```

## User feedback

```ts
MapleBrowser.sendFeedback({ message: "The pay button does nothing", email: user.email })
```

Headless (bring your own form). Sent as a `maple.user_feedback` log event linked to the session and
the last error the user hit, and keeps a buffered replay like an error does.

## Web Vitals

LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry log events
Expand Down
9 changes: 5 additions & 4 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import { gzipSync } from "node:zlib"
/** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */
const BUDGET = {
/**
* 43 since 2026-09: XHR spans and the HTTP status policy, which must patch
* 43.5 since 2026-09: `sendFeedback` (~0.3 kB). 43: XHR spans and the HTTP status policy, which must patch
* before the app's first request (~1.5 kB). Document timing went to the
* deferred chunk instead. 42: error filters and cause chains. 41 before that:
* per-session trace sampling and the `logger` queue added ~2.4 kB (~1.2 kB
* code, the rest chunk-split overhead now that a second chunk shares the OTel
* core). Was 38 for navigation spans.
*/
eager: 43,
eager: 43.5,
/**
* Every page load, after `init()`: the OTel logs SDK and exporter, document
* timing, and `web-vitals` (~3.3 kB, 8 -> 12).
Expand Down Expand Up @@ -63,9 +63,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. 17.5
* for `errors.captureHttpStatus`, applied by the span exporter.
* for `errors.captureHttpStatus`, applied by the span exporter. 18 for the
* `sendFeedback` API.
*/
firstParty: 17.5,
firstParty: 18,
}

/** How close to a ceiling counts as worth warning about. */
Expand Down
135 changes: 135 additions & 0 deletions packages/browser/src/feedback.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
import type { ReadableLogRecord } from "@opentelemetry/sdk-logs"
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

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

const { MapleBrowser } = await import("./index")
const { resetFeedbackForTests } = await import("./feedback")
const { resetReportedErrorsForTests } = await import("./errors")

type InitConfig = Parameters<typeof MapleBrowser.init>[0]
const BASE: InitConfig = {
ingestKey: "k",
serviceName: "web",
endpoint: "https://ingest.test",
replay: { enabled: false },
tracing: { instrumentFetch: false, instrumentXhr: false },
webVitals: false,
breadcrumbs: false,
}

let handle: ReturnType<typeof MapleBrowser.init> | undefined
const stop = async (): Promise<void> => {
await handle?.shutdown()
handle = undefined
}

beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}")),
)
})

afterEach(async () => {
await stop()
exportedSpans.length = 0
exportedLogs.length = 0
resetFeedbackForTests()
resetReportedErrorsForTests()
vi.unstubAllGlobals()
})

const feedback = () => exportedLogs.filter((log) => log.eventName === "maple.user_feedback")

describe("MapleBrowser.sendFeedback", () => {
it("sends a feedback event linked to the last error the user hit", async () => {
handle = MapleBrowser.init(BASE)
MapleBrowser.captureException(new Error("checkout failed"))
expect(
MapleBrowser.sendFeedback({
message: " the pay button does nothing ",
email: "ada@example.com",
name: "Ada",
attributes: { "feedback.category": "bug" },
}),
).toBe(true)
await stop()

const error = exportedSpans.find((span) => span.name === "exception")
const [sent] = feedback()
expect(sent?.body).toBe("the pay button does nothing")
expect(sent?.attributes["user.email"]).toBe("ada@example.com")
expect(sent?.attributes["user.name"]).toBe("Ada")
expect(sent?.attributes["feedback.category"]).toBe("bug")
expect(sent?.attributes["maple.feedback.error_trace_id"]).toBe(error?.spanContext().traceId)
expect(sent?.spanContext?.spanId).toBe(error?.spanContext().spanId)
expect(typeof sent?.attributes["session.id"]).toBe("string")
})

it("does not link feedback to an error from before a shutdown and re-init", async () => {
handle = MapleBrowser.init(BASE)
MapleBrowser.captureException(new Error("old page state"))
await stop()
handle = MapleBrowser.init(BASE)
MapleBrowser.sendFeedback({ message: "still broken" })
await stop()
expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined()
})

it("keeps the email out when captureUserEmail is off, and sends nothing without a message", async () => {
handle = MapleBrowser.init({ ...BASE, privacy: { captureUserEmail: false } })
expect(MapleBrowser.sendFeedback({ message: " " })).toBe(false)
MapleBrowser.sendFeedback({ message: "slow", email: "ada@example.com" })
await stop()
expect(feedback()).toHaveLength(1)
expect(feedback()[0]?.attributes["user.email"]).toBeUndefined()
expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined()
})

it("reports has_replay for the buffered replay the feedback itself keeps", async () => {
// Headless Chromium's user agent is classified as a bot, which never gets replay.
Object.defineProperty(navigator, "userAgent", {
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
configurable: true,
})
const urls: string[] = []
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
urls.push(String(input))
return new Response("{}")
}),
)
try {
handle = MapleBrowser.init({ ...BASE, replay: { sampleRate: 0, onErrorSampleRate: 1 } })
await vi.waitFor(() =>
expect(urls.some((url) => url.endsWith("/v1/sessionReplays/meta"))).toBe(true),
)
MapleBrowser.sendFeedback({ message: "it froze" })
await stop()
expect(feedback()[0]?.attributes["maple.feedback.has_replay"]).toBe(true)
} finally {
Reflect.deleteProperty(navigator, "userAgent")
sessionStorage.clear()
}
})
})
74 changes: 74 additions & 0 deletions packages/browser/src/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// End-user feedback ("this page is broken"), as an OTel log event linked to
// the session and to the last error the user hit. Headless: bring your own UI.
import { getSession, hasConsent } from "@maple/browser-session"
import type { SpanContext } from "@opentelemetry/api"
import { onErrorRecorded } from "./errors"
import { emitLog, type LogAttributeValue, Severity } from "./logs"

export interface FeedbackInput {
/** What the user wrote. Required; trimmed and capped at 5,000 characters. */
readonly message: string
/** Sent as `user.email` unless `privacy.captureUserEmail` is false. */
readonly email?: string | undefined
/** Sent as `user.name`. */
readonly name?: string | undefined
/** Extra attributes, e.g. `{ "feedback.category": "bug" }`. */
readonly attributes?: Readonly<Record<string, LogAttributeValue>> | undefined
}

const MAX_MESSAGE = 5_000

let lastError: SpanContext | undefined
let captureEmail = true
/** Keeps a buffered replay; set by `init()`. */
let keepReplay: () => void = () => {}

// Module-level: the last error is whatever the user saw most recently, whichever `init()` recorded it.
onErrorRecorded((spanContext) => {
lastError = spanContext
})

export function configureFeedback(options: {
readonly captureUserEmail: boolean
readonly keepReplay: () => void
}): void {
captureEmail = options.captureUserEmail
keepReplay = options.keepReplay
// init() and shutdown() both start a new lifecycle: an earlier error's trace is not this one's.
lastError = undefined
}

export function resetFeedbackForTests(): void {
lastError = undefined
captureEmail = true
keepReplay = () => {}
}

/** Send feedback. Returns false when there was nothing to send (empty message, no consent). */
export function sendFeedback(input: FeedbackInput): boolean {
const message = String(input.message ?? "")
.trim()
.slice(0, MAX_MESSAGE)
if (!message || !hasConsent()) return false
const email = captureEmail ? input.email?.trim() : undefined
const name = input.name?.trim()
// Keep a buffered replay first: the trigger marks the session recorded synchronously, so
// `has_replay` then reflects the minute this feedback just kept.
keepReplay()
const recorded = typeof window === "undefined" ? false : getSession().replaySampled === true
Comment thread
Makisuo marked this conversation as resolved.
emitLog({
eventName: "maple.user_feedback",
severityNumber: Severity.INFO,
severityText: "INFO",
body: message,
spanContext: lastError,
attributes: {
...input.attributes,
...(email ? { "user.email": email } : undefined),
...(name ? { "user.name": name } : undefined),
...(lastError ? { "maple.feedback.error_trace_id": lastError.traceId } : undefined),
"maple.feedback.has_replay": recorded,
},
})
return true
}
10 changes: 10 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/b
import type { MapleBrowserConfig } from "./config"
import { type CaptureExceptionOptions, captureException } from "./errors"
import { identify, init, type MapleBrowserHandle } from "./init"
import { type FeedbackInput, sendFeedback } from "./feedback"
import { type MapleLogger, logger } from "./logger"
import { endNavigation, startNavigation, type TracedOptions, traced } from "./navigation"

Expand All @@ -19,6 +20,7 @@ export type { HttpStatusRange } from "./http-status"
export type { MapleBrowserHandle } from "./init"
export type { LogAttributeValue } from "./logs"
export type { MapleLogger } from "./logger"
export type { FeedbackInput } from "./feedback"
export type { TracedOptions } from "./navigation"

/** The `MapleBrowser` namespace object. */
Expand Down Expand Up @@ -62,6 +64,13 @@ export interface MapleBrowserApi {
* span and the session. Safe before `init`: records queue until it runs.
*/
logger: MapleLogger
/**
* Send end-user feedback, linked to the session, its replay, and the last
* error the user hit. Headless: call it from your own form. Also keeps a
* buffered replay (`replay.onErrorSampleRate`). Returns false when nothing
* was sent (empty message, no consent).
*/
sendFeedback: (input: FeedbackInput) => boolean
}

/**
Expand Down Expand Up @@ -92,4 +101,5 @@ export const MapleBrowser: MapleBrowserApi = {
endNavigation,
traced,
logger,
sendFeedback,
}
10 changes: 7 additions & 3 deletions packages/browser/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { trace } from "@opentelemetry/api"
import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config"
import { configureErrorFilters } from "./error-filters"
import { onErrorRecorded, setupErrorCapture } from "./errors"
import { configureFeedback } from "./feedback"
import { setLogIdentity } from "./logs"
import { resetNavigation } from "./navigation"
import { setupTracing } from "./tracing"
Expand Down Expand Up @@ -214,10 +215,12 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle {
await Promise.all([replayShutdown, metadataShutdown, previous.replayPending])
}

// A buffered replay keeps itself the moment an error is recorded.
const stopReplayTrigger = onErrorRecorded(() => {
// A buffered replay keeps itself the moment an error is recorded, or the user sends feedback.
const keepReplay = (): void => {
void runtime?.replay?.trigger()
})
}
const stopReplayTrigger = onErrorRecorded(keepReplay)
configureFeedback({ captureUserEmail: config.captureUserEmail, keepReplay })
startRuntime()
const stopConsentListener = config.requireConsent
? onConsentChange((allowed) => {
Expand All @@ -243,6 +246,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle {
stopped = true
stopConsentListener()
stopReplayTrigger()
configureFeedback({ captureUserEmail: true, keepReplay: () => {} })
await stopRuntime(true)
stopErrorCapture?.()
stopErrorCapture = undefined
Expand Down
Loading