Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c76c782
feat(web): manage custom ingest keys in ingestion settings
Makisuo Oct 16, 2026
53b581c
fix(web): mask the revealed ingest key, shorter attribute mode label
Makisuo Oct 16, 2026
4fa7413
docs: custom ingest keys in the authentication reference
Makisuo Oct 16, 2026
7da1ec4
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
ddfb0a5
fix(web): review fixes for the custom keys form
Makisuo Oct 16, 2026
a9cd73e
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
25dbcf0
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
1a00688
fix(web): say Expired or Expires for the stored expiry
Makisuo Oct 16, 2026
d169dbc
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
4babc47
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
e0ed30a
docs: browser track() calls count as events for custom keys
Makisuo Oct 16, 2026
6d4eff0
test(web): pin custom key form validation and the one-time reveal
Makisuo Oct 16, 2026
b5470ab
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
3f696ba
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
135f1bd
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
94e44f8
feat(web): ingest keys get a keys tab, a full-page form and a key page
Makisuo Oct 16, 2026
e82262d
fix(web): shorter key subtitles and a paused hint on the key page
Makisuo Oct 16, 2026
67c8ae9
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
800ead2
refactor(web): review fixes for the ingest keys pages
Makisuo Oct 16, 2026
f025bfb
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
0a8d913
test(web): key page pauses with the right deadline and reveals a roll…
Makisuo Oct 16, 2026
31f3f77
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 16, 2026
787aa75
fix(web): key names are the links, rows keep their table semantics
Makisuo Oct 16, 2026
8cd5b4e
perf(web): keep the ingest key helpers out of the startup bundle
Makisuo Oct 17, 2026
83c2bdc
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 17, 2026
a7146fc
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 17, 2026
6204865
Merge branch 'feat/ingest-keys-custom-gateway' into feat/ingest-keys-…
Makisuo Oct 17, 2026
8546136
fix(web): call the key breadcrumb hook before the settings redirects
Makisuo Oct 17, 2026
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
26 changes: 21 additions & 5 deletions apps/landing/src/content/docs/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Maple uses two kinds of credential. **Ingest keys** send telemetry. **API keys**
| ------------------ | ----------- | ---------------------------------------- | ---------------------------------------------- | ---------------------------- |
| Public ingest key | `maple_pk_` | **Settings → Ingestion** | Send telemetry | Yes |
| Private ingest key | `maple_sk_` | **Settings → Ingestion** | Send telemetry | No |
| Custom ingest key | `maple_ik_` | **Settings → Ingestion → Keys** | Send the signals you allow, up to its limit | No |
| API key | `maple_ak_` | **Settings → API Keys** | REST API and MCP server, limited by its scopes | No |
| MCP key | `maple_ak_` | **Settings → MCP** | MCP server only | No |
| OAuth token | | Issued to an MCP client when you sign in | MCP server only | No |
Expand All @@ -19,11 +20,11 @@ Every credential belongs to one organization in one [region](/docs/reference/reg

## Which endpoint accepts what

| Endpoint | Accepts |
| --------------------------------------- | ------------------------------------------------------------------------------------- |
| Ingest, `https://ingest.maple.dev/v1/*` | Public or private ingest key, as `Authorization: Bearer …` or `x-maple-ingest-key: …` |
| REST API, `https://api.maple.dev/v2/*` | API key, or a dashboard session token, as `Authorization: Bearer …` |
| MCP server, `https://api.maple.dev/mcp` | API key, MCP key or OAuth token, as `Authorization: Bearer …` |
| Endpoint | Accepts |
| --------------------------------------- | ----------------------------------------------------------------------- |
| Ingest, `https://ingest.maple.dev/v1/*` | Any ingest key, as `Authorization: Bearer …` or `x-maple-ingest-key: …` |
| REST API, `https://api.maple.dev/v2/*` | API key, or a dashboard session token, as `Authorization: Bearer …` |
| MCP server, `https://api.maple.dev/mcp` | API key, MCP key or OAuth token, as `Authorization: Bearer …` |

Use the EU hosts (`ingest.eu.maple.dev`, `api.eu.maple.dev`) for an EU organization.

Expand All @@ -42,6 +43,21 @@ To rotate a key, click **Regenerate** next to it in **Settings → Ingestion**,

The literal key `MAPLE_TEST` is accepted and returns `200`, but the data is discarded. Use it in CI or example code.

### Custom ingest keys

Beside the default pair you can create named keys under **Settings → Ingestion → Custom keys**, or with `POST /v2/ingest_keys/custom`. Give one to a team, a service or an environment so you can limit and revoke it on its own. Each key has:

| Setting | What it does |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Allowed signals** | Traces, logs, metrics, sessions (replays and session events) and events (product events, including browser `track()` calls). Other signals are rejected with `403`. |
| **Rate limit** | Requests per second, split across the ingest servers. Requests over it get `429` with a `Retry-After` header. |
| **Resource attributes** | Written on every resource the key sends over OTLP (traces, logs, metrics). **Always** overwrites the sender's value; **If missing** only fills it in. `maple_*` is reserved. |
| **Expires** | Never, or a date after which the key gets `401`. |

The key is shown once, when it is created or rolled; Maple keeps only a hash. Telemetry sent with a custom key carries a `maple_ingest_key_id` resource attribute with the key's `ingk_…` ID, so you can filter by key. Each key's page shows when it last sent data and its span and log volume over the last 24 hours.

**Pausing** a key (for an hour, a day, or until you resume it, or with `POST /v2/ingest_keys/custom/{id}/pause`) keeps answering senders with success, so nothing retries or backs up, but drops what they send: it is not stored or billed. Revoke a key by rolling or deleting it instead. Edits, pauses, rolling and deleting reach the ingest servers within a minute.

## API keys

Create API keys under **Settings → API Keys**, or with `POST /v2/api_keys`. When you create one you choose:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// TEST-SEAM: the atom client and the list atom are process-global wiring with no
// instance-level injection seam, so the hooks are replaced at the module boundary.
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"
import { Exit, Schema } from "effect"
import { afterEach, describe, expect, it, vi } from "vitest"
import { V2CustomIngestKey } from "@maple/domain/http/v2"

const mutations = {
pauseCustom: vi.fn(async (_input: { payload: { until: string | null } }) => Exit.succeed({})),
resumeCustom: vi.fn(async (_input: unknown) => Exit.succeed({})),
rollCustom: vi.fn(async (_input: unknown) =>
Exit.succeed({ secret: "maple_ik_rolledSecret000000000000000000" }),
),
deleteCustom: vi.fn(async (_input: unknown) => Exit.succeed({})),
}
interface ListState {
key: V2CustomIngestKey | null
}
const state: ListState = { key: null }

vi.mock("@/lib/effect-atom", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/effect-atom")>()
return {
...actual,
useAtomValue: (atom: string) =>
atom === "list"
? actual.Result.success({ data: state.key === null ? [] : [state.key] })
: actual.Result.success({
object: "custom_ingest_key_usage",
window_start: "2026-07-14T10:00:00.000Z",
window_end: "2026-07-15T10:00:00.000Z",
buckets: [],
}),
useAtomRefresh: () => () => {},
useAtomSet: (name: keyof typeof mutations) => mutations[name],
}
})
vi.mock("@/lib/services/common/v2-atom-client", () => ({
MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name },
retainedQueryV2: () => "usage",
}))
vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" }))
vi.mock("@tanstack/react-router", () => ({ Link: (props: { children?: unknown }) => props.children }))

