Reference

FireTrace API

Scoped keys and every key-authenticated endpoint, with the OpenAPI document.

View source

Everything a program can do against a FireTrace deployment goes through the key-authenticated API under /api/v1. The dashboard uses a separate session cookie and never shares these routes. A machine-readable OpenAPI 3.1 document is served by every deployment at GET /api/v1/openapi.json, and GET /api/v1 lists the endpoints.

  • Base URL: your deployment origin, e.g. https://tracing.art3m1s.me
  • Authentication: Authorization: Bearer ft_live_<keyId>_<secret>
  • Content type: JSON in, JSON out. Every response carries X-Request-Id and Cache-Control: no-store.
  • Errors: { "error": { "code", "message", "requestId" } } with a matching HTTP status.

API keys and scopes

Keys are created per project under Project → Settings → API keys. Only an HMAC digest of the secret is stored, so the plaintext is shown once. Each key carries the scopes you choose at creation:

ScopeGrants
traces:writePOST /api/v1/traces, POST /api/v1/traces/{id}/spans, POST /api/v1/traces/{id}/end, PATCH /api/v1/traces/{id}, POST /api/v1/traces/{id}/scores; MCP record_trace, add_score, get_ingest_schema
traces:readGET /api/v1/traces, GET /api/v1/traces/{id}, GET /api/v1/traces/{id}/scores, GET /api/v1/scores, GET /api/v1/project; MCP read tools
traces:deleteDELETE /api/v1/traces/{id}, DELETE /api/v1/traces/{id}/scores/{scoreId}; MCP delete_trace

GET /api/v1/key needs no scope. Defaults for a new key are traces:write + traces:read; an SDK embedded in an application usually only needs traces:write. Keys created before scopes existed behave as traces:write only.

Keys can also carry an expiry (30 days, 90 days, 1 year, or never). Expired, revoked, unknown, and malformed keys all produce the same 401 invalid_api_key with a WWW-Authenticate: Bearer challenge, so a response never reveals whether a key id exists. Calling a route without the required scope yields 403 insufficient_scope.

Rotating a key issues a new secret with the same label, scopes, environment, and expiry and revokes the old one in the same transaction. lastUsedAt is updated at most every five minutes per key so busy keys do not cost a write per request.

Environments

Every key carries an optional environment: a slug matching [a-z0-9][a-z0-9_-]{0,31} (input is folded to lowercase, so Production becomes production). The key dialog offers production, preview and development as one-click presets; staging, qa, local or anything else that fits the pattern works the same way. unassigned and all are reserved and cannot be key environments.

When a key records a trace, the server copies the key's environment onto the trace as environment. Nothing in the request body can set or override it: the ingest schema rejects an environment field with 400 invalid_trace, exactly like any other unknown field. That is what makes the separation trustworthy. A preview deployment physically cannot write into production's numbers, because it does not hold production's key.

It also means no application code changes. Mint one key per environment and set a different FIRETRACE_API_KEY in each of your host's environment scopes (Vercel, Netlify, Fly and Docker all scope secrets that way). If you currently share one key across environments, that is the one step to take; the key screen shows a "one key per environment" prompt until a key has an environment. Verify the wiring at startup with GET /api/v1/key, which reports the environment.

Rules worth knowing:

  • A key without an environment stamps null; such traces are unassigned. Every key and every trace that predates environments is unassigned. Nothing is ever silently relabelled production.
  • Changing a key's environment (the "change" control on the key screen) affects traces it records from then on. FireTrace never rewrites stored traces; scripts/backfill-environment.ts (below) is the explicit way to classify history.
  • Revoking or deleting a key leaves its traces and their environment intact: the value is copied at ingest, never looked up through the key when reading.
  • Two keys may share one environment. A rotation in progress is the normal case.
  • Scores never store an environment; they inherit their trace's, so the two cannot disagree.
  • A resend of a trace through a key with another environment is still the same trace: 200 duplicate, environment unchanged.
  • The dashboard has a project-wide environment selector (a cookie, not a URL parameter). It defaults to production once a key is assigned there, otherwise to every environment, and every list, chart and summary number follows it. The project's trace, span and storage counts stay project-wide, because storage is one quota.

Assigning environments to history

