From c76c782f7fb0ba0814767b969ab9fce5d40382a1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:11:23 +0200 Subject: [PATCH 01/14] feat(web): manage custom ingest keys in ingestion settings A Custom keys section under Endpoint & keys lists keys with their signals, rate limit and attribute overrides, and creates, edits, toggles, rolls and deletes them. The secret is shown once after create and roll. --- .../settings/custom-ingest-keys-section.tsx | 608 ++++++++++++++++++ .../components/settings/ingestion-section.tsx | 7 +- .../src/lib/services/atoms/ingestion-atoms.ts | 14 +- 3 files changed, 625 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/settings/custom-ingest-keys-section.tsx diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx new file mode 100644 index 0000000000..1f6a91b4ee --- /dev/null +++ b/apps/web/src/components/settings/custom-ingest-keys-section.tsx @@ -0,0 +1,608 @@ +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import type { IngestKeyAttributeMode, IngestKeySignal } from "@maple/domain/http" +import type { V2CustomIngestKey, V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" +import { useState } from "react" + +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CopyableField } from "@maple/ui/components/ui/copyable-field" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from "@maple/ui/components/ui/dialog" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" +import { FormDialog } from "@maple/ui/components/ui/form-dialog" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Input } from "@maple/ui/components/ui/input" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" +import { Switch } from "@maple/ui/components/ui/switch" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { ArrowPathIcon, KeyIcon, PencilIcon, PlusIcon, TrashIcon, XmarkIcon } from "@/components/icons" +import { ErrorState } from "@/components/common/error-state" +import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { SettingsSection } from "./settings-section" + +const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: string }> = [ + { value: "traces", label: "Traces", hint: "/v1/traces" }, + { value: "logs", label: "Logs", hint: "/v1/logs" }, + { value: "metrics", label: "Metrics", hint: "/v1/metrics" }, + { value: "sessions", label: "Sessions", hint: "Replays and session events" }, + { value: "events", label: "Events", hint: "Product events" }, +] + +const MODE_LABELS = { + set: "Always set", + default: "Only if missing", +} satisfies Record + +const EXPIRY_LABELS = { + never: "Never", + "30": "In 30 days", + "90": "In 90 days", + "365": "In 1 year", +} as const +type ExpiryChoice = keyof typeof EXPIRY_LABELS +const toExpiryChoice = (value: string | null): ExpiryChoice => + value === "30" || value === "90" || value === "365" ? value : "never" + +const toMode = (value: string | null): IngestKeyAttributeMode => (value === "default" ? "default" : "set") + +interface AttributeDraft { + readonly rowId: string + readonly key: string + readonly value: string + readonly mode: IngestKeyAttributeMode +} + +const newAttributeDraft = (key = "", mode: IngestKeyAttributeMode = "set"): AttributeDraft => ({ + rowId: crypto.randomUUID(), + key, + value: "", + mode, +}) + +const signalsSummary = (signals: V2CustomIngestKey["allowed_signals"]) => + signals === null + ? "All signals" + : SIGNALS.filter((signal) => signals.includes(signal.value)) + .map((signal) => signal.label) + .join(", ") + +const isExpired = (key: V2CustomIngestKey, now: number) => + key.expires_at !== null && new Date(key.expires_at).getTime() <= now + +export function CustomIngestKeysSection() { + const listResult = useAtomValue(customIngestKeysListAtom) + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + + const createMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "createCustom"), { + mode: "promiseExit", + }) + const updateMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "updateCustom"), { + mode: "promiseExit", + }) + const rollMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "rollCustom"), { + mode: "promiseExit", + }) + const deleteMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "deleteCustom"), { + mode: "promiseExit", + }) + + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [formName, setFormName] = useState("") + const [formDescription, setFormDescription] = useState("") + const [formSignals, setFormSignals] = useState>( + SIGNALS.map((s) => s.value), + ) + const [formRateLimit, setFormRateLimit] = useState("") + const [formAttributes, setFormAttributes] = useState>([]) + const [formExpiry, setFormExpiry] = useState("never") + + const [revealed, setRevealed] = useState<{ secret: V2CustomIngestKeyWithSecret; rolled: boolean } | null>( + null, + ) + const [rollConfirm, setRollConfirm] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const keys = Result.builder(listResult) + .onSuccess((response) => [...response.data]) + .orElse(() => [] as V2CustomIngestKey[]) + const now = Date.now() + + function openCreateDialog() { + setEditing(null) + setFormName("") + setFormDescription("") + setFormSignals(SIGNALS.map((s) => s.value)) + setFormRateLimit("") + setFormAttributes([newAttributeDraft("service.name", "default")]) + setFormExpiry("never") + setDialogOpen(true) + } + + function openEditDialog(key: V2CustomIngestKey) { + setEditing(key) + setFormName(key.name) + setFormDescription(key.description ?? "") + setFormSignals(key.allowed_signals ?? SIGNALS.map((s) => s.value)) + setFormRateLimit(key.rate_limit_rps === null ? "" : String(key.rate_limit_rps)) + setFormAttributes( + key.resource_attributes.map((attribute) => ({ rowId: crypto.randomUUID(), ...attribute })), + ) + setDialogOpen(true) + } + + const rateLimitText = formRateLimit.trim() + const rateLimit = rateLimitText.length === 0 ? null : Number(rateLimitText) + const rateLimitValid = + rateLimit === null || (Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 100_000) + const attributes = formAttributes.filter((attribute) => attribute.key.trim().length > 0) + const attributeKeys = attributes.map((attribute) => attribute.key.trim()) + const reservedAttribute = attributeKeys.find((key) => key === "org_id" || key.startsWith("maple_")) + const duplicateAttribute = attributeKeys.find((key, index) => attributeKeys.indexOf(key) !== index) + const formValid = + formName.trim().length > 0 && + formSignals.length > 0 && + rateLimitValid && + reservedAttribute === undefined && + duplicateAttribute === undefined + + const [handleSave, isSaving] = useAsyncAction(async () => { + if (!formValid) return + const allSignals = formSignals.length === SIGNALS.length + const payload = { + name: formName.trim(), + description: formDescription.trim().length > 0 ? formDescription.trim() : null, + allowed_signals: allSignals + ? null + : SIGNALS.map((s) => s.value).filter((s) => formSignals.includes(s)), + rate_limit_rps: rateLimit, + resource_attributes: attributes.map(({ key, value, mode }) => ({ key: key.trim(), value, mode })), + } + if (editing) { + const ok = toastExit(await updateMutation({ params: { id: editing.id }, payload }), { + success: "Ingest key updated. The gateway picks it up within a minute.", + error: "Failed to update ingest key", + }) + if (!ok) return + setDialogOpen(false) + refreshKeys() + return + } + const expiresAt = + formExpiry === "never" + ? null + : new Date(Date.now() + Number(formExpiry) * 86_400_000).toISOString() + const result = await createMutation({ payload: { ...payload, expires_at: expiresAt } }) + if (!toastExit(result, { error: "Failed to create ingest key" })) return + if (result._tag === "Success") setRevealed({ secret: result.value, rolled: false }) + setDialogOpen(false) + refreshKeys() + }) + + const [handleRoll, isRolling] = useAsyncAction(async () => { + if (!rollConfirm) return + const result = await rollMutation({ params: { id: rollConfirm.id } }) + setRollConfirm(null) + if (!toastExit(result, { error: "Failed to roll ingest key" })) return + if (result._tag === "Success") setRevealed({ secret: result.value, rolled: true }) + refreshKeys() + }) + + async function handleDelete(key: V2CustomIngestKey) { + const ok = toastExit(await deleteMutation({ params: { id: key.id } }), { + success: "Ingest key deleted", + error: "Failed to delete ingest key", + }) + if (ok) refreshKeys() + return ok + } + + const toggle = useKeyedAsyncAction(async (_id: string, key: V2CustomIngestKey) => { + const result = await updateMutation({ params: { id: key.id }, payload: { enabled: !key.enabled } }) + if (toastExit(result, { error: "Failed to update ingest key" })) refreshKeys() + }) + + const updateAttribute = (rowId: string, patch: Partial>) => + setFormAttributes((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))) + + const keyCount = Result.isSuccess(listResult) ? keys.length : null + + return ( + <> + 0 ? `Custom keys · ${keyCount}` : "Custom keys"} + description="Named keys for a team, service or environment, each with its own allowed signals, rate limit and resource attributes. The default keys keep working." + padded={false} + actions={ + + } + > + {Result.isInitial(listResult) ? ( + ( +
+
+ + +
+ +
+ )} + /> + ) : !Result.isSuccess(listResult) ? ( + refreshKeys()} + className="border-0" + /> + ) : keys.length === 0 ? ( + + + + + + No custom keys yet + + Give a team or environment its own key to cap its request rate, stamp a{" "} + service.name on everything it sends, + or revoke it without touching anyone else. + + + + + ) : ( +
+ {keys.map((key) => { + const expired = isExpired(key, now) + return ( +
+
+
+ + {key.name} + + + {key.key_prefix}… + + {expired && ( + + Expired + + )} +
+
+ {signalsSummary(key.allowed_signals)} + + {key.rate_limit_rps === null + ? "No rate limit" + : `${key.rate_limit_rps} req/s`} + + {key.resource_attributes.length > 0 && ( + + {key.resource_attributes.map((attribute) => ( + + {attribute.key}={attribute.value} + + ))} + + )} + {key.expires_at !== null && !expired && ( + Expires {formatRelativeTime(key.expires_at)} + )} +
+
+
+ openEditDialog(key)} + label={`Edit ${key.name}`} + > + + + void toggle.run(key.id, key)} + disabled={toggle.isPending(key.id)} + /> + + setRollConfirm(key)}> + + Roll secret + + setDeleteConfirm(key)} + > + + Delete key + + +
+
+ ) + })} +
+ )} +
+ + void handleSave()} + submitLabel={editing ? "Save changes" : "Create key"} + pending={isSaving} + submitDisabled={!formValid} + > + + Name + setFormName(e.target.value)} + /> + + + Description + setFormDescription(e.target.value)} + /> + + + Allowed signals +
+ {SIGNALS.map((signal) => ( + + ))} +
+ {formSignals.length === 0 && ( + Allow at least one signal. + )} +
+ + Rate limit (requests per second) + setFormRateLimit(e.target.value)} + /> + + {rateLimitValid + ? "Across all ingest servers. Requests over the limit get a 429 with Retry-After." + : "Enter a whole number between 1 and 100000, or leave it empty."} + + + + Resource attributes +
+ {formAttributes.map((attribute) => ( +
+ + updateAttribute(attribute.rowId, { key: e.target.value }) + } + /> + + updateAttribute(attribute.rowId, { value: e.target.value }) + } + /> + + + setFormAttributes((rows) => + rows.filter((row) => row.rowId !== attribute.rowId), + ) + } + > + + +
+ ))} + +
+ + {reservedAttribute !== undefined + ? `"${reservedAttribute}" is reserved by Maple.` + : duplicateAttribute !== undefined + ? `"${duplicateAttribute}" is listed twice.` + : "Written on every resource sent with this key. Rows with an empty key are ignored."} + +
+ {!editing && ( + + Expires + + + )} +
+ + !open && setRevealed(null)}> + + + {revealed?.rolled ? "Secret rolled" : "Ingest key created"} + + Copy the key now. You won't be able to see it again. + {revealed?.rolled && " The previous secret stops working within a minute."} + + + {revealed && ( + +
+ + {revealed.secret.name} + + + {signalsSummary(revealed.secret.allowed_signals)} + +
+ +

