From 338c1b5e70addb092fb76b15fdac044d827fd10f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:35:00 +0200 Subject: [PATCH 1/3] feat(browser): headless user feedback API MapleBrowser.sendFeedback({ message, email?, name?, attributes? }) sends a maple.user_feedback OTel log event: session.id, user.email / user.name (semconv; the email is dropped when privacy.captureUserEmail is false), 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 on the error's trace. Feedback also keeps a buffered replay, the same way an error does: a user reporting a problem is as good a signal. Headless for now; a drop-in widget can come as its own entry so it never touches the eager bundle. Eager budget 43 -> 43.5 kB, first-party 17.5 -> 18 kB. --- docs/browser-sdk.md | 20 ++++ packages/browser/README.md | 9 ++ packages/browser/scripts/size.ts | 9 +- packages/browser/src/feedback.browser.test.ts | 97 +++++++++++++++++++ packages/browser/src/feedback.ts | 71 ++++++++++++++ packages/browser/src/index.ts | 10 ++ packages/browser/src/init.ts | 10 +- 7 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 packages/browser/src/feedback.browser.test.ts create mode 100644 packages/browser/src/feedback.ts diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 2429b849d..98aba872b 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -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 diff --git a/packages/browser/README.md b/packages/browser/README.md index c1596c640..e72b51241 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -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 diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index 9ed05a7f6..d96ca417d 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -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). @@ -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. */ diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts new file mode 100644 index 000000000..1fb4a3328 --- /dev/null +++ b/packages/browser/src/feedback.browser.test.ts @@ -0,0 +1,97 @@ +// 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 = (sink: T[]) => + class { + export(items: T[], callback: (result: { code: number }) => void): void { + sink.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + 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[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 | undefined +const stop = async (): Promise => { + 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("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() + }) +}) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts new file mode 100644 index 000000000..79f0e3752 --- /dev/null +++ b/packages/browser/src/feedback.ts @@ -0,0 +1,71 @@ +// 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> | 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 +} + +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() + const recorded = typeof window === "undefined" ? false : getSession().replaySampled === true + 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, + }, + }) + // Feedback is a user saying something went wrong: worth the buffered minute, like an error. + keepReplay() + return true +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 582d8e2df..7b3310587 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -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" @@ -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. */ @@ -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 } /** @@ -92,4 +101,5 @@ export const MapleBrowser: MapleBrowserApi = { endNavigation, traced, logger, + sendFeedback, } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 2f0f7285b..a81949627 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -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" @@ -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) => { @@ -243,6 +246,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopped = true stopConsentListener() stopReplayTrigger() + configureFeedback({ captureUserEmail: true, keepReplay: () => {} }) await stopRuntime(true) stopErrorCapture?.() stopErrorCapture = undefined From 1af65dedb27d5df0f8dd0b6bc81df8f7cd058060 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:15:12 +0200 Subject: [PATCH 2/3] fix(browser): has_replay reflects the buffered replay feedback keeps maple.feedback.has_replay was read before keepReplay() ran, so on the buffered-replay path it was always false for the very feedback that kept the replay. keepReplay() now runs first; the trigger marks the session recorded synchronously. --- packages/browser/src/feedback.browser.test.ts | 28 +++++++++++++++++++ packages/browser/src/feedback.ts | 5 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts index 1fb4a3328..3cb99bd3b 100644 --- a/packages/browser/src/feedback.browser.test.ts +++ b/packages/browser/src/feedback.browser.test.ts @@ -94,4 +94,32 @@ describe("MapleBrowser.sendFeedback", () => { 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() + } + }) }) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts index 79f0e3752..fe53277d4 100644 --- a/packages/browser/src/feedback.ts +++ b/packages/browser/src/feedback.ts @@ -50,6 +50,9 @@ export function sendFeedback(input: FeedbackInput): boolean { 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 emitLog({ eventName: "maple.user_feedback", @@ -65,7 +68,5 @@ export function sendFeedback(input: FeedbackInput): boolean { "maple.feedback.has_replay": recorded, }, }) - // Feedback is a user saying something went wrong: worth the buffered minute, like an error. - keepReplay() return true } From 04e5138e233f97f508094ba3e70fa149b6471e0b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:18:16 +0200 Subject: [PATCH 3/3] fix(browser): forget the last error when the SDK restarts lastError survived shutdown(), so feedback after a re-init linked to a trace from the previous lifecycle. configureFeedback, run by init() and shutdown(), now clears it. --- packages/browser/src/feedback.browser.test.ts | 10 ++++++++++ packages/browser/src/feedback.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts index 3cb99bd3b..f9f0fcb6f 100644 --- a/packages/browser/src/feedback.browser.test.ts +++ b/packages/browser/src/feedback.browser.test.ts @@ -85,6 +85,16 @@ describe("MapleBrowser.sendFeedback", () => { 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) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts index fe53277d4..408021652 100644 --- a/packages/browser/src/feedback.ts +++ b/packages/browser/src/feedback.ts @@ -34,6 +34,8 @@ export function configureFeedback(options: { }): 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 {