Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
24 changes: 23 additions & 1 deletion lib/effect-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
10 changes: 7 additions & 3 deletions lib/effect-sdk/src/client/flushable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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 () => {
Expand Down
30 changes: 19 additions & 11 deletions lib/effect-sdk/src/client/flushable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
/**
Expand Down Expand Up @@ -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<void> => {
await runFlush({
Expand All @@ -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: () => {},
})
}

Expand Down
3 changes: 2 additions & 1 deletion lib/effect-sdk/src/client/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
8 changes: 5 additions & 3 deletions lib/effect-sdk/src/client/replay-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 29 additions & 3 deletions lib/effect-sdk/src/client/standalone-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ interface MetaPost {
readonly url: string
readonly row: Record<string, any>
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 = () => {
Expand All @@ -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
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
11 changes: 6 additions & 5 deletions lib/effect-sdk/src/client/standalone-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 9 additions & 1 deletion lib/effect-sdk/src/shared/flush-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand Down
69 changes: 69 additions & 0 deletions packages/browser-session/src/meta-row.test.ts
Original file line number Diff line number Diff line change
@@ -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<Captured> = []
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")
})
})
17 changes: 11 additions & 6 deletions packages/browser-session/src/meta-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,24 @@ export function buildSessionMetaRow(input: SessionMetaRowInput): Record<string,
return row
}

/** POST one session metadata row (NDJSON). Best-effort — never throws. */
/**
* POST one session metadata row (NDJSON). Best-effort — never throws.
*
* `ingestKey` is used only for the `Authorization` header and is optional: when
* unset the row goes out without an auth header, for setups where a proxy or
* gateway injects it (the key never gates whether the row is posted).
*/
export async function postSessionMetaRow(
endpoint: string,
ingestKey: string,
ingestKey: string | undefined,
row: Record<string, unknown>,
keepalive = false,
): Promise<void> {
const headers: Record<string, string> = { "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(() => {
Expand Down
Loading
Loading