Traces recorded before environments existed carry no environment field at all, and Firestore cannot query for a missing field, so they are invisible to ?environment=unassigned (and to the dashboard's "unassigned" selection) until they are marked. scripts/backfill-environment.ts does that, and can also classify history by the key that recorded it, by a tag (the env:production convention older integrations used), or by time. It is a dry run unless --apply is given, never deletes anything, reads every trace in the project once, and ends by rebuilding the dashboard rollups so the numbers move with the traces:

bash
# Mark every trace without an environment field as unassigned (do this once after upgrading).
pnpm exec tsx scripts/backfill-environment.ts --project <projectId> --apply

# Classify history: every selector given must match (AND); only unassigned traces change.
pnpm exec tsx scripts/backfill-environment.ts --project <projectId> --environment production --tag env:production --apply
pnpm exec tsx scripts/backfill-environment.ts --project <projectId> --environment development --key <keyId> --apply
pnpm exec tsx scripts/backfill-environment.ts --project <projectId> --environment preview --from 2026-08-01T00:00:00Z --to 2026-08-15T00:00:00Z --apply

# Relabel traces that already have an environment.
pnpm exec tsx scripts/backfill-environment.ts --project <projectId> --environment production --tag env:prod --overwrite --apply

--key matches the key id recorded on each trace, which exists only for traces ingested since environments were introduced; for older traces use --tag or a time window.

Endpoints

GET /api/v1/key

Describe the calling key. Use it to verify configuration at startup, including which environment the key's traces will land in.

json
{
  "keyId": "3f9c2a1b8d4e6f70",
  "projectId": "proj_8f2c1d",
  "scopes": ["traces:write", "traces:read"],
  "expiresAt": null,
  "lastUsedAt": "2026-09-03T10:12:00.000Z",
  "environment": "production"
}

environment is null for a key that has none.

GET /api/v1/project — scope traces:read

The key's project with counters and the storage estimate (FireTrace's own serialized-size estimate, not Firebase billing).

json
{
  "id": "proj_8f2c1d",
  "name": "Support agent",
  "slug": "support-agent",
  "description": "",
  "traceCount": 1240,
  "spanCount": 8114,
  "estimatedBytes": 41230011,
  "lastTraceAt": "2026-09-03T10:11:58.201Z",
  "createdAt": "2026-08-20T18:00:00.000Z",
  "storage": { "limitBytes": 500000000, "level": "ok" },
  "keyScopes": ["traces:write", "traces:read"]
}

POST /api/v1/traces — scope traces:write

Record one trace. With trace.endedAt the body is one complete, immutable trace; without it the trace is stored as running and completed by the two requests below (streaming). The body is { "schemaVersion": 1, "trace": { ... } }; the full field reference lives in ingestion-api.md and the JSON Schema in the OpenAPI document (components.schemas.IngestRequest).

StatusMeaning
201Stored. Body: { ok, traceId, projectId, spanCount, duplicate: false, running, requestId }
200Identical resend of an existing trace; nothing written (duplicate: true)
400invalid_json or invalid_trace (message names the field; status without endedAt)
409trace_id_conflict: the id exists with different content
413payload_too_large: request over 2 MiB or a document over Firestore's limit
429quota_exhausted: Firestore refused the write; retry later, nothing was stored

Idempotency comes from the trace id: the SDK generates 32-hex ids, and a resend with the same body hash is a no-op.

On instances that enable trial mode (FIRETRACE_TRIAL_TRACE_LIMIT), a trial account's project answers 403 trial_limit_reached once the account has recorded its allotted traces; the message links to the deployment guide. Owner projects are never limited. A streamed trace is charged at the start.

POST /api/v1/traces/{traceId}/spans — scope traces:write

Append finished spans to a running trace. Body: { "schemaVersion": 1, "spans": [ ... ] } with 1–200 span objects; a trace holds at most 200 spans in total. A span's parent need not have arrived yet. Idempotent per span id: a span already stored with identical content counts as a duplicate, one stored with different content is a 409 span_conflict and nothing in the batch is written. Rules and examples in ingestion-api.md.