+ Send it as{" "} + Authorization: Bearer <key> to + the same OTLP endpoint as the default keys. +

+
+ )} + + + +
+
+ + !open && setRollConfirm(null)} + title="Roll this key's secret?" + description={ + <> + {rollConfirm?.name} gets a new + secret and keeps its settings. Senders using the old secret are rejected within a + minute. + + } + confirmLabel="Roll secret" + onConfirm={() => void handleRoll()} + pending={isRolling} + /> + + !open && setDeleteConfirm(null)} + title="Delete ingest key?" + description={ + <> + Senders using{" "} + {deleteConfirm?.name} are + rejected within a minute. This cannot be undone. + + } + confirmLabel="Delete" + onConfirm={() => (deleteConfirm ? handleDelete(deleteConfirm) : undefined)} + /> + + ) +} diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 09d3ec0ae1..146076e5c0 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -26,6 +26,7 @@ import { type IngestConnection, } from "@/components/ingest/use-ingest-connection" import { AttributeMappingsSection } from "./attribute-mappings-section" +import { CustomIngestKeysSection } from "./custom-ingest-keys-section" import { SettingsSection, SettingsSections } from "./settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" @@ -189,9 +190,7 @@ function CredentialRow({ label={isVisible ? "Hide key" : "Reveal key"} disabled={isDisabled} > - + )} @@ -317,6 +316,8 @@ export function IngestionSection() { + + = yield* collectV2Pages((cursor) => + client.ingestKeys.listCustom({ + query: { limit: PAGE_LIMIT, ...(cursor !== undefined ? { cursor } : undefined) }, + }), + ) + return { data } + }), +) From 53b581cb18f6b7313ad87e827421e75244580567 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:15:22 +0200 Subject: [PATCH 02/14] fix(web): mask the revealed ingest key, shorter attribute mode label --- .../src/components/settings/custom-ingest-keys-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx index 1f6a91b4ee..ba6a2dda49 100644 --- a/apps/web/src/components/settings/custom-ingest-keys-section.tsx +++ b/apps/web/src/components/settings/custom-ingest-keys-section.tsx @@ -50,7 +50,7 @@ const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: stri const MODE_LABELS = { set: "Always set", - default: "Only if missing", + default: "If missing", } satisfies Record const EXPIRY_LABELS = { @@ -557,7 +557,7 @@ export function CustomIngestKeysSection() { {signalsSummary(revealed.secret.allowed_signals)} - +

Send it as{" "} Authorization: Bearer <key> to From 4fa7413de72a9739f5af5c51439a8b288523a992 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:16:23 +0200 Subject: [PATCH 03/14] docs: custom ingest keys in the authentication reference --- .../content/docs/reference/authentication.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md index f8bed90c2a..95c6c9cbad 100644 --- a/apps/landing/src/content/docs/reference/authentication.md +++ b/apps/landing/src/content/docs/reference/authentication.md @@ -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 → Custom 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 | @@ -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. @@ -42,6 +43,19 @@ 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. Other signals are rejected with `403`. | +| **Rate limit** | Requests per second across all ingest servers. Requests over it get `429` with a `Retry-After` header. | +| **Resource attributes** | Written on every resource the key sends. **Always set** 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. Edits, disabling, 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: From ddfb0a520ac71f1b39bce65765d078fa6cba586b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:21:26 +0200 Subject: [PATCH 04/14] fix(web): review fixes for the custom keys form Block empty attribute values and reserved keys in any case, cap the description, let the edit dialog change or keep the expiry, drop an inline cast, and say the rate limit is split across servers. --- .../content/docs/reference/authentication.md | 12 +-- .../settings/custom-ingest-keys-section.tsx | 77 ++++++++++++------- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md index 95c6c9cbad..7699872cd8 100644 --- a/apps/landing/src/content/docs/reference/authentication.md +++ b/apps/landing/src/content/docs/reference/authentication.md @@ -47,12 +47,12 @@ The literal key `MAPLE_TEST` is accepted and returns `200`, but the data is disc 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. Other signals are rejected with `403`. | -| **Rate limit** | Requests per second across all ingest servers. Requests over it get `429` with a `Retry-After` header. | -| **Resource attributes** | Written on every resource the key sends. **Always set** 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`. | +| Setting | What it does | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Allowed signals** | Traces, logs, metrics, sessions (replays and session events) and events. 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 set** 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. Edits, disabling, rolling and deleting reach the ingest servers within a minute. diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx index ba6a2dda49..a15c650a96 100644 --- a/apps/web/src/components/settings/custom-ingest-keys-section.tsx +++ b/apps/web/src/components/settings/custom-ingest-keys-section.tsx @@ -54,6 +54,7 @@ const MODE_LABELS = { } satisfies Record const EXPIRY_LABELS = { + keep: "Keep current", never: "Never", "30": "In 30 days", "90": "In 90 days", @@ -61,7 +62,7 @@ const EXPIRY_LABELS = { } as const type ExpiryChoice = keyof typeof EXPIRY_LABELS const toExpiryChoice = (value: string | null): ExpiryChoice => - value === "30" || value === "90" || value === "365" ? value : "never" + value === "keep" || value === "30" || value === "90" || value === "365" ? value : "never" const toMode = (value: string | null): IngestKeyAttributeMode => (value === "default" ? "default" : "set") @@ -125,7 +126,7 @@ export function CustomIngestKeysSection() { const keys = Result.builder(listResult) .onSuccess((response) => [...response.data]) - .orElse(() => [] as V2CustomIngestKey[]) + .orElse((): ReadonlyArray => []) const now = Date.now() function openCreateDialog() { @@ -148,6 +149,7 @@ export function CustomIngestKeysSection() { setFormAttributes( key.resource_attributes.map((attribute) => ({ rowId: crypto.randomUUID(), ...attribute })), ) + setFormExpiry("keep") setDialogOpen(true) } @@ -157,14 +159,19 @@ export function CustomIngestKeysSection() { rateLimit === null || (Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 100_000) const attributes = formAttributes.filter((attribute) => attribute.key.trim().length > 0) const attributeKeys = attributes.map((attribute) => attribute.key.trim()) - const reservedAttribute = attributeKeys.find((key) => key === "org_id" || key.startsWith("maple_")) + const reservedAttribute = attributeKeys.find((key) => { + const lower = key.toLowerCase() + return lower === "org_id" || lower.startsWith("maple_") + }) + const missingValue = attributes.find((attribute) => attribute.value.length === 0) const duplicateAttribute = attributeKeys.find((key, index) => attributeKeys.indexOf(key) !== index) const formValid = formName.trim().length > 0 && formSignals.length > 0 && rateLimitValid && reservedAttribute === undefined && - duplicateAttribute === undefined + duplicateAttribute === undefined && + missingValue === undefined const [handleSave, isSaving] = useAsyncAction(async () => { if (!formValid) return @@ -178,8 +185,16 @@ export function CustomIngestKeysSection() { rate_limit_rps: rateLimit, resource_attributes: attributes.map(({ key, value, mode }) => ({ key: key.trim(), value, mode })), } + // "keep" leaves the stored expiry untouched; it is only offered when editing. + const expiresAt = + formExpiry === "keep" + ? undefined + : formExpiry === "never" + ? null + : new Date(Date.now() + Number(formExpiry) * 86_400_000).toISOString() if (editing) { - const ok = toastExit(await updateMutation({ params: { id: editing.id }, payload }), { + const update = expiresAt === undefined ? payload : { ...payload, expires_at: expiresAt } + const ok = toastExit(await updateMutation({ params: { id: editing.id }, payload: update }), { success: "Ingest key updated. The gateway picks it up within a minute.", error: "Failed to update ingest key", }) @@ -188,11 +203,7 @@ export function CustomIngestKeysSection() { refreshKeys() return } - const expiresAt = - formExpiry === "never" - ? null - : new Date(Date.now() + Number(formExpiry) * 86_400_000).toISOString() - const result = await createMutation({ payload: { ...payload, expires_at: expiresAt } }) + const result = await createMutation({ payload: { ...payload, expires_at: expiresAt ?? null } }) if (!toastExit(result, { error: "Failed to create ingest key" })) return if (result._tag === "Success") setRevealed({ secret: result.value, rolled: false }) setDialogOpen(false) @@ -397,6 +408,7 @@ export function CustomIngestKeysSection() { setFormDescription(e.target.value)} /> @@ -442,7 +454,7 @@ export function CustomIngestKeysSection() { /> {rateLimitValid - ? "Across all ingest servers. Requests over the limit get a 429 with Retry-After." + ? "Split across the ingest servers. Requests over the limit get a 429 with Retry-After." : "Enter a whole number between 1 and 100000, or leave it empty."} @@ -512,30 +524,37 @@ export function CustomIngestKeysSection() { ? `"${reservedAttribute}" is reserved by Maple.` : duplicateAttribute !== undefined ? `"${duplicateAttribute}" is listed twice.` - : "Written on every resource sent with this key. Rows with an empty key are ignored."} + : missingValue !== undefined + ? `Give "${missingValue.key.trim()}" a value, or remove it.` + : "Written on every resource the key sends over OTLP. Rows with an empty key are ignored."} - {!editing && ( - - Expires - setFormExpiry(toExpiryChoice(value))} + > + + + + + {Object.entries(EXPIRY_LABELS) + .filter(([value]) => editing !== null || value !== "keep") + .map(([value, label]) => ( {label} ))} - - - - )} + + + {editing !== null && editing.expires_at !== null && formExpiry === "keep" && ( + + Currently {formatRelativeTime(editing.expires_at)}. + + )} +

!open && setRevealed(null)}> From 1a0068862404f9c1abd9cd4bdf90d01391c5739b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:22:51 +0200 Subject: [PATCH 05/14] fix(web): say Expired or Expires for the stored expiry --- .../web/src/components/settings/custom-ingest-keys-section.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx index a15c650a96..a582967b6a 100644 --- a/apps/web/src/components/settings/custom-ingest-keys-section.tsx +++ b/apps/web/src/components/settings/custom-ingest-keys-section.tsx @@ -551,7 +551,8 @@ export function CustomIngestKeysSection() { {editing !== null && editing.expires_at !== null && formExpiry === "keep" && ( - Currently {formatRelativeTime(editing.expires_at)}. + {isExpired(editing, now) ? "Expired" : "Expires"}{" "} + {formatRelativeTime(editing.expires_at)}. )} From e0ed30aedbfe36e919649718b067705b52233249 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:38:15 +0200 Subject: [PATCH 06/14] docs: browser track() calls count as events for custom keys --- apps/landing/src/content/docs/reference/authentication.md | 2 +- apps/web/src/components/settings/custom-ingest-keys-section.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md index 7699872cd8..6311eebc4d 100644 --- a/apps/landing/src/content/docs/reference/authentication.md +++ b/apps/landing/src/content/docs/reference/authentication.md @@ -49,7 +49,7 @@ Beside the default pair you can create named keys under **Settings → Ingestion | Setting | What it does | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Allowed signals** | Traces, logs, metrics, sessions (replays and session events) and events. Other signals are rejected with `403`. | +| **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 set** 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`. | diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx index a582967b6a..a402b06fb4 100644 --- a/apps/web/src/components/settings/custom-ingest-keys-section.tsx +++ b/apps/web/src/components/settings/custom-ingest-keys-section.tsx @@ -45,7 +45,7 @@ const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: stri { value: "logs", label: "Logs", hint: "/v1/logs" }, { value: "metrics", label: "Metrics", hint: "/v1/metrics" }, { value: "sessions", label: "Sessions", hint: "Replays and session events" }, - { value: "events", label: "Events", hint: "Product events" }, + { value: "events", label: "Events", hint: "Product events, incl. browser track()" }, ] const MODE_LABELS = { From 6d4eff0eb9fddb1c748c60ab12ba761f254c3b63 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 19:46:25 +0200 Subject: [PATCH 07/14] test(web): pin custom key form validation and the one-time reveal --- ...ustom-ingest-keys-section.browser.test.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx new file mode 100644 index 0000000000..e4e20c6277 --- /dev/null +++ b/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx @@ -0,0 +1,116 @@ +// 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 +// and the section is exercised through its rendered controls. +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { Exit, Schema } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" +import { V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" + +type CreateResult = Exit.Exit + +const createMutation = vi.fn<(input: { payload: Record }) => Promise>() +const otherMutation = vi.fn(async () => Exit.succeed({})) + +vi.mock("@/lib/effect-atom", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAtomValue: () => actual.Result.success({ data: [] }), + useAtomRefresh: () => () => {}, + useAtomSet: (atom: string) => (atom === "createCustom" ? createMutation : otherMutation), + } +}) +vi.mock("@/lib/services/common/v2-atom-client", () => ({ + MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name }, +})) +vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" })) + +import { CustomIngestKeysSection } from "./custom-ingest-keys-section" + +const SECRET = "maple_ik_Q7xZa8Kt7pQ2wR5vN0sL3dJ6hM4gY9dW1" + +const created = Schema.decodeUnknownSync(V2CustomIngestKeyWithSecret)({ + id: "ingk_aXunrkWtzvXSkw9Z2smcft", + object: "custom_ingest_key", + name: "Checkout staging", + description: null, + key_prefix: "maple_ik_Q7xZa", + enabled: true, + allowed_signals: null, + rate_limit_rps: null, + resource_attributes: [{ key: "service.name", value: "checkout", mode: "default" }], + expires_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", + secret: SECRET, +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const submitButton = () => + screen.getAllByRole("button", { name: "Create key" }).find((button) => button.closest("[role=dialog]")) + +const typeInto = (element: HTMLElement, value: string) => fireEvent.change(element, { target: { value } }) + +describe("CustomIngestKeysSection", () => { + it("blocks submit on a valueless, reserved or duplicate attribute, then reveals the one-time secret", async () => { + createMutation.mockResolvedValue(Exit.succeed(created)) + render() + + const [openCreate] = screen.getAllByRole("button", { name: "Create key" }) + if (openCreate) fireEvent.click(openCreate) + typeInto(await screen.findByLabelText("Name"), "Checkout staging") + + // The pre-filled service.name row has no value yet. + expect(submitButton()).toHaveProperty("disabled", true) + expect(screen.getByText('Give "service.name" a value, or remove it.')).toBeDefined() + + const [key] = screen.getAllByLabelText("Attribute key") + const [value] = screen.getAllByLabelText("Attribute value") + if (key === undefined || value === undefined) throw new Error("attribute row missing") + typeInto(value, "checkout") + expect(submitButton()).toHaveProperty("disabled", false) + + typeInto(key, "Maple_Org_Id") + expect(submitButton()).toHaveProperty("disabled", true) + expect(screen.getByText('"Maple_Org_Id" is reserved by Maple.')).toBeDefined() + typeInto(key, "service.name") + + fireEvent.click(screen.getByRole("button", { name: "Add attribute" })) + const keys = screen.getAllByLabelText("Attribute key") + const values = screen.getAllByLabelText("Attribute value") + const [, secondKey] = keys + const [, secondValue] = values + if (secondKey === undefined || secondValue === undefined) throw new Error("second row missing") + typeInto(secondKey, "service.name") + typeInto(secondValue, "other") + expect(submitButton()).toHaveProperty("disabled", true) + expect(screen.getByText('"service.name" is listed twice.')).toBeDefined() + fireEvent.click(screen.getAllByRole("button", { name: "Remove attribute" })[1] ?? document.body) + + const submit = submitButton() + expect(submit).toHaveProperty("disabled", false) + if (submit) fireEvent.click(submit) + + expect(await screen.findByText("Ingest key created")).toBeDefined() + expect(createMutation).toHaveBeenCalledWith({ + payload: { + name: "Checkout staging", + description: null, + allowed_signals: null, + rate_limit_rps: null, + resource_attributes: [{ key: "service.name", value: "checkout", mode: "default" }], + expires_at: null, + }, + }) + + // Masked until revealed; revealing shows the exact secret the API returned. + const field = screen.getByDisplayValue((text) => text.startsWith("maple_ik_Q7xZa") && text !== SECRET) + fireEvent.click(screen.getByRole("button", { name: "Reveal key" })) + expect(field).toHaveProperty("value", SECRET) + }) +}) From 94e44f8856b2cc555245cdcc47f8c07e0ca37ae3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:38:47 +0200 Subject: [PATCH 08/14] feat(web): ingest keys get a keys tab, a full-page form and a key page Ingestion splits into Setup, Keys and Attribute mappings. Keys is one table of the default and custom keys with status and last data. New and configure are a full-page form with a live preview of how each resource attribute lands. A key's page shows the one-time secret with paste-ready snippets, status cards, 24h span and log volume, its configuration, and pause (for an hour, a day or until resumed) in place of the enable switch. --- .../content/docs/reference/authentication.md | 18 +- ...ustom-ingest-keys-section.browser.test.tsx | 116 ---- .../settings/custom-ingest-keys-section.tsx | 628 ------------------ .../settings/ingest-keys/key-detail.tsx | 596 +++++++++++++++++ .../ingest-keys/key-form.browser.test.tsx | 106 +++ .../settings/ingest-keys/key-form.tsx | 477 +++++++++++++ .../settings/ingest-keys/key-status.tsx | 37 ++ .../settings/ingest-keys/keys-table.tsx | 248 +++++++ .../settings/ingest-keys/navigation.ts | 46 ++ .../components/settings/ingest-keys/shared.ts | 70 ++ .../components/settings/ingestion-section.tsx | 153 ++++- apps/web/src/routes/settings.tsx | 19 +- 12 files changed, 1752 insertions(+), 762 deletions(-) delete mode 100644 apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx delete mode 100644 apps/web/src/components/settings/custom-ingest-keys-section.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/key-detail.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/key-form.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/key-status.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/keys-table.tsx create mode 100644 apps/web/src/components/settings/ingest-keys/navigation.ts create mode 100644 apps/web/src/components/settings/ingest-keys/shared.ts diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md index 6311eebc4d..6fbf122c0f 100644 --- a/apps/landing/src/content/docs/reference/authentication.md +++ b/apps/landing/src/content/docs/reference/authentication.md @@ -11,7 +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 → Custom keys** | Send the signals you allow, up to its limit | 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 | @@ -47,14 +47,16 @@ The literal key `MAPLE_TEST` is accepted and returns `200`, but the data is disc 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 set** 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`. | +| 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. Edits, disabling, rolling and deleting reach the ingest servers within a minute. +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 diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx deleted file mode 100644 index e4e20c6277..0000000000 --- a/apps/web/src/components/settings/custom-ingest-keys-section.browser.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// 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 -// and the section is exercised through its rendered controls. -import { cleanup, fireEvent, render, screen } from "@testing-library/react" -import { Exit, Schema } from "effect" -import { afterEach, describe, expect, it, vi } from "vitest" -import { V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" - -type CreateResult = Exit.Exit - -const createMutation = vi.fn<(input: { payload: Record }) => Promise>() -const otherMutation = vi.fn(async () => Exit.succeed({})) - -vi.mock("@/lib/effect-atom", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useAtomValue: () => actual.Result.success({ data: [] }), - useAtomRefresh: () => () => {}, - useAtomSet: (atom: string) => (atom === "createCustom" ? createMutation : otherMutation), - } -}) -vi.mock("@/lib/services/common/v2-atom-client", () => ({ - MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name }, -})) -vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" })) - -import { CustomIngestKeysSection } from "./custom-ingest-keys-section" - -const SECRET = "maple_ik_Q7xZa8Kt7pQ2wR5vN0sL3dJ6hM4gY9dW1" - -const created = Schema.decodeUnknownSync(V2CustomIngestKeyWithSecret)({ - id: "ingk_aXunrkWtzvXSkw9Z2smcft", - object: "custom_ingest_key", - name: "Checkout staging", - description: null, - key_prefix: "maple_ik_Q7xZa", - enabled: true, - allowed_signals: null, - rate_limit_rps: null, - resource_attributes: [{ key: "service.name", value: "checkout", mode: "default" }], - expires_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", - secret: SECRET, -}) - -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - -const submitButton = () => - screen.getAllByRole("button", { name: "Create key" }).find((button) => button.closest("[role=dialog]")) - -const typeInto = (element: HTMLElement, value: string) => fireEvent.change(element, { target: { value } }) - -describe("CustomIngestKeysSection", () => { - it("blocks submit on a valueless, reserved or duplicate attribute, then reveals the one-time secret", async () => { - createMutation.mockResolvedValue(Exit.succeed(created)) - render() - - const [openCreate] = screen.getAllByRole("button", { name: "Create key" }) - if (openCreate) fireEvent.click(openCreate) - typeInto(await screen.findByLabelText("Name"), "Checkout staging") - - // The pre-filled service.name row has no value yet. - expect(submitButton()).toHaveProperty("disabled", true) - expect(screen.getByText('Give "service.name" a value, or remove it.')).toBeDefined() - - const [key] = screen.getAllByLabelText("Attribute key") - const [value] = screen.getAllByLabelText("Attribute value") - if (key === undefined || value === undefined) throw new Error("attribute row missing") - typeInto(value, "checkout") - expect(submitButton()).toHaveProperty("disabled", false) - - typeInto(key, "Maple_Org_Id") - expect(submitButton()).toHaveProperty("disabled", true) - expect(screen.getByText('"Maple_Org_Id" is reserved by Maple.')).toBeDefined() - typeInto(key, "service.name") - - fireEvent.click(screen.getByRole("button", { name: "Add attribute" })) - const keys = screen.getAllByLabelText("Attribute key") - const values = screen.getAllByLabelText("Attribute value") - const [, secondKey] = keys - const [, secondValue] = values - if (secondKey === undefined || secondValue === undefined) throw new Error("second row missing") - typeInto(secondKey, "service.name") - typeInto(secondValue, "other") - expect(submitButton()).toHaveProperty("disabled", true) - expect(screen.getByText('"service.name" is listed twice.')).toBeDefined() - fireEvent.click(screen.getAllByRole("button", { name: "Remove attribute" })[1] ?? document.body) - - const submit = submitButton() - expect(submit).toHaveProperty("disabled", false) - if (submit) fireEvent.click(submit) - - expect(await screen.findByText("Ingest key created")).toBeDefined() - expect(createMutation).toHaveBeenCalledWith({ - payload: { - name: "Checkout staging", - description: null, - allowed_signals: null, - rate_limit_rps: null, - resource_attributes: [{ key: "service.name", value: "checkout", mode: "default" }], - expires_at: null, - }, - }) - - // Masked until revealed; revealing shows the exact secret the API returned. - const field = screen.getByDisplayValue((text) => text.startsWith("maple_ik_Q7xZa") && text !== SECRET) - fireEvent.click(screen.getByRole("button", { name: "Reveal key" })) - expect(field).toHaveProperty("value", SECRET) - }) -}) diff --git a/apps/web/src/components/settings/custom-ingest-keys-section.tsx b/apps/web/src/components/settings/custom-ingest-keys-section.tsx deleted file mode 100644 index a402b06fb4..0000000000 --- a/apps/web/src/components/settings/custom-ingest-keys-section.tsx +++ /dev/null @@ -1,628 +0,0 @@ -import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import type { IngestKeyAttributeMode, IngestKeySignal } from "@maple/domain/http" -import type { V2CustomIngestKey, V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" -import { useState } from "react" - -import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" -import { Checkbox } from "@maple/ui/components/ui/checkbox" -import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" -import { CopyableField } from "@maple/ui/components/ui/copyable-field" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogTitle, -} from "@maple/ui/components/ui/dialog" -import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" -import { FormDialog } from "@maple/ui/components/ui/form-dialog" -import { IconButton } from "@maple/ui/components/ui/icon-button" -import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Input } from "@maple/ui/components/ui/input" -import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" -import { Switch } from "@maple/ui/components/ui/switch" -import { TruncatedText } from "@maple/ui/components/ui/truncated-text" -import { formatRelativeTime } from "@maple/ui/lib/time-format" -import { cn } from "@maple/ui/lib/utils" -import { ArrowPathIcon, KeyIcon, PencilIcon, PlusIcon, TrashIcon, XmarkIcon } from "@/components/icons" -import { ErrorState } from "@/components/common/error-state" -import { useAsyncAction, useKeyedAsyncAction } from "@/hooks/use-mutation-action" -import { toastExit } from "@/lib/error-toast" -import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" -import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { SettingsSection } from "./settings-section" - -const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: string }> = [ - { value: "traces", label: "Traces", hint: "/v1/traces" }, - { value: "logs", label: "Logs", hint: "/v1/logs" }, - { value: "metrics", label: "Metrics", hint: "/v1/metrics" }, - { value: "sessions", label: "Sessions", hint: "Replays and session events" }, - { value: "events", label: "Events", hint: "Product events, incl. browser track()" }, -] - -const MODE_LABELS = { - set: "Always set", - default: "If missing", -} satisfies Record - -const EXPIRY_LABELS = { - keep: "Keep current", - never: "Never", - "30": "In 30 days", - "90": "In 90 days", - "365": "In 1 year", -} as const -type ExpiryChoice = keyof typeof EXPIRY_LABELS -const toExpiryChoice = (value: string | null): ExpiryChoice => - value === "keep" || value === "30" || value === "90" || value === "365" ? value : "never" - -const toMode = (value: string | null): IngestKeyAttributeMode => (value === "default" ? "default" : "set") - -interface AttributeDraft { - readonly rowId: string - readonly key: string - readonly value: string - readonly mode: IngestKeyAttributeMode -} - -const newAttributeDraft = (key = "", mode: IngestKeyAttributeMode = "set"): AttributeDraft => ({ - rowId: crypto.randomUUID(), - key, - value: "", - mode, -}) - -const signalsSummary = (signals: V2CustomIngestKey["allowed_signals"]) => - signals === null - ? "All signals" - : SIGNALS.filter((signal) => signals.includes(signal.value)) - .map((signal) => signal.label) - .join(", ") - -const isExpired = (key: V2CustomIngestKey, now: number) => - key.expires_at !== null && new Date(key.expires_at).getTime() <= now - -export function CustomIngestKeysSection() { - const listResult = useAtomValue(customIngestKeysListAtom) - const refreshKeys = useAtomRefresh(customIngestKeysListAtom) - - const createMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "createCustom"), { - mode: "promiseExit", - }) - const updateMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "updateCustom"), { - mode: "promiseExit", - }) - const rollMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "rollCustom"), { - mode: "promiseExit", - }) - const deleteMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "deleteCustom"), { - mode: "promiseExit", - }) - - const [dialogOpen, setDialogOpen] = useState(false) - const [editing, setEditing] = useState(null) - const [formName, setFormName] = useState("") - const [formDescription, setFormDescription] = useState("") - const [formSignals, setFormSignals] = useState>( - SIGNALS.map((s) => s.value), - ) - const [formRateLimit, setFormRateLimit] = useState("") - const [formAttributes, setFormAttributes] = useState>([]) - const [formExpiry, setFormExpiry] = useState("never") - - const [revealed, setRevealed] = useState<{ secret: V2CustomIngestKeyWithSecret; rolled: boolean } | null>( - null, - ) - const [rollConfirm, setRollConfirm] = useState(null) - const [deleteConfirm, setDeleteConfirm] = useState(null) - - const keys = Result.builder(listResult) - .onSuccess((response) => [...response.data]) - .orElse((): ReadonlyArray => []) - const now = Date.now() - - function openCreateDialog() { - setEditing(null) - setFormName("") - setFormDescription("") - setFormSignals(SIGNALS.map((s) => s.value)) - setFormRateLimit("") - setFormAttributes([newAttributeDraft("service.name", "default")]) - setFormExpiry("never") - setDialogOpen(true) - } - - function openEditDialog(key: V2CustomIngestKey) { - setEditing(key) - setFormName(key.name) - setFormDescription(key.description ?? "") - setFormSignals(key.allowed_signals ?? SIGNALS.map((s) => s.value)) - setFormRateLimit(key.rate_limit_rps === null ? "" : String(key.rate_limit_rps)) - setFormAttributes( - key.resource_attributes.map((attribute) => ({ rowId: crypto.randomUUID(), ...attribute })), - ) - setFormExpiry("keep") - setDialogOpen(true) - } - - const rateLimitText = formRateLimit.trim() - const rateLimit = rateLimitText.length === 0 ? null : Number(rateLimitText) - const rateLimitValid = - rateLimit === null || (Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 100_000) - const attributes = formAttributes.filter((attribute) => attribute.key.trim().length > 0) - const attributeKeys = attributes.map((attribute) => attribute.key.trim()) - const reservedAttribute = attributeKeys.find((key) => { - const lower = key.toLowerCase() - return lower === "org_id" || lower.startsWith("maple_") - }) - const missingValue = attributes.find((attribute) => attribute.value.length === 0) - const duplicateAttribute = attributeKeys.find((key, index) => attributeKeys.indexOf(key) !== index) - const formValid = - formName.trim().length > 0 && - formSignals.length > 0 && - rateLimitValid && - reservedAttribute === undefined && - duplicateAttribute === undefined && - missingValue === undefined - - const [handleSave, isSaving] = useAsyncAction(async () => { - if (!formValid) return - const allSignals = formSignals.length === SIGNALS.length - const payload = { - name: formName.trim(), - description: formDescription.trim().length > 0 ? formDescription.trim() : null, - allowed_signals: allSignals - ? null - : SIGNALS.map((s) => s.value).filter((s) => formSignals.includes(s)), - rate_limit_rps: rateLimit, - resource_attributes: attributes.map(({ key, value, mode }) => ({ key: key.trim(), value, mode })), - } - // "keep" leaves the stored expiry untouched; it is only offered when editing. - const expiresAt = - formExpiry === "keep" - ? undefined - : formExpiry === "never" - ? null - : new Date(Date.now() + Number(formExpiry) * 86_400_000).toISOString() - if (editing) { - const update = expiresAt === undefined ? payload : { ...payload, expires_at: expiresAt } - const ok = toastExit(await updateMutation({ params: { id: editing.id }, payload: update }), { - success: "Ingest key updated. The gateway picks it up within a minute.", - error: "Failed to update ingest key", - }) - if (!ok) return - setDialogOpen(false) - refreshKeys() - return - } - const result = await createMutation({ payload: { ...payload, expires_at: expiresAt ?? null } }) - if (!toastExit(result, { error: "Failed to create ingest key" })) return - if (result._tag === "Success") setRevealed({ secret: result.value, rolled: false }) - setDialogOpen(false) - refreshKeys() - }) - - const [handleRoll, isRolling] = useAsyncAction(async () => { - if (!rollConfirm) return - const result = await rollMutation({ params: { id: rollConfirm.id } }) - setRollConfirm(null) - if (!toastExit(result, { error: "Failed to roll ingest key" })) return - if (result._tag === "Success") setRevealed({ secret: result.value, rolled: true }) - refreshKeys() - }) - - async function handleDelete(key: V2CustomIngestKey) { - const ok = toastExit(await deleteMutation({ params: { id: key.id } }), { - success: "Ingest key deleted", - error: "Failed to delete ingest key", - }) - if (ok) refreshKeys() - return ok - } - - const toggle = useKeyedAsyncAction(async (_id: string, key: V2CustomIngestKey) => { - const result = await updateMutation({ params: { id: key.id }, payload: { enabled: !key.enabled } }) - if (toastExit(result, { error: "Failed to update ingest key" })) refreshKeys() - }) - - const updateAttribute = (rowId: string, patch: Partial>) => - setFormAttributes((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))) - - const keyCount = Result.isSuccess(listResult) ? keys.length : null - - return ( - <> - 0 ? `Custom keys · ${keyCount}` : "Custom keys"} - description="Named keys for a team, service or environment, each with its own allowed signals, rate limit and resource attributes. The default keys keep working." - padded={false} - actions={ - - } - > - {Result.isInitial(listResult) ? ( - ( -
-
- - -
- -
- )} - /> - ) : !Result.isSuccess(listResult) ? ( - refreshKeys()} - className="border-0" - /> - ) : keys.length === 0 ? ( - - - - - - No custom keys yet - - Give a team or environment its own key to cap its request rate, stamp a{" "} - service.name on everything it sends, - or revoke it without touching anyone else. - - - - - ) : ( -
- {keys.map((key) => { - const expired = isExpired(key, now) - return ( -
-
-
- - {key.name} - - - {key.key_prefix}… - - {expired && ( - - Expired - - )} -
-
- {signalsSummary(key.allowed_signals)} - - {key.rate_limit_rps === null - ? "No rate limit" - : `${key.rate_limit_rps} req/s`} - - {key.resource_attributes.length > 0 && ( - - {key.resource_attributes.map((attribute) => ( - - {attribute.key}={attribute.value} - - ))} - - )} - {key.expires_at !== null && !expired && ( - Expires {formatRelativeTime(key.expires_at)} - )} -
-
-
- openEditDialog(key)} - label={`Edit ${key.name}`} - > - - - void toggle.run(key.id, key)} - disabled={toggle.isPending(key.id)} - /> - - setRollConfirm(key)}> - - Roll secret - - setDeleteConfirm(key)} - > - - Delete key - - -
-
- ) - })} -
- )} -
- - void handleSave()} - submitLabel={editing ? "Save changes" : "Create key"} - pending={isSaving} - submitDisabled={!formValid} - > - - Name - setFormName(e.target.value)} - /> - - - Description - setFormDescription(e.target.value)} - /> - - - Allowed signals -
- {SIGNALS.map((signal) => ( - - ))} -
- {formSignals.length === 0 && ( - Allow at least one signal. - )} -
- - Rate limit (requests per second) - setFormRateLimit(e.target.value)} - /> - - {rateLimitValid - ? "Split across the ingest servers. Requests over the limit get a 429 with Retry-After." - : "Enter a whole number between 1 and 100000, or leave it empty."} - - - - Resource attributes -
- {formAttributes.map((attribute) => ( -
- - updateAttribute(attribute.rowId, { key: e.target.value }) - } - /> - - updateAttribute(attribute.rowId, { value: e.target.value }) - } - /> - - - setFormAttributes((rows) => - rows.filter((row) => row.rowId !== attribute.rowId), - ) - } - > - - -
- ))} - -
- - {reservedAttribute !== undefined - ? `"${reservedAttribute}" is reserved by Maple.` - : duplicateAttribute !== undefined - ? `"${duplicateAttribute}" is listed twice.` - : missingValue !== undefined - ? `Give "${missingValue.key.trim()}" a value, or remove it.` - : "Written on every resource the key sends over OTLP. Rows with an empty key are ignored."} - -
- - Expires - - {editing !== null && editing.expires_at !== null && formExpiry === "keep" && ( - - {isExpired(editing, now) ? "Expired" : "Expires"}{" "} - {formatRelativeTime(editing.expires_at)}. - - )} - -
- - !open && setRevealed(null)}> - - - {revealed?.rolled ? "Secret rolled" : "Ingest key created"} - - Copy the key now. You won't be able to see it again. - {revealed?.rolled && " The previous secret stops working within a minute."} - - - {revealed && ( - -
- - {revealed.secret.name} - - - {signalsSummary(revealed.secret.allowed_signals)} - -
- -