import { IngestKeyDetail } from "./key-detail"

const decodeKey = Schema.decodeUnknownSync(V2CustomIngestKey)
const baseKey = {
id: "ingk_aXunrkWtzvXSkw9Z2smcft",
object: "custom_ingest_key",
name: "Mobile app",
description: null,
key_prefix: "maple_ik_Q7xZa",
status: "active",
allowed_signals: null,
rate_limit_rps: null,
resource_attributes: [],
expires_at: null,
paused_at: null,
paused_until: null,
last_used_at: null,
rolled_at: "2026-07-01T12:00:00.000Z",
created_at: "2026-07-01T12:00:00.000Z",
updated_at: "2026-07-01T12:00:00.000Z",
}

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

const renderDetail = (key: V2CustomIngestKey, onRevealed = vi.fn()) => {
state.key = key
render(
<IngestKeyDetail
keyId={key.id}
revealed={null}
onRevealed={onRevealed}
onConfigure={vi.fn()}
onDeleted={vi.fn()}
/>,
)
return onRevealed
}

describe("IngestKeyDetail", () => {
it("pauses for an hour with an absolute deadline, and until resumed with none", async () => {
renderDetail(decodeKey(baseKey))
const before = Date.now()

fireEvent.click(screen.getByRole("button", { name: "Pause" }))
fireEvent.click(await screen.findByRole("menuitem", { name: "For 1 hour" }))
await vi.waitFor(() => expect(mutations.pauseCustom).toHaveBeenCalledTimes(1))
const until = new Date(mutations.pauseCustom.mock.calls[0]?.[0].payload.until ?? 0).getTime()
expect(until - before).toBeGreaterThanOrEqual(3_600_000)
expect(until - Date.now()).toBeLessThanOrEqual(3_600_000)

cleanup()
renderDetail(decodeKey(baseKey))
fireEvent.click(screen.getByRole("button", { name: "Pause" }))
fireEvent.click(await screen.findByRole("menuitem", { name: "Until I resume" }))
await vi.waitFor(() => expect(mutations.pauseCustom).toHaveBeenCalledTimes(2))
expect(mutations.pauseCustom.mock.calls[1]?.[0]).toMatchObject({ payload: { until: null } })
})

it("offers resume on a paused key and hands a rolled secret up", async () => {
const onRevealed = renderDetail(
decodeKey({ ...baseKey, status: "paused", paused_at: "2026-07-01T12:00:00.000Z" }),
)

expect(screen.queryByRole("button", { name: "Pause" })).toBeNull()
fireEvent.click(screen.getByRole("button", { name: "Resume now" }))
await vi.waitFor(() => expect(mutations.resumeCustom).toHaveBeenCalledTimes(1))

fireEvent.click(screen.getByRole("button", { name: "Roll secret" }))
const dialog = await screen.findByRole("alertdialog")
fireEvent.click(within(dialog).getByRole("button", { name: "Roll secret" }))
await vi.waitFor(() =>
expect(onRevealed).toHaveBeenCalledWith({
keyId: state.key?.id,
secret: "maple_ik_rolledSecret000000000000000000",
rolled: true,
}),
)
})
})
Loading
Loading