From 7600ab19f4d09b70588c90540e47ae87712a2aac Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 8 Jul 2026 01:52:15 +0200 Subject: [PATCH 1/2] fix(effect-sdk): decouple browser replay/session-metadata from ingest key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser client SDK overloaded `ingestKey` presence to mean both "attach the Authorization header" and "is this feature enabled", so leaving `ingestKey` unset (auth handled by a self-hosted proxy) silently disabled session replay and session-metadata even though traces still flowed through the proxy. Make `ingestKey` auth-only: replay/metadata now run regardless of key presence (gated solely by `replay.enabled` / `emitSessionMeta`), and every session/replay POST attaches `Authorization` only when a key is set — otherwise it goes out headerless for a proxy/gateway to complete, matching how OTLP trace export already behaved. `MapleFlush.make` (client) is aligned with `Maple.layer` via an internal `allowKeyless` flag; the env-driven server and Cloudflare presets keep their existing keyless no-op. Co-Authored-By: Claude Opus 4.8 --- lib/effect-sdk/README.md | 24 ++++++- lib/effect-sdk/src/client/flushable.test.ts | 10 ++- lib/effect-sdk/src/client/flushable.ts | 30 +++++--- lib/effect-sdk/src/client/layer.ts | 3 +- lib/effect-sdk/src/client/replay-loader.ts | 8 ++- .../src/client/standalone-session.test.ts | 32 ++++++++- .../src/client/standalone-session.ts | 11 +-- lib/effect-sdk/src/shared/flush-core.ts | 10 ++- packages/browser-session/src/meta-row.test.ts | 69 +++++++++++++++++++ packages/browser-session/src/meta-row.ts | 17 +++-- .../browser-session/src/replay-session.ts | 7 +- .../browser-session/src/replay/transport.ts | 27 ++++---- 12 files changed, 201 insertions(+), 47 deletions(-) create mode 100644 packages/browser-session/src/meta-row.test.ts diff --git a/lib/effect-sdk/README.md b/lib/effect-sdk/README.md index 542b0ebf9..7e864ab93 100644 --- a/lib/effect-sdk/README.md +++ b/lib/effect-sdk/README.md @@ -187,7 +187,7 @@ Both server and client layers accept these options: | ----------------------- | --------------------------------------- | ---------------------------------- | | `serviceName` | Yes | Service name reported in telemetry | | `endpoint` | Server: env or config, Client: required | Maple ingest endpoint URL | -| `ingestKey` | No | Maple ingest key | +| `ingestKey` | No | Maple ingest key — used only for the `Authorization` header (see [Auth via a proxy](#auth-via-a-proxy)) | | `serviceVersion` | No | Override auto-detected commit SHA | | `environment` | No | Override auto-detected environment | | `attributes` | No | Additional resource attributes | @@ -207,6 +207,28 @@ Client-only options: | `replay.maskAllText` | `false` | Mask all text in the recording | | `emitSessionMeta` | `true` | Post session metadata rows so unrecorded sessions still appear in the Sessions UI | +## Auth via a proxy + +`ingestKey` is **auth only** — it just sets the `Authorization: Bearer …` header. It does **not** gate +whether any feature runs. Leave it unset and everything still works: traces, logs, metrics, session +metadata, and replay all POST **without** an `Authorization` header, and whatever sits in front of your +endpoint attaches auth for them. + +This is the pattern for a self-hosted proxy (e.g. to sidestep ad-blockers / privacy browsers): point +`endpoint` at your proxy, leave `ingestKey` unset in browser code, and have the proxy inject the ingest +key server-side. + +```typescript +const TracerLive = Maple.layer({ + serviceName: "my-frontend", + endpoint: "https://telemetry.myapp.com", // your proxy; it adds Authorization + // no ingestKey — the proxy attaches it +}) +``` + +Feature enablement is controlled independently by `replay.enabled` and `emitSessionMeta` (both default +`true`), regardless of whether a key is set. + ## License MIT diff --git a/lib/effect-sdk/src/client/flushable.test.ts b/lib/effect-sdk/src/client/flushable.test.ts index 794679a48..bb853adae 100644 --- a/lib/effect-sdk/src/client/flushable.test.ts +++ b/lib/effect-sdk/src/client/flushable.test.ts @@ -265,7 +265,7 @@ describe("MapleFlush.make (client)", () => { expect(calls.length).toBe(afterAuto) }) - it("runs in no-op mode when no ingest key is configured", async () => { + it("still exports without an Authorization header when no ingest key is set (proxy attaches it)", async () => { const consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {}) const { calls, restore: rf } = setupFetch() restore = () => { @@ -284,9 +284,13 @@ describe("MapleFlush.make (client)", () => { ) await telemetry.flush() - expect(calls.length).toBe(0) + // Ingest key is auth only — the span still POSTs, just without auth. + const traceCall = calls.find((c) => c.url.endsWith("/v1/traces")) + expect(traceCall).toBeDefined() + expect(traceCall!.headers.authorization).toBeUndefined() + // One-shot heads-up that we're running keyless (expecting a proxy). expect(consoleInfoSpy).toHaveBeenCalledTimes(1) - expect(consoleInfoSpy.mock.calls[0][0]).toContain("no ingest key configured") + expect(consoleInfoSpy.mock.calls[0][0]).toContain("no ingest key set") }) it("flushes on visibilitychange only when the document is hidden", async () => { diff --git a/lib/effect-sdk/src/client/flushable.ts b/lib/effect-sdk/src/client/flushable.ts index 80e098de6..b55c8fba9 100644 --- a/lib/effect-sdk/src/client/flushable.ts +++ b/lib/effect-sdk/src/client/flushable.ts @@ -48,7 +48,11 @@ export interface MapleClientFlushableConfig { readonly serviceName: string /** Maple ingest endpoint URL. */ readonly endpoint: string - /** Maple ingest key. When unset, the preset runs in no-op mode. */ + /** + * Maple ingest key, used only for the `Authorization` header. When unset the + * preset still exports, POSTing without an auth header — for setups where a + * proxy/gateway injects it. + */ readonly ingestKey?: string | undefined /** Service version or commit SHA. */ readonly serviceVersion?: string | undefined @@ -87,7 +91,8 @@ export interface MapleClientFlushableConfig { * Post session metadata rows for the standalone session so it appears in * Maple's Sessions UI (list entry + linked traces, no replay recording). * Default `true`; no-ops when `@maple-dev/browser` is on the page (it owns - * the session rows), during SSR, or without an ingest key. + * the session rows) or during SSR. The ingest key is auth only — with no key + * the rows still post, without an `Authorization` header (proxy attaches it). */ readonly emitSessionMeta?: boolean | undefined /** @@ -185,15 +190,23 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => attributes: buildBrowserAttributes(config), }, } + // The ingest key is auth only: with no key we still export, POSTing without + // an `Authorization` header (for setups where a proxy/gateway injects it), + // matching `Maple.layer`. `allowKeyless` keeps `runFlush` from no-op'ing. const resolved: Resolved = buildResolved(resource, { tracesPath: config.tracesPath, logsPath: config.logsPath, userAgent: "maple-effect-sdk-client/0.0.0", + allowKeyless: true, }) + if (!config.ingestKey) { + console.info( + "[MapleClientSDK] no ingest key set — telemetry will POST without an Authorization header (expecting a proxy/gateway to attach it)", + ) + } const tracesState: SignalState = { disabledUntil: 0 } const logsState: SignalState = { disabledUntil: 0 } - let noOpLogged = false const flush = async (): Promise => { await runFlush({ @@ -204,14 +217,9 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => logsState, transport: keepaliveTransport, logPrefix: "[MapleClientSDK]", - onNoOp: () => { - if (!noOpLogged) { - noOpLogged = true - console.info( - "[MapleClientSDK] no ingest key configured — telemetry disabled (pass `ingestKey` to enable)", - ) - } - }, + // `allowKeyless` above keeps `noOp` false, so this never fires; kept to + // satisfy the shared `runFlush` contract. + onNoOp: () => {}, }) } diff --git a/lib/effect-sdk/src/client/layer.ts b/lib/effect-sdk/src/client/layer.ts index ab8d1e508..659ab34fe 100644 --- a/lib/effect-sdk/src/client/layer.ts +++ b/lib/effect-sdk/src/client/layer.ts @@ -28,7 +28,8 @@ export interface MapleClientConfig { * Post session metadata rows for the standalone session so it appears in * Maple's Sessions UI (list entry + linked traces, no replay recording). * Default `true`; no-ops when `@maple-dev/browser` is on the page (it owns - * the session rows), during SSR, or without an ingest key. + * the session rows) or during SSR. The ingest key is auth only — with no key + * the rows still post, without an `Authorization` header (proxy attaches it). */ readonly emitSessionMeta?: boolean | undefined /** diff --git a/lib/effect-sdk/src/client/replay-loader.ts b/lib/effect-sdk/src/client/replay-loader.ts index b2e1d5104..3671ee790 100644 --- a/lib/effect-sdk/src/client/replay-loader.ts +++ b/lib/effect-sdk/src/client/replay-loader.ts @@ -33,12 +33,14 @@ let replayStarted = false * Start the session side of the client SDK: a (sampled) rrweb replay session, * or — when replay is off, unsampled, or impossible — plain session metadata * rows so the session still appears in Maple's Sessions UI with its linked - * traces. No-ops during SSR, without an ingest key, or when - * `@maple-dev/browser` already owns the page's session. + * traces. No-ops during SSR or when `@maple-dev/browser` already owns the page's + * session. The ingest key is auth only — when it's unset the session's POSTs go + * out without an `Authorization` header (for setups where a proxy injects it), + * so replay/metadata run regardless; gate them with `replay.enabled` / + * `emitSessionMeta` instead. */ export const startClientSession = (config: ClientSessionConfig): void => { if (typeof window === "undefined") return - if (!config.ingestKey) return if (readSessionSink()) return // `@maple-dev/browser` owns the session. // Recording needs a real DOM (`document`); metadata rows below don't. diff --git a/lib/effect-sdk/src/client/standalone-session.test.ts b/lib/effect-sdk/src/client/standalone-session.test.ts index ac005e61b..29b1c77b2 100644 --- a/lib/effect-sdk/src/client/standalone-session.test.ts +++ b/lib/effect-sdk/src/client/standalone-session.test.ts @@ -12,6 +12,15 @@ interface MetaPost { readonly url: string readonly row: Record readonly keepalive: boolean | undefined + readonly authorization: string | undefined +} + +const headerGet = (headers: HeadersInit | undefined, name: string): string | undefined => { + if (!headers) return undefined + if (headers instanceof Headers) return headers.get(name) ?? undefined + const entries = Array.isArray(headers) ? headers : Object.entries(headers) + const match = entries.find(([k]) => k.toLowerCase() === name.toLowerCase()) + return match?.[1] } const setupFetch = () => { @@ -20,7 +29,12 @@ const setupFetch = () => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url if (url.includes("/v1/sessionReplays/meta") && typeof init?.body === "string") { - metaPosts.push({ url, row: JSON.parse(init.body.trim()), keepalive: init?.keepalive }) + metaPosts.push({ + url, + row: JSON.parse(init.body.trim()), + keepalive: init?.keepalive, + authorization: headerGet(init?.headers, "authorization"), + }) } return new Response(null, { status: 200 }) }) as typeof fetch @@ -80,6 +94,8 @@ describe("standalone session emission (client)", () => { expect(row.url_initial).toBe("https://app.example.com/dashboard") expect(row.resource_attributes["deployment.environment"]).toBe("test") expect(row.resource_attributes["deployment.commit_sha"]).toBe("abc123") + // Ingest key is present → the row carries the Authorization header. + expect(metaPosts[0].authorization).toBe("Bearer secret") }) it("posts nothing when the @maple-dev/browser sink owns the session", async () => { @@ -98,17 +114,27 @@ describe("standalone session emission (client)", () => { expect(metaPosts.length).toBe(0) }) - it("posts nothing during SSR or without an ingest key", async () => { + it("posts nothing during SSR (no window)", async () => { const { metaPosts, restore: r } = setupFetch() restore = r make(baseConfig) // node: no window + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(metaPosts.length).toBe(0) + }) + + it("posts without an Authorization header when no ingest key is set (proxy attaches it)", async () => { + const { metaPosts, restore: r } = setupFetch() + restore = r stubWindow() + make({ ...baseConfig, ingestKey: undefined }) // window but no key await new Promise((resolve) => setTimeout(resolve, 0)) - expect(metaPosts.length).toBe(0) + expect(metaPosts.length).toBe(1) + expect(metaPosts[0].row.status).toBe("active") + expect(metaPosts[0].authorization).toBeUndefined() }) it("attaches observed trace ids to the ended row and rotates sessions", async () => { diff --git a/lib/effect-sdk/src/client/standalone-session.ts b/lib/effect-sdk/src/client/standalone-session.ts index 76013be8d..c2d920b1d 100644 --- a/lib/effect-sdk/src/client/standalone-session.ts +++ b/lib/effect-sdk/src/client/standalone-session.ts @@ -43,7 +43,7 @@ const post = (status: "active" | "ended", keepalive: boolean): void => { const { sessionId, startedAt, options } = current void postSessionMetaRow( options.endpoint, - options.ingestKey!, + options.ingestKey, buildSessionMetaRow({ sessionId, startedAt, @@ -82,13 +82,14 @@ export const noteStandaloneSpan = (sessionId: string, traceId: string): void => /** * Start posting session metadata rows for the standalone session. No-ops - * outside a browser, without an ingest key, or when the browser SDK's sink is - * already published. Idempotent per page load — the client presets call it on - * construction and tests reset via `resetStandaloneSessionForTests`. + * outside a browser or when the browser SDK's sink is already published. The + * ingest key is auth only — when unset the rows post without an `Authorization` + * header (for setups where a proxy injects it). Idempotent per page load — the + * client presets call it on construction and tests reset via + * `resetStandaloneSessionForTests`. */ export const setupStandaloneSession = (options: StandaloneSessionOptions): void => { if (typeof window === "undefined") return - if (!options.ingestKey) return if (current) return if (readSessionSink()) return const record = getSession() diff --git a/lib/effect-sdk/src/shared/flush-core.ts b/lib/effect-sdk/src/shared/flush-core.ts index dfaed0809..ed2762be3 100644 --- a/lib/effect-sdk/src/shared/flush-core.ts +++ b/lib/effect-sdk/src/shared/flush-core.ts @@ -70,6 +70,14 @@ export const buildResolved = ( readonly tracesPath?: string | undefined readonly logsPath?: string | undefined readonly userAgent: string + /** + * Keep exporting even without an ingest key, POSTing without an + * `Authorization` header (for setups where a proxy/gateway injects it). + * The browser client sets this so `MapleFlush.make` matches `Maple.layer`'s + * keyless-send behavior; the env-driven server/Cloudflare presets leave it + * unset and keep no-op'ing when unconfigured. + */ + readonly allowKeyless?: boolean | undefined }, ): Resolved => { // `r.endpoint` is always defined in practice (every resolver falls back to @@ -89,7 +97,7 @@ export const buildResolved = ( resource: makeOtlpResource(r.resource), scope: { name: r.resource.serviceName }, headers, - noOp: r.ingestKey === undefined, + noOp: r.ingestKey === undefined && !opts.allowKeyless, } } diff --git a/packages/browser-session/src/meta-row.test.ts b/packages/browser-session/src/meta-row.test.ts new file mode 100644 index 000000000..4dfa93b27 --- /dev/null +++ b/packages/browser-session/src/meta-row.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { buildSessionMetaRow, postSessionMetaRow } from "./meta-row" + +// The ingest key is auth only: `postSessionMetaRow` attaches the `Authorization` +// header when a key is set, and omits it when unset so a proxy/gateway can add +// it — the row still posts either way. + +interface Captured { + readonly url: string + readonly authorization: string | null + readonly contentType: string | null +} + +const setupFetch = () => { + const calls: Array = [] + const original = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const headers = new Headers(init?.headers) + calls.push({ + url, + authorization: headers.get("authorization"), + contentType: headers.get("content-type"), + }) + return new Response(null, { status: 200 }) + }) as typeof fetch + return { calls, restore: () => void (globalThis.fetch = original) } +} + +const row = () => + buildSessionMetaRow({ + sessionId: "sess-1", + startedAt: new Date("2026-05-22T12:00:00Z"), + version: 1, + status: "active", + serviceName: "unit-test", + }) + +describe("postSessionMetaRow auth header", () => { + let restore: () => void + afterEach(() => { + restore?.() + vi.unstubAllGlobals() + }) + + it("attaches Authorization when an ingest key is set", async () => { + const { calls, restore: r } = setupFetch() + restore = r + + await postSessionMetaRow("https://collector.test", "secret", row()) + + expect(calls.length).toBe(1) + expect(calls[0].url).toBe("https://collector.test/v1/sessionReplays/meta") + expect(calls[0].authorization).toBe("Bearer secret") + expect(calls[0].contentType).toBe("application/x-ndjson") + }) + + it("omits Authorization when no ingest key is set (proxy attaches it)", async () => { + const { calls, restore: r } = setupFetch() + restore = r + + await postSessionMetaRow("https://collector.test", undefined, row()) + + expect(calls.length).toBe(1) + expect(calls[0].authorization).toBeNull() + // The content-type header is still present — only auth is conditional. + expect(calls[0].contentType).toBe("application/x-ndjson") + }) +}) diff --git a/packages/browser-session/src/meta-row.ts b/packages/browser-session/src/meta-row.ts index 3c43c695a..796fb1fe0 100644 --- a/packages/browser-session/src/meta-row.ts +++ b/packages/browser-session/src/meta-row.ts @@ -70,19 +70,24 @@ export function buildSessionMetaRow(input: SessionMetaRowInput): Record, keepalive = false, ): Promise { + const headers: Record = { "content-type": "application/x-ndjson" } + if (ingestKey) headers.Authorization = `Bearer ${ingestKey}` await fetch(`${endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, { method: "POST", - headers: { - Authorization: `Bearer ${ingestKey}`, - "content-type": "application/x-ndjson", - }, + headers, body: `${JSON.stringify(row)}\n`, keepalive, }).catch(() => { diff --git a/packages/browser-session/src/replay-session.ts b/packages/browser-session/src/replay-session.ts index 093d10c78..6d2797639 100644 --- a/packages/browser-session/src/replay-session.ts +++ b/packages/browser-session/src/replay-session.ts @@ -14,7 +14,12 @@ export { setActiveTraceIdProvider } from "./replay/events" export interface ReplaySessionOptions { readonly endpoint: string - readonly ingestKey: string + /** + * Ingest key, used only for the `Authorization` header. Optional: when unset + * the session's POSTs go out without an auth header, for setups where a + * proxy/gateway injects it (the key never gates whether recording runs). + */ + readonly ingestKey?: string | undefined readonly serviceName: string readonly environment?: string | undefined readonly serviceVersion?: string | undefined diff --git a/packages/browser-session/src/replay/transport.ts b/packages/browser-session/src/replay/transport.ts index 167cf328c..f01214c07 100644 --- a/packages/browser-session/src/replay/transport.ts +++ b/packages/browser-session/src/replay/transport.ts @@ -4,11 +4,21 @@ */ export interface ReplayEngineConfig { readonly endpoint: string - readonly ingestKey: string + /** + * Ingest key, used only for the `Authorization` header. Optional: when unset + * the POSTs go out without an auth header, for setups where a proxy/gateway + * injects it (the ingest key never gates whether replay runs). + */ + readonly ingestKey?: string | undefined readonly maskAllInputs: boolean readonly maskAllText: boolean } +/** Build request headers, attaching `Authorization` only when a key is set. */ +function authHeaders(config: ReplayEngineConfig, extra: Record): Record { + return config.ingestKey ? { Authorization: `Bearer ${config.ingestKey}`, ...extra } : extra +} + // Replay POSTs are best-effort and must never throw into the host app, but a // fully broken ingest endpoint should not be *silent*. Warn at most once every // 30s so a misconfigured endpoint is visible in the console without spamming it. @@ -39,10 +49,7 @@ export async function postSessionMeta( const body = `${JSON.stringify(row)}\n` await fetch(`${config.endpoint}/v1/sessionReplays/meta`, { method: "POST", - headers: { - Authorization: `Bearer ${config.ingestKey}`, - "content-type": "application/x-ndjson", - }, + headers: authHeaders(config, { "content-type": "application/x-ndjson" }), body, keepalive, }).catch((error) => { @@ -61,10 +68,7 @@ export async function postSessionEvents( const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n` await fetch(`${config.endpoint}/v1/sessionEvents`, { method: "POST", - headers: { - Authorization: `Bearer ${config.ingestKey}`, - "content-type": "application/x-ndjson", - }, + headers: authHeaders(config, { "content-type": "application/x-ndjson" }), body, keepalive, }).catch((error) => { @@ -89,15 +93,14 @@ export async function postSessionBlob( ): Promise { await fetch(`${config.endpoint}/v1/sessionReplays/blob`, { method: "POST", - headers: { - Authorization: `Bearer ${config.ingestKey}`, + headers: authHeaders(config, { "content-type": "application/octet-stream", "x-maple-session-id": meta.sessionId, "x-maple-chunk-seq": String(meta.chunkSeq), "x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0", "x-maple-event-count": String(meta.eventCount), "x-maple-duration-ms": String(meta.durationMs), - }, + }), body: gzipped as unknown as BodyInit, keepalive, }).catch((error) => { From 3de81dfff0083867ea3b11e1a79b1a11602c02e3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 8 Jul 2026 11:30:47 +0200 Subject: [PATCH 2/2] fix(browser): make ingestKey optional (auth-only) for proxy/keyless setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the ingest-key decouple to `@maple-dev/browser` so the proxy pattern works there too (addresses PR review): `ingestKey` is now optional and used only for the `Authorization` header. With no key, tracing and replay still run — the OTLP trace exporter omits the auth header for a proxy/gateway to complete, matching `@maple/browser-session`'s POSTs. Co-Authored-By: Claude Opus 4.8 --- docs/browser-sdk.md | 2 +- packages/browser/README.md | 16 ++++++++++++++++ packages/browser/src/config.test.ts | 8 ++++++++ packages/browser/src/config.ts | 11 ++++++++--- packages/browser/src/tracing.ts | 4 +++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index f9f5e8a76..a33ffd5b0 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -39,7 +39,7 @@ Every field accepted by `MapleBrowser.init`: | Option | Type | Default | Description | | ------------------------- | --------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ingestKey` | `string` | — | **Required.** Public ingest key (`maple_pk_...`). | +| `ingestKey` | `string` | — | Public ingest key (`maple_pk_...`) — used only for the `Authorization` header. Optional: leave unset when a self-hosted proxy/gateway injects auth server-side (traces + replay still run, POSTing without an auth header). | | `serviceName` | `string` | — | **Required.** Service name reported on traces and stored on replay sessions. | | `endpoint` | `string` | `https://ingest.maple.dev` | Maple ingest base URL. Override for self-hosted / regional ingest. | | `serviceNamespace` | `string` | — | Logical group this service belongs to, emitted as the OTel `service.namespace` resource attribute on traces. | diff --git a/packages/browser/README.md b/packages/browser/README.md index e2efb96e8..de7325e43 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -35,6 +35,22 @@ That single call: - writes session metadata at start (`active`) and on page hide (`ended`), including the trace ids observed during the session. +## Auth via a proxy + +`ingestKey` is **auth only** — it just sets the `Authorization` header, and is +optional. Point `endpoint` at a self-hosted proxy/gateway that injects the key +server-side (e.g. to sidestep ad-blockers) and leave `ingestKey` unset: tracing +and replay still run, POSTing without an `Authorization` header for the proxy to +complete. + +```ts +MapleBrowser.init({ + serviceName: "acme-web", + endpoint: "https://telemetry.acme.com", // your proxy; it adds Authorization + // no ingestKey — the proxy attaches it +}) +``` + ## Privacy `maskAllInputs` (default **on**) masks every `` value. Use rrweb's diff --git a/packages/browser/src/config.test.ts b/packages/browser/src/config.test.ts index d40114cc5..e0e61c3e9 100644 --- a/packages/browser/src/config.test.ts +++ b/packages/browser/src/config.test.ts @@ -23,4 +23,12 @@ describe("resolveConfig", () => { expect(custom.tracingInstrumentFetch).toBe(false) expect(custom.replaySampleRate).toBe(0.25) }) + + it("allows a keyless config (auth handled by a proxy)", () => { + // ingestKey is auth only — omitting it is valid; the proxy attaches auth. + const config = resolveConfig({ serviceName: "acme-web" }) + expect(config.ingestKey).toBeUndefined() + expect(config.serviceName).toBe("acme-web") + expect(config.replayEnabled).toBe(true) + }) }) diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index f77ba7a98..139e5acfe 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -1,7 +1,12 @@ /** Public configuration for `MapleBrowser.init`. */ export interface MapleBrowserConfig { - /** Public ingest key (`maple_pk_...`). */ - readonly ingestKey: string + /** + * Public ingest key (`maple_pk_...`), used only for the `Authorization` + * header. Optional: leave it unset when a self-hosted proxy/gateway injects + * auth server-side — traces and replay still run, POSTing without an auth + * header. The key never gates whether telemetry runs. + */ + readonly ingestKey?: string /** Service name reported on traces and stored on replay sessions. */ readonly serviceName: string /** Maple ingest base URL. Defaults to `https://ingest.maple.dev`. */ @@ -46,7 +51,7 @@ export interface MapleBrowserConfig { } export interface ResolvedConfig { - readonly ingestKey: string + readonly ingestKey: string | undefined readonly serviceName: string readonly endpoint: string readonly serviceNamespace: string | undefined diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index 18e1b9bdc..4689ead71 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -56,7 +56,9 @@ export function setupTracing(config: ResolvedConfig, sessionId: string): () => P const exporter = new OTLPTraceExporter({ url: `${config.endpoint}/v1/traces`, - headers: { Authorization: `Bearer ${config.ingestKey}` }, + // Auth header only when a key is set; without one the POST goes out + // headerless for a proxy/gateway to complete. + headers: config.ingestKey ? { Authorization: `Bearer ${config.ingestKey}` } : undefined, }) const provider = new WebTracerProvider({