- Send it as{" "} - Authorization: Bearer <key> to - the same OTLP endpoint as the default keys. -

-
- )} - - - -
-
- - !open && setRollConfirm(null)} - title="Roll this key's secret?" - description={ - <> - {rollConfirm?.name} gets a new - secret and keeps its settings. Senders using the old secret are rejected within a - minute. - - } - confirmLabel="Roll secret" - onConfirm={() => void handleRoll()} - pending={isRolling} - /> - - !open && setDeleteConfirm(null)} - title="Delete ingest key?" - description={ - <> - Senders using{" "} - {deleteConfirm?.name} are - rejected within a minute. This cannot be undone. - - } - confirmLabel="Delete" - onConfirm={() => (deleteConfirm ? handleDelete(deleteConfirm) : undefined)} - /> - - ) -} diff --git a/apps/web/src/components/settings/ingest-keys/key-detail.tsx b/apps/web/src/components/settings/ingest-keys/key-detail.tsx new file mode 100644 index 0000000000..1a4d30d0ed --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-detail.tsx @@ -0,0 +1,596 @@ +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import type { V2CustomIngestKey, V2CustomIngestKeyUsage } from "@maple/domain/http/v2" +import { Link } from "@tanstack/react-router" +import type * as React from "react" +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { CopyableField } from "@maple/ui/components/ui/copyable-field" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu" +import { Panel } from "@maple/ui/components/ui/panel" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { formatNumber } from "@maple/ui/lib/format" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { ArrowPathIcon, MediaPauseIcon, MediaPlayIcon, SlidersIcon, TrashIcon } from "@/components/icons" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { ingestUrl } from "@/lib/services/common/ingest-url" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { KeyStatusMark } from "./key-status" +import { + ACTIVITY_LABELS, + keyActivity, + MODE_LABELS, + pausedLabel, + rateLimitLabel, + type RevealedSecret, + signalsSummary, + toPublicKeyId, +} from "./shared" + +const HOUR_MS = 3_600_000 + +const PAUSE_CHOICES = [ + { label: "For 1 hour", hours: 1 }, + { label: "For 24 hours", hours: 24 }, + { label: "Until I resume", hours: null }, +] as const + +const SNIPPETS = { + env: { + label: "Environment", + render: (secret: string) => + `OTEL_EXPORTER_OTLP_ENDPOINT=${ingestUrl}\nOTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${secret}"`, + }, + collector: { + label: "OTel Collector", + render: (secret: string) => + `exporters:\n otlphttp/maple:\n endpoint: ${ingestUrl}\n headers:\n Authorization: "Bearer ${secret}"`, + }, + browser: { + label: "Browser SDK", + render: (secret: string) => + `import { MapleBrowser } from "@maple-dev/browser"\n\nMapleBrowser.init({\n ingestKey: "${secret}",\n endpoint: "${ingestUrl}",\n})`, + }, +} as const +type SnippetId = keyof typeof SNIPPETS +const isSnippetId = (value: unknown): value is SnippetId => + typeof value === "string" && Object.hasOwn(SNIPPETS, value) + +function SecretCard({ revealed }: { revealed: RevealedSecret }) { + const [snippet, setSnippet] = useState("env") + const code = SNIPPETS[snippet].render(revealed.secret) + return ( + +
+ Secret + Shown once +
+ + + isSnippetId(value) && setSnippet(value)}> +
+ + {Object.entries(SNIPPETS).map(([id, entry]) => ( + + {entry.label} + + ))} + + +
+
+
{code}
+
+
+ ) +} + +function Banner({ + tone, + title, + description, + action, +}: { + tone: "ok" | "warn" | "muted" + title: string + description?: string + action?: React.ReactNode +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action} +
+ ) +} + +function StatCard({ + label, + value, + hint, + valueClassName, +}: { + label: string + value: React.ReactNode + hint: string + valueClassName?: string +}) { + return ( + + {label} + {value} + {hint} + + ) +} + +/** 24 hourly stacked bars, spans over logs; hours without data stay empty. */ +function UsageChart({ usage }: { usage: V2CustomIngestKeyUsage }) { + const end = Math.floor(new Date(usage.window_end).getTime() / HOUR_MS) * HOUR_MS + const byHour = new Map(usage.buckets.map((bucket) => [new Date(bucket.hour).getTime(), bucket])) + const hours = Array.from({ length: 24 }, (_, index) => { + const hour = end - (23 - index) * HOUR_MS + const bucket = byHour.get(hour) + return { hour, spans: bucket?.spans ?? 0, logs: bucket?.logs ?? 0 } + }) + const peak = Math.max(1, ...hours.map((hour) => hour.spans + hour.logs)) + const width = 100 / hours.length + return ( + + {hours.map((hour, index) => { + const logs = (hour.logs / peak) * 40 + const spans = (hour.spans / peak) * 40 + const x = index * width + width * 0.12 + const barWidth = width * 0.76 + return ( + + + {`${new Date(hour.hour).toLocaleTimeString(undefined, { hour: "numeric" })}: ${formatNumber(hour.spans)} spans, ${formatNumber(hour.logs)} logs`} + + + + + ) + })} + + ) +} + +function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +export function IngestKeyDetail({ + keyId, + revealed, + onRevealed, + onConfigure, + onDeleted, +}: { + keyId: V2CustomIngestKey["id"] + revealed: RevealedSecret | null + onRevealed: (revealed: RevealedSecret) => void + onConfigure: () => void + onDeleted: () => void +}) { + const listResult = useAtomValue(customIngestKeysListAtom) + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const usageResult = useAtomValue(retainedQueryV2("ingestKeys", "usageCustom", { params: { id: keyId } })) + + const pauseMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "pauseCustom"), { + mode: "promiseExit", + }) + const resumeMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "resumeCustom"), { + mode: "promiseExit", + }) + const rollMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "rollCustom"), { + mode: "promiseExit", + }) + const deleteMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "deleteCustom"), { + mode: "promiseExit", + }) + const [rollOpen, setRollOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const [pause, pausing] = useAsyncAction(async (hours: number | null) => { + const until = hours === null ? null : new Date(Date.now() + hours * HOUR_MS).toISOString() + const result = await pauseMutation({ params: { id: keyId }, payload: { until } }) + if ( + toastExit(result, { + success: "Ingestion paused. Takes effect within a minute.", + error: "Failed to pause key", + }) + ) + refreshKeys() + }) + const [resume, resuming] = useAsyncAction(async () => { + const result = await resumeMutation({ params: { id: keyId } }) + if ( + toastExit(result, { + success: "Ingestion resumed. Takes effect within a minute.", + error: "Failed to resume key", + }) + ) + refreshKeys() + }) + const [roll, rolling] = useAsyncAction(async () => { + const result = await rollMutation({ params: { id: keyId } }) + setRollOpen(false) + if (!toastExit(result, { error: "Failed to roll secret" })) return + refreshKeys() + if (result._tag === "Success") onRevealed({ keyId, secret: result.value.secret, rolled: true }) + }) + async function remove() { + const ok = toastExit(await deleteMutation({ params: { id: keyId } }), { + success: "Ingest key deleted", + error: "Failed to delete ingest key", + }) + if (ok) { + refreshKeys() + onDeleted() + } + return ok + } + + if (Result.isInitial(listResult)) { + return ( +
+ + + +
+ ) + } + const key = Result.builder(listResult) + .onSuccess((response) => response.data.find((candidate) => candidate.id === keyId) ?? null) + .orElse(() => null) + if (key === null) { + return ( + + ) + } + + const now = Date.now() + const activity = keyActivity(key, now) + const publicId = toPublicKeyId(key.id) + const showSecret = revealed !== null && revealed.keyId === key.id + const usage = Result.builder(usageResult) + .onSuccess((value): V2CustomIngestKeyUsage | null => value) + .orElse(() => null) + const totals = usage?.buckets.reduce( + (sum, bucket) => ({ spans: sum.spans + bucket.spans, logs: sum.logs + bucket.logs }), + { spans: 0, logs: 0 }, + ) + + return ( +
+
+
+ +
+

+ {key.name} +

+

+ {activity === "paused" ? pausedLabel(key) : ACTIVITY_LABELS[activity]} ·{" "} + {key.key_prefix}… · created {formatRelativeTime(key.created_at, now)} +

+
+
+ {key.description !== null && ( +

{key.description}

+ )} +
+ + + {activity === "paused" ? ( + + ) : ( + + + } + > + + Pause + + +
+

Pause ingestion

+

+ Senders keep getting 200 OK. Data is dropped, not stored or billed. +

+
+ + + {PAUSE_CHOICES.map((choice) => ( + void pause(choice.hours)} + > + {choice.label} + + ))} + +
+
+ )} + + +
+
+ + {showSecret && ( + + )} + {activity === "paused" && ( + void resume()} loading={resuming}> + Resume now + + } + /> + )} + {activity === "expired" && key.expires_at !== null && ( + + )} + {showSecret && } + +
+ + Unavailable + ) : totals === undefined ? ( + + ) : ( + `${formatNumber(totals.spans)} spans · ${formatNumber(totals.logs)} logs` + ) + } + hint="Stored with this key" + /> + +
+ + +
+ Volume · last 24h +
+ + + Spans + + + + Logs + +
+
+ {usage === null ? ( + Result.isFailure(usageResult) ? ( +

+ Volume is unavailable right now. +

+ ) : ( + + ) + ) : usage.buckets.length === 0 ? ( +

+ No spans or logs stored with this key in the last 24 hours. +

+ ) : ( + + )} +
+ + +
+ Configuration + +
+ {signalsSummary(key.allowed_signals)} + {rateLimitLabel(key.rate_limit_rps)} + + {key.expires_at === null ? "Never" : formatRelativeTime(key.expires_at, now)} + + + {key.resource_attributes.length === 0 ? ( + None + ) : ( +
+ {key.resource_attributes.map((attribute) => ( +
+ + {attribute.key} = {attribute.value} + + + {MODE_LABELS[attribute.mode].toLowerCase()} + +
+ ))} +
+ )} +
+ +
+ {key.key_prefix}•••••••••••••••• + + Hidden after creation. Roll to get a new one. + +
+
+
+ + + {key.name} gets a new secret and + keeps its settings. Senders using the old secret are rejected within a minute. + + } + confirmLabel="Roll secret" + onConfirm={() => void roll()} + pending={rolling} + /> + + Senders using {key.name} are + rejected within a minute. This cannot be undone. + + } + confirmLabel="Delete" + onConfirm={remove} + /> +
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx b/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx new file mode 100644 index 0000000000..e92fb25894 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx @@ -0,0 +1,106 @@ +// TEST-SEAM: the atom client is process-global wiring with no instance-level injection seam, +// so the hooks are replaced at the module boundary and the form is driven through its controls. +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { Exit, Schema } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" +import { V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" + +type CreateResult = Exit.Exit + +const createMutation = vi.fn<(input: { payload: Record }) => Promise>() +const updateMutation = vi.fn(async () => Exit.succeed({})) + +vi.mock("@/lib/effect-atom", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAtomRefresh: () => () => {}, + useAtomSet: (atom: string) => (atom === "createCustom" ? createMutation : updateMutation), + } +}) +vi.mock("@/lib/services/common/v2-atom-client", () => ({ + MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name }, +})) +vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" })) + +import { IngestKeyForm } from "./key-form" + +const SECRET = "maple_ik_Q7xZa8Kt7pQ2wR5vN0sL3dJ6hM4gY9dW1" + +const created = Schema.decodeUnknownSync(V2CustomIngestKeyWithSecret)({ + id: "ingk_aXunrkWtzvXSkw9Z2smcft", + object: "custom_ingest_key", + name: "Mobile app", + description: null, + key_prefix: "maple_ik_Q7xZa", + status: "active", + allowed_signals: ["traces", "logs", "sessions", "events"], + rate_limit_rps: 50, + resource_attributes: [{ key: "service.name", value: "mobile-app", mode: "default" }], + 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", + secret: SECRET, +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const typeInto = (element: HTMLElement, value: string) => fireEvent.change(element, { target: { value } }) +const submit = () => screen.getByRole("button", { name: "Create key" }) + +describe("IngestKeyForm", () => { + it("previews how each attribute lands, blocks reserved keys, and hands the one-time secret on", async () => { + createMutation.mockResolvedValue(Exit.succeed(created)) + const onCreated = vi.fn() + render() + + typeInto(screen.getByLabelText("Name"), "Mobile app") + const [, value] = [screen.getByLabelText("Attribute key"), screen.getByLabelText("Attribute value")] + // The pre-filled service.name row has no value yet. + expect(submit()).toHaveProperty("disabled", true) + typeInto(value, "mobile-app") + expect(submit()).toHaveProperty("disabled", false) + + // "If missing" keeps the sender's value; "Always" overwrites it. + expect(screen.getByText("kept, sender set it")).toBeDefined() + fireEvent.click(screen.getAllByRole("button", { name: "Always" })[0] ?? document.body) + expect(screen.getByText("checkout-api → mobile-app")).toBeDefined() + fireEvent.click(screen.getAllByRole("button", { name: "If missing" })[0] ?? document.body) + + fireEvent.click(screen.getByRole("button", { name: "Add attribute" })) + const [, secondKey] = screen.getAllByLabelText("Attribute key") + const [, secondValue] = screen.getAllByLabelText("Attribute value") + if (secondKey === undefined || secondValue === undefined) throw new Error("second row missing") + typeInto(secondKey, "Maple_Org_Id") + typeInto(secondValue, "org_other") + expect(submit()).toHaveProperty("disabled", true) + expect(screen.getByRole("alert").textContent).toBe('"Maple_Org_Id" is reserved by Maple.') + fireEvent.click(screen.getAllByRole("button", { name: "Remove attribute" })[1] ?? document.body) + + fireEvent.click(screen.getByText("/v1/metrics")) + typeInto(screen.getByLabelText("Rate limit"), "50") + fireEvent.click(submit()) + + await vi.waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)) + expect(createMutation).toHaveBeenCalledWith({ + payload: { + name: "Mobile app", + description: null, + allowed_signals: ["traces", "logs", "sessions", "events"], + rate_limit_rps: 50, + resource_attributes: [{ key: "service.name", value: "mobile-app", mode: "default" }], + expires_at: null, + }, + }) + const [key, revealed] = onCreated.mock.calls[0] ?? [] + expect(key).not.toHaveProperty("secret") + expect(revealed).toEqual({ keyId: created.id, secret: SECRET, rolled: false }) + }) +}) diff --git a/apps/web/src/components/settings/ingest-keys/key-form.tsx b/apps/web/src/components/settings/ingest-keys/key-form.tsx new file mode 100644 index 0000000000..299c1e934c --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-form.tsx @@ -0,0 +1,477 @@ +import { useAtomRefresh, useAtomSet } from "@/lib/effect-atom" +import type { IngestKeyAttributeMode, IngestKeySignal } from "@maple/domain/http" +import type { V2CustomIngestKey } from "@maple/domain/http/v2" +import type * as React from "react" +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Input } from "@maple/ui/components/ui/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@maple/ui/components/ui/input-group" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { PlusIcon, XmarkIcon } from "@/components/icons" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { MODE_LABELS, type RevealedSecret, SIGNALS } from "./shared" + +const MAX_ATTRIBUTES = 20 + +// An array, not an object: numeric-looking object keys would sort ahead of "never". +const EXPIRY_CHOICES = [ + { value: "keep", label: "Keep current" }, + { value: "never", label: "Never" }, + { value: "7", label: "7 days" }, + { value: "30", label: "30 days" }, + { value: "90", label: "90 days" }, +] as const +type ExpiryChoice = (typeof EXPIRY_CHOICES)[number]["value"] +const isExpiryChoice = (value: string): value is ExpiryChoice => + EXPIRY_CHOICES.some((choice) => choice.value === value) + +// What the attribute preview shows a typical sender sending. +const SAMPLE_RESOURCE = new Map([ + ["service.name", "checkout-api"], + ["deployment.environment", "production"], +]) + +interface AttributeDraft { + readonly rowId: string + readonly key: string + readonly value: string + readonly mode: IngestKeyAttributeMode +} + +const newAttributeDraft = (key = "", mode: IngestKeyAttributeMode = "set"): AttributeDraft => ({ + rowId: crypto.randomUUID(), + key, + value: "", + mode, +}) + +/** One form section: heading and the why on the left, the fields on the right. */ +function FormSection({ + title, + description, + children, +}: { + title: string + description: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ) +} + +function AttributePreview({ attributes }: { attributes: ReadonlyArray }) { + const rows = attributes.filter((attribute) => attribute.key.trim() !== "" && attribute.value !== "") + if (rows.length === 0) return null + return ( +
+

+ Preview: a sender sends{" "} + {[...SAMPLE_RESOURCE].map(([key, value]) => `${key}=${value}`).join(", ")} +

+ {rows.map((attribute) => { + const key = attribute.key.trim() + const sent = SAMPLE_RESOURCE.get(key) + const outcome = + sent === undefined + ? { value: attribute.value, note: "added", tone: "text-primary" } + : attribute.mode === "set" + ? { + value: `${sent} → ${attribute.value}`, + note: "overwritten", + tone: "text-primary", + } + : { value: sent, note: "kept, sender set it", tone: "text-muted-foreground" } + return ( +
+ {key} + {outcome.value} + {outcome.note} +
+ ) + })} +
+ ) +} + +/** Full-page form to create a key, or to configure `existing`. */ +export function IngestKeyForm({ + existing, + onCreated, + onSaved, + onCancel, +}: { + existing: V2CustomIngestKey | null + onCreated: (key: V2CustomIngestKey, revealed: RevealedSecret) => void + onSaved: (key: V2CustomIngestKey) => void + onCancel: () => void +}) { + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const createMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "createCustom"), { + mode: "promiseExit", + }) + const updateMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "updateCustom"), { + mode: "promiseExit", + }) + + const [name, setName] = useState(existing?.name ?? "") + const [description, setDescription] = useState(existing?.description ?? "") + const [signals, setSignals] = useState>( + existing?.allowed_signals ?? SIGNALS.map((signal) => signal.value), + ) + const [rateLimitText, setRateLimitText] = useState( + existing?.rate_limit_rps === null || existing === null ? "" : String(existing.rate_limit_rps), + ) + const [attributes, setAttributes] = useState>( + existing === null + ? [newAttributeDraft("service.name", "default")] + : existing.resource_attributes.map((attribute) => ({ rowId: crypto.randomUUID(), ...attribute })), + ) + const [expiry, setExpiry] = useState( + existing !== null && existing.expires_at !== null ? "keep" : "never", + ) + + const trimmedRate = rateLimitText.trim() + const rateLimit = trimmedRate.length === 0 ? null : Number(trimmedRate) + const rateLimitValid = + rateLimit === null || (Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 100_000) + const filled = attributes.filter((attribute) => attribute.key.trim().length > 0) + const attributeKeys = filled.map((attribute) => attribute.key.trim()) + const reservedAttribute = attributeKeys.find((key) => { + const lower = key.toLowerCase() + return lower === "org_id" || lower.startsWith("maple_") + }) + const missingValue = filled.find((attribute) => attribute.value.length === 0) + const duplicateAttribute = attributeKeys.find((key, index) => attributeKeys.indexOf(key) !== index) + const attributeProblem = + reservedAttribute !== undefined + ? `"${reservedAttribute}" is reserved by Maple.` + : duplicateAttribute !== undefined + ? `"${duplicateAttribute}" is listed twice.` + : missingValue !== undefined + ? `Give "${missingValue.key.trim()}" a value, or remove it.` + : null + const formValid = + name.trim().length > 0 && signals.length > 0 && rateLimitValid && attributeProblem === null + + const updateAttribute = (rowId: string, patch: Partial>) => + setAttributes((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))) + + const [handleSubmit, saving] = useAsyncAction(async () => { + if (!formValid) return + const payload = { + name: name.trim(), + description: description.trim().length > 0 ? description.trim() : null, + allowed_signals: + signals.length === SIGNALS.length + ? null + : SIGNALS.map((signal) => signal.value).filter((signal) => signals.includes(signal)), + rate_limit_rps: rateLimit, + resource_attributes: filled.map(({ key, value, mode }) => ({ key: key.trim(), value, mode })), + } + // "keep" leaves the stored expiry untouched; it is only offered when configuring. + const expiresAt = + expiry === "keep" + ? undefined + : expiry === "never" + ? null + : new Date(Date.now() + Number(expiry) * 86_400_000).toISOString() + + if (existing !== null) { + const result = await updateMutation({ + params: { id: existing.id }, + payload: expiresAt === undefined ? payload : { ...payload, expires_at: expiresAt }, + }) + if ( + !toastExit(result, { + success: "Ingest key saved. The gateway picks it up within a minute.", + error: "Failed to save ingest key", + }) + ) + return + refreshKeys() + if (result._tag === "Success") onSaved(result.value) + return + } + const result = await createMutation({ payload: { ...payload, expires_at: expiresAt ?? null } }) + if (!toastExit(result, { error: "Failed to create ingest key" })) return + refreshKeys() + if (result._tag === "Success") { + const { secret, ...key } = result.value + onCreated(key, { keyId: key.id, secret, rolled: false }) + } + }) + + const expiryChoices = EXPIRY_CHOICES.filter( + (choice) => choice.value !== "keep" || (existing !== null && existing.expires_at !== null), + ) + + return ( +
{ + event.preventDefault() + void handleSubmit() + }} + > +
+

+ {existing === null ? "New ingest key" : `Configure ${existing.name}`} +

+

+ {existing === null + ? "A named key for one team, service or environment. The default keys keep working." + : "Changes reach every ingest server within a minute."} +

+
+ + + + Name + setName(event.target.value)} + /> + + + + Note (optional) + + setDescription(event.target.value)} + /> + + + + +
+ {SIGNALS.map((signal) => { + const checked = signals.includes(signal.value) + return ( + + ) + })} +
+ {signals.length === 0 && Allow at least one signal.} +
+ + + + Rate limit +
+ + setRateLimitText(event.target.value)} + controlClassName="font-mono tabular-nums" + /> + + req/s + + + Leave empty for no limit +
+ + {rateLimitValid + ? "Split across the ingest servers, so short bursts can run a little over." + : "Enter a whole number between 1 and 100000, or leave it empty."} + +
+ + Expires + { + const next = values.find((value) => value !== expiry) + if (next !== undefined && isExpiryChoice(next)) setExpiry(next) + }} + > + {expiryChoices.map((choice) => ( + + {choice.label} + + ))} + + {existing !== null && existing.expires_at !== null && expiry === "keep" && ( + + {existing.status === "expired" ? "Expired" : "Expires"}{" "} + {formatRelativeTime(existing.expires_at)}. + + )} + +
+ + +
+ {attributes.length > 0 && ( +
+ Attribute + Value + When + +
+ )} + {attributes.map((attribute) => ( +
+ + updateAttribute(attribute.rowId, { key: event.target.value }) + } + /> + + updateAttribute(attribute.rowId, { value: event.target.value }) + } + /> + { + const next = values.find((value) => value !== attribute.mode) + if (next === "set" || next === "default") + updateAttribute(attribute.rowId, { mode: next }) + }} + > + + {MODE_LABELS.set} + + + {MODE_LABELS.default} + + + + setAttributes((rows) => + rows.filter((row) => row.rowId !== attribute.rowId), + ) + } + > + + +
+ ))} +
+ + {attributeProblem !== null && ( + + {attributeProblem} + + )} +
+
+ +
+ +
+ + {existing === null + ? "The secret is shown once, on the next screen." + : "Rolling the secret is on the key page."} + + + +
+
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/key-status.tsx b/apps/web/src/components/settings/ingest-keys/key-status.tsx new file mode 100644 index 0000000000..f9ddefbffe --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-status.tsx @@ -0,0 +1,37 @@ +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { cn } from "@maple/ui/lib/utils" +import type { KeyActivity } from "./shared" + +/** Dot for live and idle keys, a hollow ring while waiting, two bars while paused. */ +export function KeyStatusMark({ activity, large = false }: { activity: KeyActivity; large?: boolean }) { + if (activity === "paused") { + return ( + + + + + ) + } + if (activity === "waiting" || activity === "expired") { + return ( + + ) + } + return ( + + ) +} diff --git a/apps/web/src/components/settings/ingest-keys/keys-table.tsx b/apps/web/src/components/settings/ingest-keys/keys-table.tsx new file mode 100644 index 0000000000..51b5c3de9b --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/keys-table.tsx @@ -0,0 +1,248 @@ +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import type { V2CustomIngestKey } from "@maple/domain/http/v2" +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Input } from "@maple/ui/components/ui/input" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { ChevronRightIcon, KeyIcon, PlusIcon } from "@/components/icons" +import { ErrorState } from "@/components/common/error-state" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { KeyStatusMark } from "./key-status" +import { useIngestionNavigation } from "./navigation" +import { + ACTIVITY_LABELS, + attributeCountLabel, + keyActivity, + pausedLabel, + rateLimitLabel, + signalsSummary, + toPublicKeyId, +} from "./shared" + +const MAX_KEYS = 50 + +const keySubtitle = (key: V2CustomIngestKey, now: number) => { + const activity = keyActivity(key, now) + if (activity === "paused") return pausedLabel(key) + if (activity === "expired" || key.expires_at === null) return ACTIVITY_LABELS[activity] + return `${ACTIVITY_LABELS[activity]} · expires ${formatRelativeTime(key.expires_at, now)}` +} + +function DefaultKeyRow({ + label, + value, + audience, + onOpen, +}: { + label: string + value: string | null + audience: string + onOpen: () => void +}) { + return ( + + +
+ +
+ {label} + + {value === null ? "…" : `${value.slice(0, 14)}…`} · Default, {audience} + +
+
+
+ All signals + No limit + None + - + + + +
+ ) +} + +export function IngestKeysTable() { + const nav = useIngestionNavigation() + const [filter, setFilter] = useState("") + const listResult = useAtomValue(customIngestKeysListAtom) + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const defaultsResult = useAtomValue(retainedQueryV2("ingestKeys", "retrieve", {})) + const defaults = Result.builder(defaultsResult) + .onSuccess((keys) => ({ privateKey: keys.private_key, publicKey: keys.public_key })) + .orElse(() => ({ privateKey: null, publicKey: null })) + + const now = Date.now() + const keys = Result.builder(listResult) + .onSuccess((response) => [...response.data]) + .orElse((): ReadonlyArray => []) + const needle = filter.trim().toLowerCase() + const visible = needle + ? keys.filter( + (key) => + key.name.toLowerCase().includes(needle) || + key.key_prefix.toLowerCase().includes(needle) || + (key.description ?? "").toLowerCase().includes(needle), + ) + : keys + const atCap = keys.length >= MAX_KEYS + + return ( +
+
+ setFilter(event.target.value)} + /> +
+ {Result.isSuccess(listResult) && ( + + {keys.length} of {MAX_KEYS} custom keys + + )} + +
+
+ + + + + Key + Signals + Rate limit + Overrides + Last data + + + + + {needle.length === 0 && ( + <> + nav.openView("setup")} + /> + nav.openView("setup")} + /> + + )} + {Result.isInitial(listResult) ? ( + + + + + + ) : !Result.isSuccess(listResult) ? ( + + + refreshKeys()} + className="border-0" + /> + + + ) : ( + visible.map((key) => { + const activity = keyActivity(key, now) + const muted = activity === "paused" || activity === "expired" + return ( + nav.openKey(toPublicKeyId(key.id))} + > + +
+ + + +
+ + {key.name} + + + {key.key_prefix}… · {keySubtitle(key, now)} + +
+
+
+ + {signalsSummary(key.allowed_signals)} + + + {rateLimitLabel(key.rate_limit_rps)} + + + {attributeCountLabel(key.resource_attributes.length)} + + + {activity === "paused" + ? "Paused" + : key.last_used_at === null + ? "Never" + : formatRelativeTime(key.last_used_at, now)} + + + + +
+ ) + }) + )} +
+
+ + {Result.isSuccess(listResult) && keys.length === 0 && ( + + + + + + No custom keys yet + + Give a team or environment its own key to cap its request rate, stamp a{" "} + service.name on everything it sends, or + pause it without touching anyone else. + + + + + )} +
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/navigation.ts b/apps/web/src/components/settings/ingest-keys/navigation.ts new file mode 100644 index 0000000000..cedeed1b50 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/navigation.ts @@ -0,0 +1,46 @@ +import { useNavigate } from "@tanstack/react-router" +import { Atom, Result, useAtomValue } from "@/lib/effect-atom" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { toPublicKeyId } from "./shared" + +export const ingestionViews = ["setup", "keys", "mappings"] as const +export type IngestionView = (typeof ingestionViews)[number] + +/** `/settings?tab=ingestion&view=keys&key=&keyView=configure` */ +export function useIngestionNavigation() { + const navigate = useNavigate({ from: "/settings" }) + const go = (search: { view?: IngestionView; key?: string; keyView?: "configure" }) => + navigate({ search: { tab: "ingestion", ...search } }) + return { + openView: (view: IngestionView) => go({ view }), + openKeys: () => go({ view: "keys" }), + openNewKey: () => go({ view: "keys", key: "new" }), + openKey: (publicId: string) => go({ view: "keys", key: publicId }), + configureKey: (publicId: string) => go({ view: "keys", key: publicId, keyView: "configure" }), + } +} + +// Read in place of the key list off key pages, so other settings tabs never fetch it. +const noKeysAtom = Atom.make(Result.success({ data: [] })) + +/** Trailing breadcrumbs for a key page: the key's name, then "Configure". */ +export function useIngestKeyCrumbs( + keyParam: string | undefined, + keyView: "configure" | undefined, +): ReadonlyArray<{ label: string; href?: string }> { + const listResult = useAtomValue( + keyParam === undefined || keyParam === "new" ? noKeysAtom : customIngestKeysListAtom, + ) + if (keyParam === undefined) return [] + if (keyParam === "new") return [{ label: "New key" }] + const name = Result.builder(listResult) + .onSuccess((response) => response.data.find((key) => toPublicKeyId(key.id) === keyParam)?.name) + .orElse(() => undefined) + const label = name ?? "Key" + return keyView === "configure" + ? [ + { label, href: `/settings?tab=ingestion&view=keys&key=${encodeURIComponent(keyParam)}` }, + { label: "Configure" }, + ] + : [{ label }] +} diff --git a/apps/web/src/components/settings/ingest-keys/shared.ts b/apps/web/src/components/settings/ingest-keys/shared.ts new file mode 100644 index 0000000000..9eebd255fa --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/shared.ts @@ -0,0 +1,70 @@ +import type { CustomIngestKeyId, IngestKeyAttributeMode, IngestKeySignal } from "@maple/domain/http" +import { CustomIngestKeyPublicId, type V2CustomIngestKey } from "@maple/domain/http/v2" +import { Option, Schema } from "effect" + +export const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: string }> = [ + { value: "traces", label: "Traces", hint: "/v1/traces" }, + { value: "logs", label: "Logs", hint: "/v1/logs" }, + { value: "metrics", label: "Metrics", hint: "/v1/metrics" }, + { value: "sessions", label: "Sessions", hint: "Replays and session events" }, + { value: "events", label: "Events", hint: "Product events and track()" }, +] + +export const MODE_LABELS = { + set: "Always", + default: "If missing", +} satisfies Record + +export const signalsSummary = (signals: V2CustomIngestKey["allowed_signals"]) => + signals === null + ? "All signals" + : SIGNALS.filter((signal) => signals.includes(signal.value)) + .map((signal) => signal.label) + .join(", ") + +export const rateLimitLabel = (rps: number | null) => (rps === null ? "No limit" : `${rps} req/s`) + +export const attributeCountLabel = (count: number) => + count === 0 ? "None" : count === 1 ? "1 attribute" : `${count} attributes` + +// The API decodes `ingk_` ids into raw UUIDs; URLs and the gateway's stamp use the public form. +export const toPublicKeyId = Schema.encodeSync(CustomIngestKeyPublicId) +const decodePublicKeyId = Schema.decodeUnknownOption(CustomIngestKeyPublicId) +export const fromPublicKeyId = (value: string): Option.Option => decodePublicKeyId(value) + +/** The one-time secret from a create or roll, held in memory until the key page is left. */ +export interface RevealedSecret { + readonly keyId: CustomIngestKeyId + readonly secret: string + readonly rolled: boolean +} + +const LIVE_WINDOW_MS = 15 * 60 * 1000 + +export type KeyActivity = "live" | "idle" | "waiting" | "paused" | "expired" + +/** Lifecycle from the API's `status`, then recency of `last_used_at` for active keys. */ +export const keyActivity = (key: V2CustomIngestKey, now: number): KeyActivity => { + if (key.status !== "active") return key.status + if (key.last_used_at === null) return "waiting" + return now - new Date(key.last_used_at).getTime() <= LIVE_WINDOW_MS ? "live" : "idle" +} + +export const ACTIVITY_LABELS = { + live: "Live", + idle: "Idle", + waiting: "Waiting for data", + paused: "Paused", + expired: "Expired", +} satisfies Record + +/** "Paused until 15:42", or "Paused until resumed". */ +export const pausedLabel = (key: V2CustomIngestKey) => + key.paused_until === null + ? "Paused until resumed" + : `Paused until ${new Date(key.paused_until).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}` diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 146076e5c0..8c4044f65e 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -26,7 +26,14 @@ import { type IngestConnection, } from "@/components/ingest/use-ingest-connection" import { AttributeMappingsSection } from "./attribute-mappings-section" -import { CustomIngestKeysSection } from "./custom-ingest-keys-section" +import { Option } from "effect" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { IngestKeyDetail } from "./ingest-keys/key-detail" +import { IngestKeyForm } from "./ingest-keys/key-form" +import { IngestKeysTable } from "./ingest-keys/keys-table" +import { type IngestionView, useIngestionNavigation } from "./ingest-keys/navigation" +import { fromPublicKeyId, type RevealedSecret, toPublicKeyId } from "./ingest-keys/shared" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" import { SettingsSection, SettingsSections } from "./settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" @@ -211,7 +218,143 @@ function CredentialRow({ ) } -export function IngestionSection() { +const VIEW_LABELS = { + setup: "Setup", + keys: "Keys", + mappings: "Attribute mappings", +} satisfies Record + +export function IngestionSection({ + view = "setup", + keyParam, + keyView, +}: { + view?: IngestionView + keyParam?: string + keyView?: "configure" +}) { + const nav = useIngestionNavigation() + // The one-time secret lives only here, so it is gone once the key page is left or reloaded. + const [revealed, setRevealed] = useState(null) + // Leaving the key's page drops the secret ("new" is the create step navigating to it). + if ( + revealed !== null && + keyParam !== "new" && + (keyParam !== toPublicKeyId(revealed.keyId) || keyView === "configure") + ) { + setRevealed(null) + } + + if (keyParam !== undefined) { + return ( + + ) + } + + return ( +
+ { + const next = values.find((value) => value !== view) + if (next === "setup" || next === "keys" || next === "mappings") nav.openView(next) + }} + > + {Object.entries(VIEW_LABELS).map(([id, label]) => ( + + {label} + + ))} + + {view === "keys" ? ( + + ) : view === "mappings" ? ( + + + + + ) : ( + + )} +
+ ) +} + +function IngestKeyPage({ + keyParam, + configure, + revealed, + onRevealed, +}: { + keyParam: string + configure: boolean + revealed: RevealedSecret | null + onRevealed: (revealed: RevealedSecret | null) => void +}) { + const nav = useIngestionNavigation() + const listResult = useAtomValue(customIngestKeysListAtom) + + if (keyParam === "new") { + return ( + nav.openKeys()} + onCreated={(key, secret) => { + onRevealed(secret) + nav.openKey(toPublicKeyId(key.id)) + }} + /> + ) + } + const keyId = fromPublicKeyId(keyParam) + if (Option.isNone(keyId)) { + return

That is not an ingest key id.

+ } + if (configure) { + const existing = Result.builder(listResult) + .onSuccess((response) => response.data.find((key) => key.id === keyId.value) ?? null) + .orElse(() => null) + if (existing === null) { + return Result.isInitial(listResult) ? ( + + ) : ( +

This ingest key no longer exists.

+ ) + } + return ( + nav.openKey(keyParam)} + onSaved={() => nav.openKey(keyParam)} + onCreated={() => nav.openKey(keyParam)} + /> + ) + } + return ( + nav.configureKey(keyParam)} + onDeleted={() => { + onRevealed(null) + nav.openKeys() + }} + /> + ) +} + +function IngestionSetup() { const [publicKeyVisible, setPublicKeyVisible] = useState(false) const [privateKeyVisible, setPrivateKeyVisible] = useState(false) const [regenerateDialogOpen, setRegenerateDialogOpen] = useState(false) @@ -316,8 +459,6 @@ export function IngestionSection() {
- - - - - - 0 ? "/settings?tab=ingestion&view=keys" : undefined, + }, + ...keyCrumbs, + ] } filters={ loading || empty ? undefined : ( @@ -111,7 +124,9 @@ function SettingsPage() { {activeTab === "members" && } {activeTab === "audit-log" && } {activeTab === "setup-audit" && } - {activeTab === "ingestion" && } + {activeTab === "ingestion" && ( + + )} {activeTab === "api-keys" && } {activeTab === "mcp" && } {activeTab === "ai-provider" && } From e82262d1cb3f3929dd86ecb8891ae698a394c8de Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:42:26 +0200 Subject: [PATCH 09/14] fix(web): shorter key subtitles and a paused hint on the key page --- .../settings/ingest-keys/key-detail.tsx | 8 +++--- .../settings/ingest-keys/keys-table.tsx | 25 ++++++++----------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/settings/ingest-keys/key-detail.tsx b/apps/web/src/components/settings/ingest-keys/key-detail.tsx index 1a4d30d0ed..d99049d5b3 100644 --- a/apps/web/src/components/settings/ingest-keys/key-detail.tsx +++ b/apps/web/src/components/settings/ingest-keys/key-detail.tsx @@ -463,9 +463,11 @@ export function IngestKeyDetail({ activity === "expired" && "text-muted-foreground", )} hint={ - key.last_used_at === null - ? "Updates once the first request lands" - : `Last data ${formatRelativeTime(key.last_used_at, now)}` + key.last_used_at !== null + ? `Last data ${formatRelativeTime(key.last_used_at, now)}` + : activity === "paused" + ? "Data sent while paused is dropped" + : "Updates once the first request lands" } /> { - const activity = keyActivity(key, now) - if (activity === "paused") return pausedLabel(key) - if (activity === "expired" || key.expires_at === null) return ACTIVITY_LABELS[activity] - return `${ACTIVITY_LABELS[activity]} · expires ${formatRelativeTime(key.expires_at, now)}` -} +// Short enough for the key column; the key's page carries the long form. +const SUBTITLE_LABELS = { + live: "Live", + idle: "Idle", + waiting: "No data yet", + paused: "Paused", + expired: "Expired", +} satisfies Record function DefaultKeyRow({ label, value, - audience, onOpen, }: { label: string value: string | null - audience: string onOpen: () => void }) { return ( @@ -55,7 +54,7 @@ function DefaultKeyRow({
{label} - {value === null ? "…" : `${value.slice(0, 14)}…`} · Default, {audience} + {value === null ? "…" : `${value.slice(0, 14)}…`} · Default
@@ -136,13 +135,11 @@ export function IngestKeysTable() { nav.openView("setup")} /> nav.openView("setup")} /> @@ -184,7 +181,7 @@ export function IngestKeysTable() { {key.name} - {key.key_prefix}… · {keySubtitle(key, now)} + {key.key_prefix}… · {SUBTITLE_LABELS[activity]} From 800ead24e8d3e29a5c5ba8dd9d2c26631813cc57 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:47:41 +0200 Subject: [PATCH 10/14] refactor(web): review fixes for the ingest keys pages Rows open from the keyboard, a filter with no match says so, the view switch is Tabs, banners use Alert, the form takes a create or configure mode, one hook reads a key from the shared list, and limits come from the domain. --- .../settings/ingest-keys/key-detail.tsx | 55 +++++------ .../ingest-keys/key-form.browser.test.tsx | 2 +- .../settings/ingest-keys/key-form.tsx | 48 +++++---- .../settings/ingest-keys/keys-table.tsx | 28 +++++- .../settings/ingest-keys/navigation.ts | 3 +- .../components/settings/ingest-keys/shared.ts | 22 ++++- .../components/settings/ingestion-section.tsx | 98 +++++++++---------- 7 files changed, 151 insertions(+), 105 deletions(-) diff --git a/apps/web/src/components/settings/ingest-keys/key-detail.tsx b/apps/web/src/components/settings/ingest-keys/key-detail.tsx index d99049d5b3..c0d9971725 100644 --- a/apps/web/src/components/settings/ingest-keys/key-detail.tsx +++ b/apps/web/src/components/settings/ingest-keys/key-detail.tsx @@ -1,9 +1,11 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { INGEST_KEY_ID_ATTRIBUTE } from "@maple/domain/http" import type { V2CustomIngestKey, V2CustomIngestKeyUsage } from "@maple/domain/http/v2" import { Link } from "@tanstack/react-router" import type * as React from "react" import { useState } from "react" +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { CopyButton } from "@maple/ui/components/ui/copy-button" @@ -38,6 +40,7 @@ import { type RevealedSecret, signalsSummary, toPublicKeyId, + useCustomIngestKey, } from "./shared" const HOUR_MS = 3_600_000 @@ -104,27 +107,17 @@ function Banner({ description, action, }: { - tone: "ok" | "warn" | "muted" + tone: "ok" | "warn" | "default" title: string description?: string action?: React.ReactNode }) { return ( -
-
-

{title}

- {description &&

{description}

} -
- {action} -
+ + {title} + {description && {description}} + {action && {action}} + ) } @@ -220,7 +213,7 @@ export function IngestKeyDetail({ onConfigure: () => void onDeleted: () => void }) { - const listResult = useAtomValue(customIngestKeysListAtom) + const lookup = useCustomIngestKey(keyId) const refreshKeys = useAtomRefresh(customIngestKeysListAtom) const usageResult = useAtomValue(retainedQueryV2("ingestKeys", "usageCustom", { params: { id: keyId } })) @@ -279,7 +272,7 @@ export function IngestKeyDetail({ return ok } - if (Result.isInitial(listResult)) { + if (lookup._tag === "loading") { return (
@@ -288,18 +281,24 @@ export function IngestKeyDetail({
) } - const key = Result.builder(listResult) - .onSuccess((response) => response.data.find((candidate) => candidate.id === keyId) ?? null) - .orElse(() => null) - if (key === null) { + if (lookup._tag !== "found") { return ( ) } + const { key } = lookup const now = Date.now() const activity = keyActivity(key, now) @@ -344,13 +343,13 @@ export function IngestKeyDetail({ to="/traces" search={{ resourceAttributeFilters: [ - { key: "maple_ingest_key_id", value: publicId }, + { key: INGEST_KEY_ID_ATTRIBUTE, value: publicId }, ], }} /> } > - View data + View traces {activity === "paused" ? (