StatusMeaning
200Body: { ok, traceId, added, duplicate, spanCount, requestId }
400invalid_json, or invalid_trace (empty batch, schema violation, the 200-span limit)
403forbidden (a key from another environment) or, on trial instances, trial_limit_reached (2 MiB per trace)
404not_found: no such trace in this key's project
409trace_finished (the trace has already ended) or span_conflict (a span id reused with other content)
413payload_too_large: request over 2 MiB or a span over 750 KiB
429quota_exhausted: nothing was stored

POST /api/v1/traces/{traceId}/end — scope traces:write

Close a running trace. Body: { "schemaVersion": 1, "endedAt", "status"?, "provider"?, "model"?, "output"?, "usage"?, "costUsd"?, "metadata"?, "tags"?, "spans"? }. metadata is shallow-merged into the start's, tags are added to it, spans is a last batch, and everything else replaces the start's value. Only now does the trace count in the dashboard's rollups. Idempotent: the same end body again is a 200 with duplicate: true; a different one after the trace has ended is a 409 trace_finished. Details in ingestion-api.md.

StatusMeaning
200Body: { ok, traceId, duplicate, spanCount, requestId }
400invalid_json, or invalid_trace (schema violation, endedAt before startedAt, the span limit)
403forbidden (a key from another environment) or, on trial instances, trial_limit_reached
404not_found: no such trace in this key's project
409trace_finished (ended with a different body) or span_conflict
413payload_too_large: request over 2 MiB, or the finished trace document over 750 KiB
429quota_exhausted: nothing was stored

GET /api/v1/traces — scope traces:read

Newest-first list with cursor pagination. Filters combine with AND.

QueryNotes
statusok, error, unset, or running (a streamed trace that has not ended)
modelexact model string
nameexact trace name
tagone tag the trace must carry
environmentan environment slug (case-folded), or unassigned for traces recorded by keys without one
sessionIdexact
userIdexact
frominclusive ISO-8601 lower bound on startedAt
toinclusive ISO-8601 upper bound on startedAt
sortnewest (default), slowest (by durationMs) or costliest (by costUsd)
limit1–200, default 50
afternextCursor of a previous page (older traces)
beforeprevCursor of a previous page (newer traces)

slowest and costliest combine only with status, model, name, tag and environment; adding sessionId, userId, from or to to them is a 400 invalid_request, because Firestore has no index for that combination. environment composes with every sort and filter, so "costliest production traces" is ?environment=production&sort=costliest. costliest omits traces that were recorded without costUsd, and slowest omits running traces, which have no durationMs yet. A cursor is only valid under the sort that produced it. Omitting environment returns every environment, as before; filtering by an environment no key has ever used is a 200 with an empty list.

The query string is strict, like the ingest body: an unknown parameter (?env=production, ?Status=error) or an unknown value for status, sort, environment, from or to is a 400 invalid_request whose message names the offender. A misspelled filter can therefore never come back as a complete, unfiltered list.

json
{
  "traces": [
    {
      "id": "42f38ac8295345a7a12c4e3f60d6da23",
      "name": "answer-question",
      "status": "ok",
      "environment": "production",
      "startedAt": "2026-09-02T19:01:02.120Z",
      "endedAt": "2026-09-02T19:01:04.812Z",
      "durationMs": 2692,
      "provider": null,
      "model": "example-model",
      "sessionId": "session-123",
      "userId": null,
      "tags": [],
      "usage": { "inputTokens": 120, "outputTokens": 84, "totalTokens": 204 },
      "costUsd": null,
      "spanCount": 5,
      "errorCount": 1,
      "estimatedBytes": 4310,
      "ingestedAt": "2026-09-02T19:01:05.004Z",
      "scores": {
        "helpful": {
          "scoreId": "9c1e7a2b3d4f5061",
          "dataType": "boolean",
          "value": true,
          "evaluatorId": null
        }
      }
    }
  ],
  "nextCursor": "eyJz...",
  "prevCursor": null,
  "pageSize": 50
}

Cursors are opaque; an unparseable cursor is 400 invalid_request. Offsets are never supported because Firestore charges per document read. A running trace has "status": "running" with endedAt and durationMs both null.

GET /api/v1/traces/{traceId} — scope traces:read

One trace with all of its spans, ordered by startedAt then id, and all of its scores, newest first. Trace ids are matched case-insensitively; anything that is not 32 hex characters is a 404 not_found, as is a trace that belongs to another project. A running trace comes back with "status": "running" and null for endedAt and durationMs, with whatever spans have arrived so far.

json
{
  "trace": { "...summary fields...", "input": {}, "output": {}, "metadata": {}, "metadataUpdatedAt": null, "bodyHash": "…" },
  "scores": [],
  "spans": [
    {
      "id": "00f067aa0ba902b7",
      "traceId": "42f38ac8295345a7a12c4e3f60d6da23",
      "parentSpanId": null,
      "name": "answer-question",
      "kind": "agent",
      "status": "ok",
      "startedAt": "2026-09-02T19:01:02.120Z",
      "endedAt": "2026-09-02T19:01:04.812Z",
      "durationMs": 2692,
      "provider": null,
      "model": null,
      "input": null,
      "output": null,
      "attributes": {},
      "events": [],
      "usage": null,
      "costUsd": null
    }
  ]
}

PATCH /api/v1/traces/{traceId} — scope traces:write

Shallow-merge keys into a stored trace's metadata. This is the only mutable part of a trace: it exists so evaluations that arrive after the run — a thumbs rating, a reviewer's verdict, an eval score, a business outcome — have somewhere to live. Everything the trace was ingested with, spans included, stays write-once. The full reference is in ingestion-api.md.

bash
curl -X PATCH https://tracing.art3m1s.me/api/v1/traces/$TRACE_ID \
  -H "Authorization: Bearer $FIRETRACE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"feedback":1,"feedbackLabel":"thumbs-up"}}'
json
{
  "ok": true,
  "traceId": "42f38ac8295345a7a12c4e3f60d6da23",
  "metadata": { "route": "/api/chat", "feedback": 1, "feedbackLabel": "thumbs-up" },
  "changed": true,
  "requestId": "0f1e2d3c4b5a6978"
}
StatusMeaning
200Merged. metadata is the full merged object; changed: false means it already matched and nothing was written
400invalid_json, or invalid_request — metadata missing or not an object, a field other than metadata, or a key Firestore refuses
404not_found: no such trace in this key's project
413payload_too_large: request over 2 MiB, or the merged document over 750 KiB
429quota_exhausted: Firestore refused the write; nothing was stored

Three things to know before you build on it:

  • The merge is shallow and last-writer-wins. A patched key replaces that top-level key outright, and two writers racing on the same key leave no trace of the loser.
  • bodyHash is not recomputed, so re-sending the original trace after a patch is still a 200 duplicate rather than a 409. It describes the body as ingested, not the document as it stands.
  • Metadata is not indexed, so there is no metadata filter on GET /api/v1/traces and no server-side aggregation. For ratings, verdicts, and eval results use scores instead; keep metadata for free-form facts.

metadataUpdatedAt on the trace (see GET /api/v1/traces/{traceId}) is the only marker distinguishing a trace that was edited after ingestion from one that was not.

Scores

A score is a judgement attached to a trace after the run: a thumbs rating, a reviewer's verdict, an evaluator's result. Unlike metadata, scores are a first-class resource: each has a name, a typed value, an optional comment and a source, they are indexed, and they can be listed per trace or across the project. Scores are append-only. Adding a name again records a newer score, and every trace carries a scores summary with the newest score per name (see the list example above).

POST /api/v1/traces/{traceId}/scores — scope traces:write

bash
curl -X POST https://tracing.art3m1s.me/api/v1/traces/$TRACE_ID/scores \
  -H "Authorization: Bearer $FIRETRACE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"helpful","dataType":"boolean","value":true,"comment":"answered the question"}'
FieldNotes
nameletters, digits, _ and -, at most 64 characters; doubles as the display name
dataTypenumeric, categorical, or boolean
valuea number for numeric, a string of at most 200 characters for categorical, a boolean for boolean
commentoptional, at most 2,000 characters
spanIdoptional 16-hex span id when the score applies to one span rather than the whole trace
json
{
  "ok": true,
  "score": {
    "id": "9c1e7a2b3d4f5061",
    "traceId": "42f38ac8295345a7a12c4e3f60d6da23",
    "spanId": null,
    "name": "helpful",
    "dataType": "boolean",
    "value": true,
    "comment": "answered the question",
    "source": "api",
    "evaluatorId": null,
    "runId": null,
    "createdAt": "2026-09-03T10:12:00.000Z"
  },
  "requestId": "0f1e2d3c4b5a6978"
}
StatusMeaning
201Stored
400invalid_json, or invalid_request when the body is not a score (the message names the field)
404not_found: no such trace in this key's project
409conflict: the trace already holds 100 scores, the maximum; delete one first
429quota_exhausted: Firestore refused the write; nothing was stored

source is api for scores recorded through the API or MCP, annotation for ones entered on the trace page, and eval for ones written by an evaluator.

GET /api/v1/traces/{traceId}/scores — scope traces:read

Every score of one trace, newest first: { "traceId", "scores": [ ... ] }. A trace that does not exist in this project is a 404 not_found.

GET /api/v1/scores — scope traces:read

Newest-first list across the project with cursor pagination. Filters combine with AND.

QueryNotes
nameexact score name
environmentonly scores whose trace is in this environment (a slug or unassigned)
frominclusive ISO-8601 lower bound on createdAt
toinclusive ISO-8601 upper bound on createdAt
limit1–500, default 50
afternextCursor of a previous page (older scores)

Responds with { "scores": [ ... ], "nextCursor": "…" | null, "pageSize": 50 }. An unparseable cursor is 400 invalid_request, and so is an unknown parameter or value, which the message names. environment is resolved through each score's trace (scores never store one): the server scans scores in order, keeps those whose trace matches, and examines at most 1000 per call, after which nextCursor points at the last score examined rather than the last one returned, so keep paging until it is null. A page filtered by environment therefore costs one trace read per distinct trace on top of the score reads.

DELETE /api/v1/traces/{traceId}/scores/{scoreId} — scope traces:delete

Delete one score. If it was the newest score of its name, the previous one takes its place in the trace's summary. Returns { "ok": true, "traceId", "scoreId" } or 404 not_found. Deleting a trace deletes its scores with it.

DELETE /api/v1/traces/{traceId} — scope traces:delete

Delete the trace, every span and score under it, and any patched metadata with them, then correct the project counters. Returns { "ok": true, "traceId" } or 404 not_found. This and the dashboard are the only two ways data leaves Firestore: FireTrace never sets TTLs or deletes on its own.

POST /api/mcp

The Model Context Protocol endpoint, documented in mcp.md. Same bearer key; tools follow scopes.

Clients

TypeScript SDK

@firetrace/sdk records traces (FireTrace) and, through client.api() or new FireTraceApi(...), reads and deletes them:

ts
import { FireTrace, FireTraceApi } from "@firetrace/sdk";

const api = new FireTraceApi({
  endpoint: "https://tracing.art3m1s.me",
  apiKey: process.env.FIRETRACE_API_KEY!,
});
const key = await api.getKey(); // verify scopes at startup
const page = await api.listTraces({ status: "error", limit: 20 });
for await (const trace of api.iterateTraces({ model: "gpt-5" })) console.log(trace.id);
const detail = await api.getTrace(page.traces[0].id); // null when missing; includes scores
await api.addScore(detail!.trace.id, { name: "helpful", dataType: "boolean", value: true }); // needs traces:write
const helpful = await api.listScores({ name: "helpful", limit: 100 }); // across the project
await api.patchMetadata(detail!.trace.id, { ticket: "SUP-142" }); // needs traces:write
await api.deleteTrace(page.traces[0].id); // needs traces:delete

Read methods throw FireTraceError with status, code, and requestId; the recording client keeps its never-throw default.

curl

bash
curl -s https://tracing.art3m1s.me/api/v1/traces?status=error&limit=5 \
  -H "Authorization: Bearer $FIRETRACE_API_KEY"
bash
curl -s -X DELETE https://tracing.art3m1s.me/api/v1/traces/42f38ac8295345a7a12c4e3f60d6da23 \
  -H "Authorization: Bearer $FIRETRACE_API_KEY"

Operational notes

  • Every route is Cache-Control: no-store; only /api/v1/openapi.json is cacheable.
  • Requests are served by Vercel functions on the Node.js runtime; a cold start adds a few hundred milliseconds to the first call.
  • Reads cost Firestore document reads (one per trace in a page, one per span in a detail call). Keep limit modest and prefer filters over client-side scanning.
  • Error codes are stable strings; new codes may be added but existing ones will not change meaning.