API conventions and notes
The backend implements the API as first designed (that design is not published) without changing any documented shape. This file is the reference for where the original design was silent or ambiguous, and for the decisions the backend made. Items marked Deviation depart from the original design; everything else is a clarification.
Versioning
Section titled “Versioning”- The dashboard API is served under
/api/v1, for exampleGET /api/v1/projects/{project_id}/traces. The earlier unversioned/api/<resource>paths no longer exist and return 404. - Not part of the versioned surface, and unchanged: ingestion (
POST /v1/traces,POST /v1/otlp/traces, versioned on its own),/health/*,/metrics, and the OpenAPI document and docs UI (/api/openapi.json,/api/docs). - A breaking change ships as
/api/v2. From then on/api/v1keeps working for at least 12 months and its responses carry aDeprecationheader. Additive changes (new fields, endpoints, optional parameters) stay in/api/v1. See ADR 5. - The
routelabel ofspanlight_http_requests_totalandspanlight_http_request_duration_secondsis the full route template, so it carries the prefix (for example/api/v1/projects/{project_id}/traces).
Errors
Section titled “Errors”- Every error is
application/problem+json:{type:"about:blank", title, status, detail, code, request_id, errors?}. Every response also carries anX-Request-IDheader (an incoming validX-Request-IDis echoed back). - Codes in use:
UNAUTHORIZED(401),INVALID_CREDENTIALS(401, login),KEY_EXPIRED(401, an API key past itsexpires_at),TOKEN_EXPIRED(401, a personal access token past itsexpires_at),FORBIDDEN(403),KEY_SCOPE(403, an API key used outside its scopes),TOKEN_SCOPE(403, a read-only personal access token used to change something),SESSION_REQUIRED(403, a personal access token used on a route that needs a signed-in session),CSRF_FAILED(403),ORIGIN_NOT_ALLOWED(403),NOT_FOUND(404),EMAIL_TAKEN(409),EMAIL_ALREADY_VERIFIED(409),EMAIL_UNVERIFIED(409, an action needs a verified email address while email is configured),LAST_OWNER(409),NOT_CONFIGURED(409, a request needs an optional integration the operator has not set up;detailnames the setting to change),LAST_SIGN_IN_METHOD(409, unlinking the only way to sign in),TOTP_ALREADY_ENABLED/TOTP_NOT_ENABLED(409, two-factor setup state),TWO_FACTOR_NOT_ENABLED(409, an owner requires two-factor for the org without having it),TWO_FACTOR_REQUIRED(403, the org requires two-factor and the caller has not turned it on),INVALID_TOTP_CODE(422 when enabling or disabling, 401 when signing in),TOTP_CHALLENGE_INVALID(401, the second step of signing in),INVALID_PASSWORD(422, two-factor setup with a missing or wrong current password),REAUTH_REQUIRED(403, two-factor setup by an account without a password whose sign-in is older than 10 minutes),TOO_MANY_EXPORTS(409, a project already has 5 exports queued or running),EE_LICENSE_REQUIRED(403, an enterprise-edition feature the instance’s license does not grant; thefeaturemember names it, see “Enterprise edition” below),CONFIRMATION_MISMATCH(422, a deletion whose typedconfirmis not the slug of what it deletes),IDEMPOTENCY_MISMATCH(422, anIdempotency-Keyfirst used for a different request),IDEMPOTENCY_IN_PROGRESS(409, the first request with that key has not answered yet),SLUG_UNAVAILABLE(409),PAYLOAD_TOO_LARGE(413),UNSUPPORTED_MEDIA_TYPE(415),VALIDATION_ERROR(422, witherrors[].field),RATE_LIMITED(429, withRetry-After),INVALID_JSON/INVALID_OTLP/BAD_REQUEST(400),INTERNAL_ERROR(500),SERVICE_UNAVAILABLE(503, withRetry-After, the database could not take or finish the request in time). - The codes of prompts, scores, datasets, review queues, evaluators and experiments are listed in the section “Prompts, scores, datasets, evaluators and experiments” below; those of billing sources in “Auditable cost: tiers, fees, cut streams, explanation, billing sources, reconciliation”.
- Validation
errors[].fieldis the dotted path without thebody./query.prefix (e.g.password). - NUL in a text parameter is a 422 (added 2026-10-10). Postgres text cannot hold NUL, so a query or path value with
one could not be bound. The insights, releases and users parameters refuse it as
422 VALIDATION_ERRORwith the field named; any other text value that reaches the database with NUL also answers422 VALIDATION_ERROR(withouterrors) instead of a 500. - A busy database is a 503, not a 500 (added 2026-10-09). A request answers
503 SERVICE_UNAVAILABLEwithRetry-After: 5in four cases: it waited longer thanAPI_POOL_TIMEOUT_SECONDS(default 1) for a database connection, one of its statements ran longer thanAPI_STATEMENT_TIMEOUT_SECONDS(default 10) and the database cancelled it, the pooled connection it was given had died, or no connection could be opened because Postgres was restarting or unreachable (both added 2026-10-18). The request pool does not test a connection before use (that was aSELECT 1per request), so after Postgres restarts, the first request on each stale connection, at most the pool size (10) per api process, meets the dead connection once; the pool is then refreshed. The same holds for/gw/*in the provider envelope. Before this, the first case waited 30 seconds and then answered500 INTERNAL_ERROR, and the second ran until the client gave up, holding its connection the whole time. The server logs each as a warning (db_pool_timeout,db_statement_timeout,db_connection_lost,db_unreachable) and not as an unhandled exception. Clients may retry after the delay; the Python SDK already does for every5xx. Both limits apply to the database session of every/apiand ingestion request, with two exceptions: deleting an organization or a project is not subject to the statement limit (the foreign keys remove all its spans in one statement, which on a large project takes longer than any request limit), and the work that runs on its own engine has no statement limit: the worker, exports and the idempotency and rate-limit pools (the last two have their own pool settings). The statement limit is a setting of the api’s connections, so the audit-log CSV stream (GET /api/v1/orgs/{org_id}/audit/export.csv, one short statement per batch) and the gateway’s span writes are under it too. Any other database failure stays a500 INTERNAL_ERROR. Separately, a process that is already servingAPI_MAX_IN_FLIGHT(default 64)/apiand/v1requests answers the next one503 SERVICE_UNAVAILABLEwithRetry-After: 1at once, before reading its body or authenticating it (added 2026-10-18);/gwand/healthare not counted. - Request body limit: every
/api/v1request body is limited to 1 MiB. Over the limit is413 PAYLOAD_TOO_LARGE, answered without reading the body whenContent-Lengthalready declares too much, and while reading when the body is chunked or understates its length. Ingestion (/v1/traces,/v1/otlp/traces,/v1/scores) is outside this limit and keeps its own 5 MB. The dataset upload route (POST …/datasets/{dataset_id}/items/upload) has its own limit of 5 MB of file plus 64 KB of multipart framing.
Auth, CSRF, sessions
Section titled “Auth, CSRF, sessions”spl_csrfis a signed token bound to the session; the SPA echoes the cookie value inX-CSRF-Token.GET /api/v1/auth/mere-issuesspl_csrfif it is missing or stale, so the SPA can recover.- State-changing
/api/requests (every version) without anOriginheader are rejected (403ORIGIN_NOT_ALLOWED), not only those with a foreign Origin. Browsers always send Origin on POST/PUT/PATCH/DELETE. A request that carriesAuthorization: Bearer …is exempt from this check and from CSRF (see API key scopes, expiry and bearer requests); otherAuthorizationschemes are not. - CSRF is required on every authenticated unsafe method, including
POST /api/v1/auth/logoutandPOST /api/v1/invites/accept. Exempt (Origin check only):signup,login,POST /api/v1/demo/session,POST /api/v1/auth/email/verify/confirm,POST /api/v1/auth/password/forgot,POST /api/v1/auth/password/reset,POST /api/v1/auth/totp/verify. POST /api/v1/auth/signupis limited to 10 attempts per IP per hour; the 11th is 429RATE_LIMITEDwithRetry-After. Every attempt counts, including one refused with409 EMAIL_TAKEN, so probing for registered addresses is limited too.- Login throttle: once an email has 5 failures (or an IP 20) within 15 minutes, further attempts
return 429 with
Retry-After— i.e. the 6th attempt is blocked. Throttled attempts are not recorded. Wrong two-factor codes count as failed sign-ins too (see Two-factor authentication). POST /api/v1/demo/session→ 200Userand sets the same cookies as login (Max-Age3600). 404 when the demo is disabled; 429 after 10 per IP per hour.DELETE /api/v1/auth/sessions/{id}returns 404 for sessions that are not the caller’s.- Addition:
DELETE /api/v1/auth/sessions→ 204 signs out everywhere else: it deletes every session of the caller except the one making the request, which keeps working. It needs a session and the CSRF header like any authenticated unsafe method, and it is a no-op (still 204) when there are no other sessions. Other users’ sessions are never touched. - The shared demo account’s sessions are not manageable. Every demo visitor is the same user, so their
sessions belong to other people. For the demo user,
GET /api/v1/auth/sessionslists only the caller’s own session (so other visitors’ addresses and browsers are not shown), andDELETE /api/v1/auth/sessionsandDELETE /api/v1/auth/sessions/{id}answer403 FORBIDDENwithout ending anything. A visitor can stillPOST /api/v1/auth/logoutto end their own session.
Orgs, members, invites
Section titled “Orgs, members, invites”PATCH /api/v1/orgs/{org_id}/members/{user_id}→ 200{user: User, role, created_at}(same shape as a member list item).- Only owners may grant the owner role, change an owner’s role, remove an owner, or invite as owner; admins get 403 for those.
DELETE …/members/{own user id}lets any member leave the org (still subject toLAST_OWNER).- Accepting an invite as an existing member keeps the existing role (never downgrades).
- Invalid, used, expired or revoked invite tokens → 404.
- Addition:
GET /api/v1/invites/preview?token=…→ 200{org: {id, name, slug}, role, expires_at}, so the invite page can show the organization and role before the user accepts. It requires a signed-in session (401 otherwise), changes nothing (no audit event, the invite stays usable) and returns the same 404NOT_FOUNDfor unknown, used, expired and revoked tokens. The token travels in the query string, like the/invite/{token}page URL it comes from. - Addition (invite emails, 2026-10-08):
POST /api/v1/orgs/{org_id}/invitestakes an optionalemail(a valid address, else 422VALIDATION_ERROR) next torole. The create response andGET /api/v1/orgs/{org_id}/invitesitems gainemail, which isnullfor an invite created without one. The response still returnsurl, whether or not anything was mailed.- Mailing. With an
emailand a configured email sender, the invitation is also queued for delivery. The invite, itsinvite.createaudit event and the queued email are one transaction. The email names the inviter (the caller’s name), the organization and the role, and carries the sameurl. Without anemail, or when email is not configured, nothing is queued and the invite is created as before; the address, if given, is still stored and listed. The response does not say whether the email was queued, so a client that needs to know checks whether email is configured. - Limit. At most 20 mailed invites per organization per hour, counted only for invites that
are mailed (link-only invites and invites created while email is not configured do not count).
The 21st gets 429
RATE_LIMITEDwithRetry-After(whole seconds, rounded up) and no invite is created. Refused requests are not recorded, so retrying early does not extend the wait. The counts live in Postgres (throttle_events), so they hold across API replicas. The limit exists because anyone can sign up, create an organization and invite arbitrary addresses; unlimited, that would let strangers send mail from the instance’s sender address. - Accepting is unchanged. Mailing does not tie an invite to the address it was sent to: any
signed-in holder of the link can accept it, as for a link shared by hand. The link keeps its
{APP_BASE_URL}/invite/<token>shape. Because the token is in the path, it can appear in access logs andRefererheaders; unlike the verification and reset links, it is not in the URL fragment.
- Mailing. With an
- Addition (2026-10-08):
PATCH /api/v1/orgs/{org_id}→ 200Orgtakes{"require_2fa": bool}(see Two-factor authentication) and, since 2026-10-09,name(see Organization and project administration).Org(inmemberships[].org,POST /orgs, this response andGET /orgs/{id}) gains arequire_2faboolean, false by default. - Audit actions:
org.create, org.update, member.add, member.role_change, member.remove, invite.create, invite.revoke, invite.accept, project.create, project.update, project.delete, key.create, key.revoke, user.password_reset, user.oauth_link, user.oauth_unlink, user.totp_enable, user.totp_disable.actoris null for CLI actions. - Internal: a permission
org:read(held by every role) guards org-level reads; it does not change which roles can do what.
Projects, keys
Section titled “Projects, keys”- Creating a project requires
project:write(admin/owner). Listing keys requiresproject:read. prefixis the displayable start of the key,spl_live_<12 chars>; the full key is<prefix>_<32 chars>.secretappears only in the 201 response.last_used_atis updated at most once per minute, by ingestion and by reads alike.- Keys have scopes and an optional expiry; see API key scopes, expiry and bearer requests.
- Projects can be deleted (
DELETE /api/v1/projects/{project_id}); see Organization and project administration.
Traces, sessions, metrics
Section titled “Traces, sessions, metrics”- Non-members get 404 for every project route; members lacking a permission get 403.
from/todefault to the last 24 h;from >= toor a window > 90 days → 422.- Sessions:
last_atis the latest traceended_at; ordering and cursors use it (desc). - Sessions
has_unpriced(addition, 2026-10-19):truewhen any trace in the session hashas_unpriced. A session’scost_usdsums the known trace costs only, so with the flag set a knowncost_usdis a lower bound, and the UI marks it as one. Before this, a session with an unpriced call showed the smaller sum as if exact.recent_sessionsof the end-user detail carries the same flag. - Sessions
environment(addition):GET …/sessions?environment=keeps only traces of that environment before the traces are grouped, exactly as the trace list does. A session that spans environments is listed with thetrace_count,cost_usd,error_count,first_atandlast_atof its matching traces only, andlast_at(so the order and the cursor) follows those traces. A session with no trace in the environment is not listed. - KPI definitions (overview, timeseries, models):
llm_calls,errors/error_rate,p50_ms/p95_ms(percentile_cont) andunpriced_callsare computed over spans of kindllmstarted in the window.cost_usdand token totals are over all spans in the window (matching trace rollups);cost_usdisnullwhen nothing in the window was priced.error_rate/percentiles arenullwhen there are no LLM calls.tracescounts traces started in the window.environmentfilters by the trace’s environment.- Timeseries buckets are gapless (empty buckets have zero counts and
nullcost/p95), in UTC.
- Metrics source and
approximate(any window length): whole UTC hours that closed at least 10 minutes before the project’s last rollup run are read from the hourly rollups; everything else (the partial first hour, the partial last hour, and every hour from the last fully rolled-up one to now) is read from the raw spans. The previous period is split the same way. The raw part never reaches back more than 24 h: if the rollups are further behind, older hours are read as they are; a project with no rollup row yet is read raw. Counts, errors, tokens, cost,unpriced_callsand traces are exact sums of the two parts, except that a span stored more than 10 minutes after it started can be missing from a rolled-up hour until that hour is rolled up again: within 5 minutes while the hour is one of the last three, otherwise within about an hour. When a window reads any rollup hour, its p50/p95 are estimated from merged latency histograms (within about ±25 %) andapproximateis true; otherwise percentiles are exact (percentile_cont) andapproximateis false.overviewcarries a top-levelapproximate: bool, true when either period used a rollup hour (then every percentile of the response is an estimate);timeseriesandmodelsstay plain lists (an envelope would break clients), so each item carriesapproximate: bool: per bucket fortimeseries(a bucket of raw spans only keeps its exact p95), and the window’s value for everymodelsrow.modelsmerges both parts per provider and model before ranking, then keeps the top 100. - Trace
status=ok|errorfilters onerror_count = 0 | > 0.qmatches the trace name (case-insensitive substring), an exact trace id, or (2026-10-19,qof 3+ characters) span content: a case-insensitive substring of a preview of a span’s input and output (the last 2,000 characters of the input and the first 2,000 of the output, as JSON text, so newlines and quotes inside strings appear as\nand\"). Only spans that started inside[from, to)are searched, so a span that starts aftertodoes not make its trace match. The trace-list query of a search runs under a 5 s statement limit; a search too broad to finish in it (typically a word found in most spans of a long window) is answered422 SEARCH_TOO_BROAD, not retried, and the dashboard shows itsdetail. Exports apply the sameq. The search index is only usable under row-level security when the migrations (andspanlight ensure-app-role) run as a superuser, which marks the search functions LEAKPROOF (migration 1007 explains why that is safe). Otherwise content search covers only the spans that started in the last hour of the window (name and id matches still cover the whole window), so it stays well under the timeout. - Money is a decimal string with up to 8 fractional digits, e.g.
"0.00021000".
Ingestion (POST /v1/traces)
Section titled “Ingestion (POST /v1/traces)”- Both ingestion routes need a key with the
ingest:writescope (403KEY_SCOPEotherwise). The scope is checked before the per-key rate limit, so a key that may not ingest spends nothing from it. - Token convention (shared with the SDK):
usage.input_tokensis the total prompt size including cache reads and cache writes;usage.cached_tokensis the part read from the provider’s prompt cache. Since 2026-10-15,usage.cache_write_tokensis the part written to it (every TTL) andusage.cache_write_1h_tokensthe subset of those writes made with the 1-hour TTL; both are optional. Cost =uncached × input_rate + reads × cached_rate + writes_5m × write_5m_rate + writes_1h × write_1h_rate + output × output_rate, withuncached = input - reads - writesandwrites_5m = writes - writes_1h. A missing cached or write rate falls back to the input rate, so a model without published write rates prices as before. A missingcache_write_1h_tokensmeans every write was 5-minute; a missingcache_write_tokensmeans none were reported (priced as before). - Usage checks (per span, the batch continues):
cached_tokens > input_tokensrejects the span (usage.cached_tokens: cannot exceed usage.input_tokens); so doescached_tokens + cache_write_tokens > input_tokens(usage.cache_write_tokens: cached_tokens + cache_write_tokens cannot exceed usage.input_tokens) andcache_write_1h_tokens > cache_write_tokens(usage.cache_write_1h_tokens: cannot exceed usage.cache_write_tokens). Acache_write_1h_tokenssent withoutcache_write_tokensis rejected too (usage.cache_write_1h_tokens: requires usage.cache_write_tokens): the database checkcache_write_1h_tokens <= cache_write_tokenscompares with NULL and would let a subset of nothing through. A span that reports noinput_tokensskips these sum checks, as thecached_tokenscheck always has; it is unpriced anyway. - Span read model:
GET /traces/{trace_id}spans carrycache_write_tokensandcache_write_1h_tokens,nullwhen the span did not report them (every span stored before 2026-10-15, and every OTLP span’scache_write_1h_tokens). - Cost is
null(never 0) for unknown models or when input/output tokens are missing; such LLM spans set the trace’shas_unpriced. - Implausible token counts are unpriced (2026-10-19, owner decision). A span whose
input_tokensoroutput_tokensis over 100,000,000 (MAX_PRICED_TOKENS, far above any model’s context window) is stored as sent, tokens included, withcost_usdandpricing_versionnull(a priced web search fee is kept, as for any span whose tokens are not priced), so it sets the trace’shas_unpricedand counts inunpriced_calls. It is not rejected: an agent span that sums its children (a usage rollup) can pass the bound legitimately, and rejecting it would orphan its children. One buggy client (a span claiming 2,000,000,099 tokens in under a millisecond was priced at $40,409) can no longer wreck a project’s spend, budgets, projections and alerts; token totals still include the count. The cost explanation reports such a span asno_pricewith its own message, andunpriced-modelsleaves it out (its model has a price). The bound applies on every path (native, OTLP, the gateway) and to the judge and playground costs, which share the price book. Spans priced before the change keep their stored cost until they are sent again. - Deviation — model matching: a price pattern matches the model exactly, or followed by a
snapshot suffix (
-YYYYMMDD,-YYYY-MM-DD,-latest); longest pattern wins. Plain prefix matching (as in the original design) would price e.g.gpt-4.1-nanoasgpt-4.1. Matching is case-sensitive (since 2026-10-13, see “Owner decisions 2026-10-13”); the provider is compared case-insensitively, and whenprovideris sent it must match the price’s provider. Since 2026-10-16 the provider is compared after its aliases are folded onto the seeded name (see “Provider aliases for pricing only” under “Auditable cost”). - Unknown span fields are ignored (forward compatible) rather than rejected.
- Per-span rejections (
rejected[].reasonis"<field>: <message>"): schema errors;end_timebeforestart_time;start_timeolder than 90 days;end_timemore than 10 minutes in the future; a span parenting itself; attributes over 64 KB; all-zero ids. Timestamps must be ISO-8601 with an offset or integer unix nanoseconds. Ids are lowercased. - A value the database cannot store is rejected with the span, never by failing the batch: a
timestamp whose offset moves it outside years 1–9999, a
time_to_first_token_mslarger than a double, and a cost of $1,000,000 or more (usage: the cost at this model's price is too large to store; only an organization’s price override can price a call that high). Any other unexpected failure while preparing a span rejects that span alone withspan: could not be processed; it is logged by type and origin and counted inspanlight_span_normalization_failures_total. - A trace’s
cost_usdisnullwhen the sum of its spans’ costs reaches $1,000,000, the most the column holds; the spans keep their own costs. - A
span_idrepeated within one batch keeps the last occurrence; earlier ones are reported inrejectedwith reasonduplicate span_id in batch…. - Trace fields: last non-null wins (within a batch and across batches);
nullnever overwrites.tagsare unioned. Rollups are always recomputed from stored spans, so re-sending is idempotent. - Replaying a batch. Sending the same spans again leaves every column of their rows and of their traces
as it was, except
ingested_at, which moves to the time of the latest send, as long as prices have not changed. The spans are priced again with the prices in force at that moment (the price table and the organization’s overrides), so a price change between two sends changes a span’scost_usdandpricing_versionand the trace’s totals with it. - Payloads: secrets are redacted first, then
input/outputover 1 MiB (1,048,576 bytes of JSON text, each) are replaced by a string holding the first 1 MiB of their JSON text, withtruncated: true(32 KB before 2026-10-19). A span may also sendtruncated: trueitself (the Python SDK does when it cut a long string, a long list, deep nesting or an attribute before sending); the stored flag is set when either side cut. - The body may be
Content-Encoding: gzipordeflate; the 5 MB limit applies after decompression. NaN/Infinity and NUL characters are rejected/stripped. JSON nested more than 64 levels deep, or holding a number of more than 4300 digits or one past a double (1e999, which would be an infinity), is400 INVALID_JSON.
OTLP (POST /v1/otlp/traces)
Section titled “OTLP (POST /v1/otlp/traces)”- Model:
gen_ai.response.modelis preferred overgen_ai.request.model(it is the billed snapshot). - Kind:
llmif anygen_ai.*attribute;httpfor client spans withhttp.*/url.*attributes;retrievalwithdb.*; otherwiseother. (Superseded on 2026-10-17 by the convention kinds in “OTLP (POST /v1/otlp/traces) conventions, usage rollups and usage state” below.) - Trace name: the root span’s name (falling back to
service.name); non-root spans do not set it. - Cached input:
gen_ai.usage.cache_read.input_tokens(current conventions, LiteLLM) is read first, then the oldergen_ai.usage.cache_read_input_tokens. Either waygen_ai.usage.input_tokensis taken to include the cached part. Reading only the older name priced LiteLLM cache reads at the full input rate (+43% on a real LiteLLM workload, 2026-10-14). - Cache writes (since 2026-10-15):
gen_ai.usage.cache_creation.input_tokensis read first, then the oldergen_ai.usage.cache_creation_input_tokens, intocache_write_tokens;input_tokensis taken to include the written part. OpenTelemetry has no attribute for the TTL split, socache_write_1h_tokensstaysnulland every OTLP cache write is priced at the 5-minute rate. - Legacy
gen_ai.usage.prompt_tokens/completion_tokensare accepted. Content attributes are parsed as JSON when they are JSON strings and are removed fromattributes;service.nameis copied intoattributes. - A double that is NaN or infinite (protobuf, or the OTLP/JSON strings
"NaN","Infinity","-Infinity") is stored as that string, sincejsonbhas no such numbers. Inside JSON-encoded content, NaN, Infinity and a number past a double (1e999) become such a string the same way. A malformedintValue/doubleValue, or a value where a JSON object belongs, is400 INVALID_OTLP. - An OTLP/JSON body in which a number is written past a double (
"doubleValue": 1e999, a number literal and not the string"Infinity") is not usable JSON for the server: the whole request is400 INVALID_JSON, as onPOST /v1/traces, and no span of it is stored. - JSON ids may be hex (as in the original design) or base64. Response:
{}or{"partialSuccess": {"rejectedSpans": "<n>", "errorMessage": "…"}}; protobuf mirrors this.
Health and metrics
Section titled “Health and metrics”/metricsreturns 404 whenMETRICS_TOKENis unset, 401 for a wrong/missing bearer./health/readyreportsmigrations: "pending"(503) when the DB is not at the Alembic head./health/readyreportsgateway_spans_lost: the gateway spans this process answered a call for but could not store since it started (the write failed after up to four tries, a span for a deleted project being not retried; its payloads could not be prepared; ingestion refused it; or the write backlog was full). It is per process (the standalone gateway reports its own), resets on restart, and never makes the probe503; the same spans are inspanlight_gateway_record_failures_total.alert_evaluation_lag_sis the age of the newest finished evaluation job. It isnullbefore the first one, withALERTS_EVALUATION_ENABLEDoff, and also once the cleanup job has deleted every finished evaluation job (finished jobs are kept 7 days). A worker that has been stopped for more than a week therefore readsnull, not a large number; the worker heartbeat age is the signal that stays.
Worker / demo
Section titled “Worker / demo”- The demo job’s LLM calls go through the gateway, in process. It calls
executedirectly (no HTTP hop) with the demo project’s own gateway keydemo(environmentdemo, default tagdemo), so itsllmspans carryspanlight.gateway.key_idlike any keyed call. That key has no rate limits, cache TTL or fault profile, and the in-process call checks no limits; its routedemosends every call once to the credentialdemo-anthropic(one attempt, no fallback, 20 s). The job creates all three on first run and sealsANTHROPIC_API_KEYinto the credential, sealing it again (acredential.rotate) only when the configured key differs from the stored one. Those audit events have no actor. The rootchainspan and thefaq-lookupretrieval span are still written by calling the ingestion pipeline function, into the same trace; the gateway spans are namedmessages <model>, as every gateway span is. - The demo job skips (
skipped_not_configured, reason “credentials key not configured”) withoutCREDENTIALS_KEYS, as it does withoutANTHROPIC_API_KEY. An existingdemoroute and key are reused as they are, so before any provider call the job checks them: one attempt, no fallback, a single target ondemo-anthropic, the key on that route, no cache TTL and no fault profile. Anything else skips the run (skipped_not_configured, reason “demo gateway configuration changed”) without calling the provider. - The demo job also skips (with a warning) if any billed demo LLM call this month is unpriced, since the budget could not be enforced. It runs at most one attempt per period (no paid retries).
cleanup_sessionsalso prunes login attempts and throttle events older than a day, email tokens that are used or expired and older than a day, finished jobs older than 7 days, and idempotency keys past their 24 hours.
Trace summaries: error_message (added 2026-10-08)
Section titled “Trace summaries: error_message (added 2026-10-08)”TraceSummary (trace list items and trace detail) gains error_message: string | null — the
status message of the earliest failed span (ties broken by span id), truncated to 500 characters.
null when no span failed or the failed span sent no message. Used by the Overview “Recent errors”
card and the trace list. Computed with a correlated subquery on the spans primary key.
Price catalogue (added 2026-10-09)
Section titled “Price catalogue (added 2026-10-09)”GET /api/v1/priceslists every row of the price table ordered by provider,model_pattern,effective_fromandmin_prompt_tokens; money is a decimal string. Since 2026-10-15 each row also carriescache_write_5m_per_mtokandcache_write_1h_per_mtok(nullwhen the provider bills cache writes at the input rate, and for every row of the 2026-10-01 snapshot) andmin_prompt_tokens, the smallest prompt the row prices (0 for the base tier). A model with a prompt-length tier has one row per tier; the row with the highestmin_prompt_tokensnot above the span’sinput_tokensprices the whole prompt. Any signed-in user may read it (session or access token); API keys get 403KEY_SCOPE. There is no write endpoint: prices change throughspanlight sync-prices.GET /api/v1/projects/{project_id}/unpriced-modelsneedsproject:read(an API key withtraces:readmay read its own project) and takes the usualfrom/towindow. It groups LLM spans started in the window by provider and model, counting those with a model, both token counts and a NULL cost: the model matched no price at ingestion. Spans missing a model or a token count, and spans over the implausible token bound, are NULL-cost for another reason and are not listed, so the list can be shorter than the overview’sunpriced_calls. Most-called first, at most 100 models.
Price overrides (added 2026-10-09)
Section titled “Price overrides (added 2026-10-09)”An organization can set its own rates for a model. The routes are under
/api/v1/orgs/{org_id}/price-overrides: GET (org:read) lists them ordered by provider,
model_pattern, effective_from and min_prompt_tokens, POST (gateway:write, owners and admins) adds one and
returns 201, DELETE /{override_id} returns 204. Non-members get 404, and so does an override of
another organization.
- Body:
provider,model_pattern,input_per_mtok,output_per_mtok, optionalcached_input_per_mtok,cache_write_5m_per_mtokandcache_write_1h_per_mtok(USD per million tokens, not negative, below 1,000,000, at most six decimals; a missing cached or write rate falls back to the input rate), optionalmin_prompt_tokens(an integer >= 0, default 0; a negative or non-integer value is422 VALIDATION_ERROR) and optionaleffective_from(defaults to now; a time without an offset is UTC).providerandmodel_patternare trimmed and stored lower-case, as in the price table. The response and the list echo all three new fields (the ratesnullwhen not set). - The same provider, pattern,
effective_fromandmin_prompt_tokenstwice in one organization is409 PRICE_OVERRIDE_EXISTS; the same pattern with anothermin_prompt_tokensis a second tier of the override. - Tiers (since 2026-10-15): an override is one tier of a model. Among the seed rows and
overrides that match a span, the row with the highest
min_prompt_tokensnot above the span’sinput_tokenswins; ties keep the precedence below (longest pattern, override over seed, latesteffective_from). So an organization that overrides only the base tier (min_prompt_tokens: 0) ofclaude-haiku-5-5keeps the seed price for prompts over 100,000 tokens, and must add a second override withmin_prompt_tokens: 100001to change that tier too. - Matching follows the seed prices: a pattern matches a model exactly or with a snapshot suffix
(
-YYYYMMDD,-YYYY-MM-DD,-latest), never by prefix. The longest matching pattern wins whatever its source; for equal patterns an override beats the seed row. An override applies to a span whose start time is at or after itseffective_from. - A span priced by an override stores
pricing_versionoverride:<id>. Spans already stored keep the cost they were priced with; adding or deleting an override affects spans ingested afterwards. Replaying a span (sending the same span id again through native ingestion) re-prices it with the overrides in force at replay time. A span the gateway recorded is never replayed over (see Span attribution under Gateway keys), so its cost stays as the gateway priced it. - Audit events
price_override.createandprice_override.deletehave target typeprice_overrideand the provider, pattern andmin_prompt_tokensin their metadata.
Email verification (added 2026-10-08)
Section titled “Email verification (added 2026-10-08)”Verification is soft: an unverified user uses the product normally. Two actions need a verified email address when email is configured (409 EMAIL_UNVERIFIED): turning on two-factor authentication and linking a sign-in provider.
Usergainsemail_verified: boolean, true once the owner of the address has proven it. It is on every response that embeds a user: signup, login,GET /api/v1/auth/me(asuser.email_verified), members, API key creators and audit actors.GET /api/v1/auth/megains a top-levelemail_verification_requiredboolean, next tohas_password: true only when the server can send email, the caller’s address is not yet verified and the caller is not the shared demo account (which is never asked and cannot receive email). It exists so the dashboard knows whether to ask for verification at all, since on a server without email no link can arrive.POST /api/v1/auth/email/verify/request(session and CSRF) → 202{"status": "accepted"}and queues a verification email for the signed-in user. The body is theAcceptedOutshape, meant for any request whose work is finished later by the worker. Checked in this order:- 409
NOT_CONFIGUREDwhen email is not configured.detailnamesEMAIL_PROVIDERandEMAIL_CONSOLE_FILE; theconsoleprovider counts as configured only whenEMAIL_CONSOLE_FILEis set, since logging an address delivers nothing. - 403
FORBIDDENfor the shared demo user. Everyone who starts a demo session is signed in as that one anonymous account, so no visitor may make the server send mail to its address. - 409
EMAIL_ALREADY_VERIFIED. - 429
RATE_LIMITEDwithRetry-After(whole seconds, rounded up) after 3 requests per user per hour. The email sent at signup is not counted, and refused requests are not recorded, so retrying early does not extend the wait. The count lives in Postgres, so it holds across API replicas.
- 409
POST /api/v1/auth/email/verify/confirmwith{"token"}→ 200User. It needs no session, because the link is often opened in a browser that is not signed in; the Origin check still applies and CSRF does not (there is no session to bind it to).- 404
NOT_FOUND, the same response for every bad token: unknown, already used, expired, or issued for another purpose (a password-reset token). Nothing tells a caller which. A token presented to the wrong route is not spent. - Confirming an address that is already verified (with a second valid token) returns 200 and keeps the original verification time.
- 404
- The link is
{APP_BASE_URL}/verify-email#token=<token>. The token is in the URL fragment, which browsers send neither to the server nor in aRefererheader, so it stays out of access logs; the page reads the fragment and posts it to the confirm route. - The token is 32 random bytes in URL-safe base64 (43 characters), stored only as its SHA-256, single use, and valid for 24 hours. Concurrent confirmations with one token succeed exactly once.
- Signup queues the verification email in the signup transaction when email is configured, so the account, its token and the queued email commit together. Without email configured nothing is queued and the user stays unverified.
Password reset (added 2026-10-08)
Section titled “Password reset (added 2026-10-08)”Both routes need no session, because the person has forgotten their password. The Origin check still applies and CSRF does not (there is no session to bind it to). Neither sets a cookie.
POST /api/v1/auth/password/forgotwith{"email"}→ 202{"status": "accepted"}(AcceptedOut).- The answer is the same for every address. A known address, an unknown one and the demo user’s get the same status and the same body; only a known, non-demo address also gets a token and a queued email. Unknown addresses queue nothing. Both kinds are counted by the limits below first, so hitting a limit says nothing about whether an account exists.
- 409
NOT_CONFIGUREDwhen email is not configured, with the samedetailas the verification request. It is the operator’s state, so it is the same for every address and is checked before the limits (such a request is not counted). - 429
RATE_LIMITEDwithRetry-After(whole seconds, rounded up) after 3 requests per email address per 15 minutes, or 10 per client IP per 15 minutes. The email address is compared case-insensitively. The email limit is checked first, then the IP limit, always in that order. Refused requests are not recorded, so retrying early does not extend the wait. The counts live in Postgres (throttle_events), so they hold across API replicas. The sign-in throttle is separate and unchanged: it still counts failed sign-ins inlogin_attempts. - Asking again retires the reset links sent before: at most one reset link is live per user. Verification links are not affected.
- Validation:
emailmust be a valid address (422VALIDATION_ERROR).
POST /api/v1/auth/password/resetwith{"token", "password"}→ 204, no body.passwordis 10–256 characters, the same rule as signup.- 404
NOT_FOUND, the same response for every bad token: unknown, already used, expired, or issued for another purpose (a verification token). A token presented to the wrong route is not spent. A password that fails validation (422) does not spend the token either, so the link can be used again with a better one. - A successful reset is one transaction. It sets the new password hash; ends every session of
the user; sets
email_verified_atif it was unset, since the link reached the inbox (an earlier verification time is kept); marks the user’s other unused reset links as used; and writes auser.password_resetaudit event, withactorthe user,targetthe user andmetadata{"via": "email"}, to each org the user belongs to. - No session survives the change. A sign-in that was verifying the old password while the reset
committed either is refused (401
INVALID_CREDENTIALS) or has the session it created ended by the reset; it never keeps one. The same holds forspanlight reset-password. - It does not sign the user in. The person signs in afterwards with the new password.
- 404
- The link is
{APP_BASE_URL}/reset-password#token=<token>, with the token in the URL fragment for the same reason as the verification link. The page reads the fragment and posts it to the reset route. - The token has the same format as the verification token (43 characters, stored only as its SHA-256, single use) and is valid for 1 hour. Concurrent resets with one token succeed exactly once.
- The demo user cannot be reset by email:
forgottreats its address like an unknown one, and it sends nothing. - The reset email body, which holds the link, is kept in the notification outbox only until the row is settled. Once it is sent, or has failed for good, the stored payload is reduced to the subject.
Sign in with GitHub and Google (added 2026-10-08)
Section titled “Sign in with GitHub and Google (added 2026-10-08)”A provider is configured when both its OAUTH_<PROVIDER>_CLIENT_ID and OAUTH_<PROVIDER>_CLIENT_SECRET
are set (a blank value counts as unset). Setting only one of a pair stops the app at startup, and the
message names the two variables, not their values. The callback URL to register with the provider is
{APP_BASE_URL}/api/v1/auth/oauth/{provider}/callback.
| Route | Auth | Behaviour |
|---|---|---|
GET /api/v1/auth/oauth/providers |
none | [{"provider": "github"}, {"provider": "google"}], configured providers only, in that order |
GET /api/v1/auth/oauth/identities |
session | [{"provider", "email", "created_at", "last_used_at"}], the caller’s linked accounts, oldest first |
GET /api/v1/auth/oauth/{provider}/start?next=&intent=sign_in|link |
none (link needs a session) |
302 to the provider; 404 NOT_FOUND for an unknown or unconfigured provider; 401 UNAUTHORIZED for intent=link without a session; for intent=link also 403 FORBIDDEN for the demo account and 409 EMAIL_UNVERIFIED for an unverified account (see Linking); 422 for another intent |
GET /api/v1/auth/oauth/{provider}/callback?code&state |
the spl_oauth cookie |
302, always (see below); 404 for an unconfigured provider |
DELETE /api/v1/auth/oauth/{provider} |
session + CSRF | 204; 404 NOT_FOUND if that provider is not linked; 409 LAST_SIGN_IN_METHOD if the user has no password and no other linked provider |
startandcallbackare browser navigations, so they never answer with problem+json once the provider is known. A failed callback is a302to{APP_BASE_URL}/login?error=<CODE>, with<CODE>one ofOAUTH_STATE,OAUTH_PROVIDER_ERROR,EMAIL_UNVERIFIED_AT_PROVIDER,ACCOUNT_EMAIL_UNVERIFIEDorOAUTH_ALREADY_LINKED. Every query parameter of the callback is optional, so a malformed callback gets this redirect rather than a422. All of these responses carryCache-Control: no-store.- The state cookie
spl_oauthis set bystartand cleared by every callback, whatever its outcome. It is httpOnly,SameSite=Lax,SecurewhenAPP_BASE_URLis https, scoped toPath=/api/v1/auth/oauthand lives 10 minutes. Its value is signed withSECRET_KEY(HMAC-SHA256) and holds thestatesent to the provider, the PKCE verifier, the provider, the intent,nextand, for a link, the user who started it. The callback needs the cookie to be present, correctly signed, unexpired and for the same provider, and itsstateto equal thestatequery parameter (compared in constant time); otherwise it redirects withOAUTH_STATE, before anything is sent to the provider. This is what stops a login CSRF: a callback URL built from someone else’scodehas no matching cookie in the victim’s browser. nextmust be a path that starts with exactly one/. Anything else (//host,/\host, a scheme, a control character anywhere, more than 2048 characters) becomes/. The default is/for a sign-in and/settings/securityfor a link. The redirect is always{APP_BASE_URL}{next}.- The provider exchange uses the authorization-code flow with PKCE (
S256). The access token is used for the profile calls only and is neither stored nor logged. Calls time out after 10 seconds. A refused code, an unreachable provider, a bad status or a malformed answer isOAUTH_PROVIDER_ERROR, as is the user declining at the provider (error=access_denied). Scopes: GitHubread:user user:email, Googleopenid email profile. - The profile: GitHub’s
/user(id, name, login) plus/user/emails(the primary address and itsverifiedflag); Google’s userinfo (sub,email,email_verified,name). An email counts as verified only when the provider says so. A provider that reports no primary address gives no email.
Resolution of a sign-in, in this order:
- An identity with this
(provider, subject)exists: that user signs in. The subject is the provider’s stable account id; a changed or unverified email does not matter. - Otherwise, if a user has the same email: the provider must say it is verified (else
EMAIL_UNVERIFIED_AT_PROVIDER) and the local account’s email must be verified (elseACCOUNT_EMAIL_UNVERIFIED); with both, the identity is linked and the user signs in. A user that already has a different account of the same provider linked getsOAUTH_ALREADY_LINKED. The shared demo account is never matched. - Otherwise, if the provider says the email is verified, a new user is created with no password,
email_verified_atset, and the provider’s name (the part of the email before@if there is none; at most 100 characters). If the email is not verified, or there is none, no account is created (EMAIL_UNVERIFIED_AT_PROVIDER): GitHub lets anyone attach an unverified address to their account, and an account created for it would keep that GitHub identity after the real owner of the address recovered it.
On success the callback creates a session exactly like password login (same cookies, same lifetimes) and
redirects to {APP_BASE_URL}{next}. Concurrent first sign-ins for one account create one user.
For a user with two-factor authentication on, the callback creates no session and sets no session
cookie. It redirects to {APP_BASE_URL}/login/two-factor#challenge=<challenge> instead (the challenge is in
the fragment, so no server sees it), and the page finishes with POST /api/v1/auth/totp/verify. next is
not carried over. What the callback wrote before that point stays: a new user, or an identity linked
because both emails are verified. That identity is the user’s own provider account, and every sign-in
through it still needs the second factor.
Linking (intent=link): the identity attaches to the signed-in user, who must be the user who started
the flow, else OAUTH_STATE. The provider’s email is not checked, since the person already proved who they
are with their session and sign-in never goes by that email. An account already linked to someone else, or a
different account of the same provider than the one the user already linked, is OAUTH_ALREADY_LINKED.
Linking the account that is already linked is a no-op.
- Only verified accounts can link, when email is configured. Anyone can register an address that is not
theirs, and a provider attached to that unproven account would keep a way in after the address’s real
owner recovered the account. So with email configured (
EMAIL_PROVIDERset up, seeNOT_CONFIGURED),start?intent=linkanswers 409EMAIL_UNVERIFIEDfor a user whose email is not verified, and the callback checks again and redirects withACCOUNT_EMAIL_UNVERIFIED, linking nothing. Without email nobody can verify an address, and the email recovery that makes this dangerous cannot run, so linking stays allowed for unverified accounts. - The demo account cannot link:
start?intent=linkanswers 403FORBIDDENfor it, whether or not email is configured, and the callback refuses withACCOUNT_EMAIL_UNVERIFIED. It is shared and anonymous, so a visitor’s provider account would otherwise stay bound to it, show up in its identity list for later visitors and put the visitor’s IP in the demo org’s audit log. - Ruling: a link returns to
next. The login page sends signed-in users away, which would lose an error shown there, so forintent=linkboth success and failure redirect to{APP_BASE_URL}{next}; a failure appendserror=<CODE>to the query string (nextkeeps its own query and fragment). - Unlinking takes a lock on the user, so two simultaneous unlinks cannot remove every way to sign in. It works whether or not the provider is still configured.
- Audit:
user.oauth_link(metadata:provider, andviaofemail_matchorlink) anduser.oauth_unlink(metadata:provider) are written to each org the user belongs to, withactorandtargetthe user. Creating a user through a provider writes nothing, since the user has no org yet.
Users without a password
GET /api/v1/auth/megains a top-levelhas_passwordboolean: false for a user who signed up with a provider and never set a password, and for the shared demo account. It is not onUserOut, which other people’s profiles are also shown through.POST /api/v1/auth/loginfor such a user answers exactly as for an unknown address: 401INVALID_CREDENTIALS, after a dummy password verification so it takes as long, and counted as a failed attempt.- Such a user can set a password with the password reset flow; the email proves the inbox.
Password reset and linked providers (added 2026-10-08)
Section titled “Password reset and linked providers (added 2026-10-08)”A password reset that verifies a previously unverified email (the first time the address is proven) also
removes every sign-in provider linked to that user, in the same transaction, and writes a
user.oauth_unlink audit event per removed provider to each org the user belongs to, with metadata
{"provider", "reason": "password_reset"}. Until then the address was unproven, so whoever registered it
could have attached a provider that would otherwise stay on the account after the owner recovered it. The
owner can link their own providers again afterwards. A reset for an account whose email was already
verified leaves its providers alone. spanlight reset-password (the admin CLI) changes neither.
Two-factor authentication (added 2026-10-08)
Section titled “Two-factor authentication (added 2026-10-08)”Time-based one-time passwords (RFC 6238: HMAC-SHA1, 6 digits, 30-second steps) with recovery codes. It
needs CREDENTIALS_KEYS, which seals the secret in the database (AES-256-GCM, see NOT_CONFIGURED).
| Route | Auth | Request | Response |
|---|---|---|---|
GET /api/v1/auth/totp |
session | {"enabled", "enabled_at", "recovery_codes_remaining"} |
|
POST /api/v1/auth/totp/setup |
session + CSRF | {"secret", "otpauth_url"}; replaces a pending secret; 409 TOTP_ALREADY_ENABLED; 409 NOT_CONFIGURED without CREDENTIALS_KEYS; 403 FORBIDDEN for the demo account |
|
POST /api/v1/auth/totp/enable |
session + CSRF | {"code"} |
{"recovery_codes": [10 strings]}; 422 INVALID_TOTP_CODE; 409 TOTP_ALREADY_ENABLED; 409 EMAIL_UNVERIFIED; 403 FORBIDDEN for the demo account |
POST /api/v1/auth/totp/disable |
session + CSRF | {"code"} (TOTP or recovery) |
204; 422 INVALID_TOTP_CODE; 409 TOTP_NOT_ENABLED; 429 |
POST /api/v1/auth/totp/verify |
none (Origin check) | {"challenge", "code"} |
200 LoginOut with cookies; 401 INVALID_TOTP_CODE; 401 TOTP_CHALLENGE_INVALID; 429 |
- Login now returns
LoginOut, a union discriminated bystatus:{"status": "signed_in", "user": User}with the session cookies set, as before but wrapped, or{"status": "totp_required", "challenge", "expires_at"}with no cookies when the password was right and the account has two-factor authentication. A wrong password still answers401 INVALID_CREDENTIALSbefore any of this, so the challenge only ever means “the first step passed”. - The challenge is signed with
SECRET_KEY(HMAC-SHA256, its own signing context) over the user id, an expiry, a random nonce and a fingerprint of the user’s password, and lasts 5 minutes. It is not stored. It signs nobody in by itself; it names whom a code is for. Expired, forged, malformed, for a user that does not exist, for a user whose two-factor authentication has been turned off since, and issued before the user’s password changed: all401 TOTP_CHALLENGE_INVALID. - Ruling: a challenge does not outlive a password change. The fingerprint is the first 16 bytes
(hex) of SHA-256 over the stored password hash, or a fixed value for an account with no password.
verifyrecomputes it from the user row it has locked, so any change of the password (a reset by email, the admin CLI, setting a first password on a provider-only account) ends every open challenge of that user, and the person signs in again with the new password. A password is often replaced because it leaked, and a sign-in that passed the first step with the old one must not become a session afterwards. The token is signed, not encrypted, and carries only the truncated digest, never the hash. The refused attempt is not counted as a failed sign-in. - Ruling: a sign-in sees two-factor authentication being turned on while it runs. The password check is
slow, so a person can finish enabling two-factor authentication in another browser while a password
sign-in (or a GitHub/Google callback) for the same account is in flight. Both read the user row under a
shared lock after the first step and decide from that read whether to issue a challenge or a session, so
the late sign-in gets
totp_required(or the redirect to the second step) and never a password-only session. - Codes. A code is accepted for the current 30-second step and one step either side. It is accepted once: the step of the last accepted code is stored, and a code for an earlier or equal step is refused, so a code that was seen cannot be replayed. The code that enables two-factor authentication counts as used, so signing in right after enabling needs the next code. Spaces and hyphens in a typed code are ignored.
- Recovery codes.
enablereturns 10 codes of 10 characters froma-z2-7, shown asabcde-fghij, once and never again. Only their SHA-256 is stored. A recovery code is accepted wherever a code is (verify,disable), case-insensitively and without the hyphen, and works once.GET /auth/totpreports how many are left.disabledeletes all of them, andenablemakes a fresh set. - Two checks on one account never both succeed with one code.
verify,enableanddisableread the user rowFOR UPDATE, so two requests presenting the same code run one after the other and the second is refused. - Throttling. Wrong codes at
verifyare recorded as failed sign-ins for the account’s email and the client IP, so they share the password’s limit: 5 failures per email (20 per IP) in 15 minutes, and the next attempt is429withRetry-After, checked before the code is looked at. The user row is locked before the failures are counted, so concurrent attempts cannot overshoot the limit. A throttled request records nothing.disableneeds a code too, and a stolen session must not be able to try them all, so it allows 5 attempts per user per 15 minutes (429 RATE_LIMITED); this limit is independent of the sign-in one. - Ruling:
enableneeds a verified email when email is configured (409 EMAIL_UNVERIFIED, checked before the code), for the reason given under linking providers: anyone can register an address that is not theirs, and a second factor set on that account would outlast the real owner’s recovery. Without email configured nobody can verify an address, so the rule does not apply. - Ruling:
enablewithout asetupis422 INVALID_TOTP_CODE, with adetailthat says to start the setup first; there is no secret for any code to be right for.setupnever reveals an existing secret: it answers409 TOTP_ALREADY_ENABLEDonce enabled, and replaces the secret before that. - Ruling: the demo account cannot set up or enable two-factor authentication (
403 FORBIDDEN): it is a single shared, anonymous account, and one visitor turning it on would lock every later visitor out. - Secrets are returned once and never logged. The
setupresponse carriessecret, theenableresponse carries the recovery codes, and both are sent withCache-Control: no-store. The secret is stored sealed, with the id of the key it was sealed under. If the secret can no longer be opened (the key leftCREDENTIALS_KEYS), signing in fails closed instead of skipping the second factor; recovery codes still work. - Operator recovery. Someone who lost both the authenticator app and the recovery codes cannot turn
two-factor authentication off themselves, since that needs a code.
spanlight reset-2fa --email <address>does it from the host: it clears the secret, the stored step and every recovery code in one transaction and writes auser.totp_disableaudit event to each org of the user with no actor andmetadata{"via": "cli"}, then prints one line. An unknown address exits non-zero and changes nothing; a user without two-factor authentication exits zero, says so and writes nothing. The password and the sessions are not touched (spanlight reset-passwordrevokes sessions). The user then signs in with the password alone and can enrol again; in an org that requires two-factor, they must. GET /api/v1/auth/megains a top-leveltotp_enabledboolean, next tohas_password. It is not onUser, which other people’s profiles are also shown through.- Audit:
user.totp_enableanduser.totp_disableare written to each org the user belongs to, withactorandtargetthe user and nometadata. A failed attempt writes nothing.
Requiring it for an organization
PATCH /api/v1/orgs/{org_id}with{"require_2fa": true|false}needs the owner-only permissionorg:security(admins get403 FORBIDDEN, non-members404).require_2famust be a boolean:nullor anything else is422 VALIDATION_ERROR. Setting it to its current value is a no-op:200, no audit event. The same route also renames the organization; see Organization and project administration.- Turning it on needs
CREDENTIALS_KEYS(else409 NOT_CONFIGURED) and two-factor authentication on the owner’s own account (else409 TWO_FACTOR_NOT_ENABLED), so the owner can always meet the rule they set. Turning it off needs neither. Each change writes anorg.updateaudit event whosemetadatahas"require_2fa": {"from": <bool>, "to": <bool>}. - While it is on, a member of that org without two-factor authentication gets
403 TWO_FACTOR_REQUIREDon every route scoped to the org or one of its projects. Membership is checked first, so a non-member still gets404; the check comes before the role check./api/v1/auth/*is not affected, which is how the member turns two-factor on, andGET /auth/mestill lists the org. Ingestion with an API key is not affected: a key is not a person signing in. - A member who turns their own two-factor authentication off while an org of theirs requires it is locked out of that org until they enable it again. Nothing prevents it.
API key scopes, expiry and bearer requests (added 2026-10-08)
Section titled “API key scopes, expiry and bearer requests (added 2026-10-08)”A project API key (spl_live_…) used to do one thing, ingest. It now carries scopes and may expire, and a
key can read its own project through the dashboard API.
| Scope | Allows |
|---|---|
ingest:write |
POST /v1/traces and POST /v1/otlp/traces |
traces:read |
the read routes listed below, for the key’s own project |
scores:write |
POST /v1/scores (added 2026-10-11) |
prompts:read |
GET /v1/prompts/{name} (added 2026-10-11) |
- Creating a key.
POST /api/v1/projects/{project_id}/keystakes{"name", "scopes"?, "expires_at"?}.scopesis a non-empty list of the values above and defaults to["ingest:write"]; repeated values are stored once, in the order first given. An unknown scope, an empty list, or anexpires_atthat is not in the future is422 VALIDATION_ERROR(errors[].fieldisscopes…orexpires_at).expires_atis an ISO-8601 time; one without an offset is read as UTC, andnull(the default) means the key never expires. The database enforces the scope list as well (api_keys_scopes_check). - Shapes.
ApiKeygainsscopes(list of strings) andexpires_at(time ornull), in the list and in the 201 response. Thekey.createaudit event’smetadatagainsscopesandexpires_at. Keys created before this change have["ingest:write"]and no expiry, so none of them changes behaviour. - Ingestion. A key without
ingest:writegets403 KEY_SCOPEon both ingestion routes. - Expiry. A key at or past its
expires_atis401 KEY_EXPIRED, on ingestion and on reads. This is only said to a caller who proved they hold the key: a wrong secret is401 UNAUTHORIZEDwhatever the key’s state, and a revoked key is401 UNAUTHORIZEDeven if it has also expired. - Revocation delay. Each api process caches the key rows it has looked up for
API_KEY_CACHE_TTL_SECONDS(default 10,0turns it off), so revocation takes effect withinAPI_KEY_CACHE_TTL_SECONDSon each api process. The process that served the revoke, or the deletion of the key’s project or organization, drops its copy at once; a revocation through SQL waits out the TTL everywhere. The secret,revoked_atandexpires_atare still checked on every request, so a wrong secret and a key whose expiry has passed are refused at once; only rows that exist are cached. Personal access tokens and sessions are not cached. - Reading with a key. A key with
traces:readmay call these routes, and only for its own project:GET /api/v1/projects/{project_id}/traces,…/traces/{trace_id},…/sessions,…/filters,…/metrics/overview,…/metrics/timeseries,…/metrics/models,…/releases,…/releases/compare,…/usersand…/users/{external_user_id}. Responses are the same as for a session.- The project in the path must be the key’s. Any other project, existing or not, is
404 NOT_FOUND, the same answer a non-member gets, and the check comes before the scope check. Row-level security is bound to the key’s project for the request, as it is for a member. - For its own project, a key without
traces:readis403 KEY_SCOPE. - Every other
/api/v1route answers an API key with403 KEY_SCOPE. That includes the routes that only a session may use (/auth/*, creating an organization, accepting an invite) and the routes that need no sign-in at all (signup, login, email verification, password reset,POST /auth/totp/verify,POST /demo/sessionand the/auth/oauth/…routes): a key is refused there rather than served as an anonymous request, and nothing is created or signed in. A key never has an organization role, so it cannot manage keys, members or projects. - A bearer that is not a valid, unrevoked, unexpired key is
401on every/api/v1route (UNAUTHORIZED, orKEY_EXPIREDfor an expired key), before any of the above. - A key is not a user, so reads with it are not subject to
TWO_FACTOR_REQUIRED, which is a check on a person’s account. Creating the key is: it needs a session, and an organization that requires two-factor authentication refuses a member without it (see Two-factor authentication). - Reads update
last_used_atlike ingestion (at most once a minute), including requests that are then refused with 403 or 404, since the key did authenticate.
- The project in the path must be the key’s. Any other project, existing or not, is
- Bearer requests ignore cookies. When a request to
/apihas anAuthorization: Bearer …header, itsCookieheader is dropped before routing and the CSRF andOriginchecks are skipped: a bearer credential is not attached by the browser on another site’s behalf. The header is never combined with, or replaced by, a cookie. A bearer that is empty or carries a key that is malformed or unknown is401 UNAUTHORIZEDeven when the request also has a valid session cookie. The scheme name is matched case-insensitively. A page on another site cannot add the header to a request without a CORS preflight, and the API sends no CORS headers. - Only
Bearercounts. Any otherAuthorizationscheme (Basic, Negotiate, Digest, or an empty value) is ignored: the request keeps its cookies and is checked as if the header were absent, including theOrigincheck and CSRF. Browsers attach Basic and Negotiate credentials by themselves when a proxy in front of the app asks for them, so treating those as a bearer would let a cross-site request skip theOrigincheck (a login CSRF). A signed-in dashboard request that happens to carry such a header keeps working through its cookie.
Personal access tokens (added 2026-10-08)
Section titled “Personal access tokens (added 2026-10-08)”A personal access token (PAT, spl_pat_<12 characters>_<32 characters>, lowercase base32) is a credential a
person creates for scripts and tools. It acts as that person on the dashboard API, with their organization
roles, and never as more than its own scope allows. It is sent as Authorization: Bearer spl_pat_…. Only a
signed-in browser session manages tokens.
| Route | Auth | Request | Response |
|---|---|---|---|
GET /api/v1/auth/tokens |
session | none | [{id, name, prefix, scope, created_at, expires_at, last_used_at}], active tokens, newest first |
POST /api/v1/auth/tokens |
session | {"name", "scope", "expires_at"?} |
201, the same object plus "token" |
DELETE /api/v1/auth/tokens/{token_id} |
session | none | 204 |
- Creating.
nameis 1 to 100 characters.scopeisreadorwriteand has no default: leaving it out, or sending another value, is422 VALIDATION_ERROR.expires_atfollows the rules for API keys: an ISO-8601 time in the future (one without an offset is UTC), ornull(the default) for a token that never expires. The 201 carriesCache-Control: no-store. - The secret is shown once.
tokenis in the 201 response and nowhere else. The list,GET /auth/meand every later response omit it, and the database keeps only the SHA-256 of the secret. Theprefix(spl_pat_and 12 characters) is what the list shows to tell tokens apart. A token cannot be recovered; create another. - Listing returns the caller’s tokens that can still be used: revoked and expired tokens are not listed.
- Revoking sets
revoked_atand is idempotent: revoking a revoked token is another204and keeps the first time. A token that does not exist, or belongs to someone else, is404 NOT_FOUND, and revoking never reveals which. The revoked token is401 UNAUTHORIZEDfrom its next use. - The demo account cannot create tokens (
403 FORBIDDEN). Every demo visitor is the same user, so a token would outlive the visit and be usable by anyone who saw it. - Creating and revoking are logged, not audited: the structured logs
pat_createdandpat_revokedcarry the user id, token id and scope (and the expiry, for creation), and never the token or its secret. A token belongs to a user and not to an organization, so there is no organization audit event for it.
What a token may do
- Memberships are read on every request. A token has no permissions of its own. The organization or
project in the path is looked up against the owner’s memberships on each call, so removing the member, or
changing their role, takes effect on the next request. A project or organization the owner does not belong to
is
404 NOT_FOUND, the answer a session gets, and that is decided before the scope or the role. - Scope. Every permission is classed
readorwrite.org:read,project:readandaudit:readareread; every other permission iswrite. Awritetoken may do whatever its owner’s role allows. Areadtoken is refused with403 TOKEN_SCOPEon awritepermission, and also on any method other than GET, HEAD and OPTIONS: leaving an organization (DELETE /orgs/{org_id}/members/{own id}) needs onlyorg:read, and a read-only token must not be able to do it. Note that listing invites needsmember:manage, which is awritepermission, so areadtoken cannot list invites. The checks run in this order: a non-member is404, then403 TWO_FACTOR_REQUIRED, then403 FORBIDDENfor a role without the permission, then403 TOKEN_SCOPE. A token is therefore never told anything a session of the same user would not be. - Two-factor authentication applies. In an organization that requires it, a token whose owner has not
turned it on gets
403 TWO_FACTOR_REQUIREDon the organization’s routes, like their session.GET /auth/mestill answers, so a tool can tell whose token it is. - Bearer requests ignore cookies,
Originand CSRF, as for API keys: theCookieheader is dropped and a token is never combined with a session. A request that carries a token and the cookies of another user is served as the token’s user. A bearer that is malformed, unknown, or revoked is401 UNAUTHORIZED, even when a valid session cookie is also sent. - Expiry. A token at or past its
expires_atis401 TOKEN_EXPIRED, on every/api/v1route. As for API keys this is only said to a caller who proved they hold the token: a wrong secret is401 UNAUTHORIZEDwhatever the token’s state, and a revoked token is401 UNAUTHORIZEDeven if it has also expired. last_used_atis written at most once a minute, including for requests that are then refused with 403 or 404, since the token did authenticate.
Where a token is accepted
- Every
/api/v1route that names an organization or project (guarded by a permission), under the rules above. These include the read routes an API key withtraces:readmay use, which a token reaches in every project its owner belongs to and not only one. GET /api/v1/auth/me, which answers with the owner’s account and memberships. It does not set cookies for a token.- Nowhere else. Every route that needs a signed-in session answers a token with
403 SESSION_REQUIRED: all of/api/v1/auth/*exceptGET /auth/me(so a token cannot create, list or revoke tokens, change two-factor authentication, or manage sessions or linked accounts),POST /orgs,GET /invites/previewandPOST /invites/accept. So do the routes that need no sign-in (signup, login, email verification, password reset,POST /auth/totp/verify,POST /demo/session,/auth/oauth/…): a token is refused there rather than served as an anonymous request. An API key gets403 KEY_SCOPEon all of these, as before. - A token is not an API key: on
POST /v1/tracesandPOST /v1/otlp/tracesit is401 UNAUTHORIZED, and an API key on the token routes is403 KEY_SCOPE.
Bearer reads of /api/v1 (GET and HEAD, with a token or an API key) are rate limited per credential: 20
requests a second with bursts of 40, counted once the credential is known to be valid. Over the limit the answer
is 429 RATE_LIMITED with Retry-After. Bearer writes and signed-in browsers are not limited per credential.
Organization and project administration (added 2026-10-09)
Section titled “Organization and project administration (added 2026-10-09)”| Route | Permission | Request | Response |
|---|---|---|---|
PATCH /api/v1/orgs/{org_id} |
org:update (admin, owner); org:security (owner) for require_2fa |
{"name"?, "require_2fa"?} |
200 Org; audit org.update |
DELETE /api/v1/orgs/{org_id} |
org:delete (owner) |
{"confirm": "<org slug>"} |
204 |
DELETE /api/v1/projects/{project_id} |
project:delete (admin, owner) |
{"confirm": "<project slug>"} |
204; audit project.delete |
Renaming
PATCH /orgs/{org_id}takesname,require_2fa, or both. At least one is required: an empty body is422 VALIDATION_ERROR. A field that is left out is left alone, andnullis not a way to leave it out: it is a422too (the OpenAPI document lists the fields as nullable, but the route refusesnull). Unknown fields are422.namefollows the rules for creating an organization: trimmed, 1 to 100 characters.- A rename changes the name only. The
slugstays as it was, because it is what a deletion asks to be typed out and what links and the audit log refer to. - Each field has its own permission and a request with both needs both: an admin who sends
require_2fa, alone or with aname, gets403 FORBIDDENand nothing is applied. If turningrequire_2faon is refused (409 NOT_CONFIGURED,409 TWO_FACTOR_NOT_ENABLED), anamesent with it is not applied either. - Only values that change are applied. Sending the current values is a no-op:
200, no audit event. A change writes oneorg.updateevent whosemetadatalists just the changed fields, each as{"from": …, "to": …}:{"name": {"from": "Acme", "to": "Acme Labs"}}, or bothnameandrequire_2fa.
Deleting
- The caller types the slug of the organization (or project) being deleted as
confirm. It is compared exactly as typed: not trimmed, and case-sensitive, so the display name does not match. A wrong value is422 CONFIRMATION_MISMATCHand nothing is deleted. A body withoutconfirm, or with something that is not a string of at most 200 characters, is422 VALIDATION_ERROR. Permission is checked first, so a caller who may not delete gets403 FORBIDDEN(a non-member404) whatever they typed. - Organization. Its projects are deleted first, with their traces, spans and API keys, and then the
organization, with its memberships, invites and audit events, all in one transaction: either all of it is
deleted or none is. It is synchronous, which is fine for the organizations this serves. People are not
deleted: they simply no longer belong to it, so
GET /auth/mestops listing it. Its API keys stop working (401). An organization has no audit log left to write to, so the deletion is logged instead: the structured logorg_deletedcarriesorg_id,project_idsandactor_user_id, and nothing else. - Project. Its traces, spans and API keys are deleted with it. The organization’s audit log keeps a
project.deleteevent, written in the same transaction, withtarget_typeproject,target_idthe project’s id andmetadata{"name": "<name>", "slug": "<slug>"}, since the project can no longer be looked up. Its slug can be used again by a new project. - The demo organization cannot be renamed, have its settings changed, or be deleted, and neither can its
projects:
403 FORBIDDEN. - A personal access token needs the
writescope for all three routes (all of them are changes), like any other non-GET request. An API key is403 KEY_SCOPE.
Idempotency keys (added 2026-10-09)
Section titled “Idempotency keys (added 2026-10-09)”A POST route that accepts an Idempotency-Key header can be retried safely: it runs once, and a retry is
answered with the first answer. A route that accepts the header says so and lists it among its parameters in
the OpenAPI document; on any other route the header is ignored.
- The header is optional. A key is 1 to 128 visible ASCII characters (no spaces, control characters or
non-ASCII); anything else is
422 VALIDATION_ERRORwitherrors[].fieldIdempotency-Key, and the route does not run. A request without the header behaves as it always did and nothing is stored. - Whose key it is. A key belongs to the caller that sent it: to the user for a browser session or a personal access token, so a script and the dashboard acting as the same user share their keys, and to the API key for an API key. Different callers can use the same key without affecting each other.
- Which request it was. Two requests are the same when their method, path and body match. A JSON body is compared as JSON, so key order and whitespace do not matter. The query string is not part of the comparison, so routes that accept the header take their parameters in the body.
- First request. The route runs. Its status, JSON body and
Content-Typeare kept for 24 hours. - Same key, same request, after the first has answered. The kept status and body are returned with the
original
Content-TypeandIdempotent-Replayed: true, and the route does not run again. Other response headers are not replayed: the replay has theX-Request-IDof the retry. - Same key, different request.
422 IDEMPOTENCY_MISMATCH, whether the first request has answered or not. Send a new key for a different request. - Same key while the first request is still running.
409 IDEMPOTENCY_IN_PROGRESS; retry in a moment. A first request that has not answered after 60 seconds is taken to have died: the next request with the key runs the route and takes the key over, so a request slower than a minute can run twice. - What is kept. Every
2xxand4xxanswer, including a422for a body the route rejects, so a client that fixes a rejected request must send it under a new key. A5xxis never kept: the key is released so that the retry runs. Authentication failures (401,403 CSRF_FAILED,403 ORIGIN_NOT_ALLOWED) and refusals by the route’s permission check (403,404) come before the key is looked at, so they are never kept. - A replay is authorized like the request it replays. The permission check runs again before the key is
looked at, so a caller who has lost access since, such as a member who was removed or a token that was
revoked, gets the
401,403or404the original request would now get, not the kept answer. - JSON only, up to 1 MiB. The kept body is JSON. If a route answers with anything else (another content type, a file), or with a JSON body of more than 1 MiB, the answer is delivered as it is but cannot be kept, and the key is released.
- The limit of the guarantee. If the server stops after the route has committed its changes and before the answer is kept, the key stays unanswered for a minute and the retry then runs the route again.
- Retention. A key is forgotten 24 hours after the first request reserved it. A key past that time is ignored even before the cleanup job removes it, and can be used again for any request.
Exports (added 2026-10-09)
Section titled “Exports (added 2026-10-09)”- Trace exports are asynchronous.
POST /api/v1/projects/{id}/exportsanswers202with the export in statusqueued; thecreate_exportjob writes the file to object storage and moves it torunning, then todoneorfailed. PollGET .../exports/{export_id}(or the list) for the status. It needs object storage: withoutS3_BUCKET,S3_ACCESS_KEYandS3_SECRET_KEYthe request is409 NOT_CONFIGURED. - Who may export.
export:createis held by members, admins and owners; a viewer is403. A session or a personal access token with thewritescope can create one; an API key cannot (403 KEY_SCOPE). The routes accept anIdempotency-Key: a retry with the same key and body returns the same export. Like any answer to a keyed request, a409 NOT_CONFIGUREDis kept for 24 hours and replayed for the same key and body, so after turning object storage on, retry with a new key. - Filters.
filterstakes the trace-list filters under the same names (from,to,environment,release,model,status,error_class,user_id,session_id,tag,q) with the same meaning (error_classis one of the nine classes and keeps traces with at least one span of that class).fromandtoare required andto - fromis at most 90 days; a time without an offset is UTC. An unknown filter is422. - Size limit. More than 100 000 matching traces end the export as
failedwitherror_codeEXPORT_TOO_LARGE, and the job does not retry. Theerror_codevalues areEXPORT_TOO_LARGE,NOT_CONFIGURED(storage was switched off after the request),EXPORT_TIMEOUT(a read ran past two minutes; not retried) andEXPORT_FAILED(every attempt failed, or the job was lost and cleanup gave up on it after 24 hours). - Download links.
download_urlis present only while the status isdone, in the list as well as on a single export, and is a presigned link valid for one hour. Fetch the export again (GET .../exports/{export_id}) for a fresh link; none is given onceexpires_athas passed. A file is deleted 7 days after the export completed and the export becomesexpired, with no link. Deleting a project deletes its export files too. - File contents. CSV has one row per trace with the columns
trace_id, name, started_at, ended_at, duration_ms, status, environment, release, user_id, session_id, span_count, error_count, input_tokens, output_tokens, cost_usd, tags, CRLF line ends and UTF-8.statusiserrorwhen the trace has a failed span, elseok. An unknown value is an empty cell,cost_usdis a decimal string, and tags are joined with;. A cell that starts with=,+,-,@, a tab or a carriage return is prefixed with'so that a spreadsheet does not run it as a formula. JSONL has one JSON object per line: the fields of the trace detail endpoint plusstatus, with the spans nested underspans. Both are ordered newest trace first. - Audit log filters.
GET /api/v1/orgs/{id}/auditacceptsaction(exact match),actor_id,from(inclusive) andto(exclusive); all are optional.frommust be earlier thanto. - Audit log CSV.
GET /api/v1/orgs/{id}/audit/export.csvtakes the same filters and streams the matching events as CSV (id, created_at, action, actor_id, actor_email, target_type, target_id, ip, metadata, withmetadataas JSON), newest first, under the same formula protection. More than 50 000 matching events are422 EXPORT_TOO_LARGE, answered before anything is sent; narrow the filters. Like the other audit routes it needsaudit:read.
Provider credentials (added 2026-10-09)
Section titled “Provider credentials (added 2026-10-09)”- Routes.
GET /api/v1/orgs/{id}/credentialsneedsorg:read(any member). Adding (POST), rotating (POST .../{credential_id}/rotate), checking (POST .../{credential_id}/check) and deleting (DELETE .../{credential_id}) needcredentials:manage, which only owners hold; an admin is403, a non-member404. The check calls the provider with the organization’s key, so it is an owner action too. - The key is write-only.
api_keyis accepted in the create and rotate bodies, trimmed of surrounding whitespace, and never returned. Responses that describe a credential carryCache-Control: no-store. The routes take noIdempotency-Key, so that no copy of a request body with a key in it is kept anywhere. - Base URL.
base_urlis required foropenai_compatibleand refused foropenaiandanthropic(422, fieldbase_url). It must behttps://, without a user name, password, query or fragment, and its host must resolve to public addresses only; trailing slashes are removed. Any failure is422 VALIDATION_ERRORwitherrors[].fieldbase_url, including a host that does not resolve. WithGATEWAY_ALLOW_INSECURE_BASE_URLS=truehttp://and private addresses are accepted. Rotating re-checks the stored base URL against the current policy. - Errors.
409 NOT_CONFIGUREDwhenCREDENTIALS_KEYSis not set (create, rotate and check);409 CREDENTIAL_NAME_TAKENfor a name already used in the organization (names are compared exactly, after trimming);409 CREDENTIAL_IN_USEwhen deleting a credential that a gateway route targets. - Check.
POST .../checklists the provider’s models with the key (GET /v1/models) without following redirects, with a 10 second timeout. It answers200either way:{"status": "ok", "checked_at", "error": null}or{"status": "error", "checked_at", "error": "401 Unauthorized"}.erroris a short status line (the HTTP status and its standard phrase,timeout,connection error,host not found,blocked addressorkey cannot be decrypted), never the provider’s response body. The outcome is stored aslast_checked_atandlast_error; both arenullfor a credential never checked. Rotating clears them, since they described the old key.last_used_atisnulluntil the gateway uses the credential, then moves at most once a minute, like a key’s. A call uses the credential of every attempt it sent upstream; an attempt refused by the egress rules sent nothing and does not count. - In use. Only a route’s current config counts when deleting a credential. An older version may still name a
deleted credential; reverting to it is then
422onconfig.targets.<i>.credential_id.
Gateway routes (added 2026-10-09)
Section titled “Gateway routes (added 2026-10-09)”- Routes. Under
/api/v1/projects/{id}/gateway/routes:GET(list, by name, unpaginated),GET /{route_id}andGET /{route_id}/versions(newest first, unpaginated) needproject:read;POST,PUT /{route_id},POST /{route_id}/revert,POST /{route_id}/defaultandDELETE /{route_id}needgateway:write(owners and admins). - Config. Unknown fields anywhere in
configare422. Counts and times are strict integers:trueor2.0is refused.retryandfallbackare required objects, but every field inside them has a default, so{}takes the defaults.max_backoff_msmust be at leastbackoff_mseven when it is left at its default, so{"backoff_ms": 5000}alone is422. Every field path is relative to the request body: schema errors and the organization check on credentials alike (config.targets.<i>.credential_id; the spec writes it relative to the config,targets.<i>.credential_id). A revert, whose body has no config, reports the sameconfig.paths for the saved config it would restore. - Reading stored configs. Responses return a stored config as it was saved, without applying today’s bounds again, so a rule tightened later never makes a route unreadable; saving it again (a revert) meets the new rules.
- Versions. Creating a route stores version 1; each
PUTand each revert stores the next version, so the history is never rewritten.PUTcarriesexpected_version; when the route has moved on it is409 ROUTE_VERSION_CONFLICT, and the problem body carries the current version as an extension member,current_version, as well as naming it indetail. A revert to a version the route never had is404; a revert whose old config no longer validates (a credential deleted since, or a tightened rule) is422. - Default. The project’s first route becomes its default.
POST .../defaultmoves the default and is not a new version:version,updated_byandupdated_atdescribe the last config save. It is audited asgateway_route.updatewith{"is_default": true}. Deleting the default route leaves the project with no default until another route is made default. - Errors.
409 ROUTE_NAME_TAKENfor a name already used in the project (compared exactly, after trimming);409 ROUTE_IN_USEwhen deleting a route a gateway key uses. - Revoked keys. Only an active gateway key makes a route in use. Deleting a route clears it from the revoked
keys that still name it, so their
route_idreadsnullfrom then on.
Gateway keys (added 2026-10-09)
Section titled “Gateway keys (added 2026-10-09)”- Routes. Under
/api/v1/projects/{id}/gateway/keys:GET(list, newest first, revoked keys included, unpaginated) needsproject:read;POST,PATCH /{key_id}andDELETE /{key_id}needgateway:write(owners and admins). - The key is shown once. The
POSTresponse is the only one withsecret, the fullspl_gw_…key, and it carriesCache-Control: no-store. Every other response showsprefix(spl_gw_and the key’s 12 character id), which is not secret. - Fields.
name1–100 characters andenvironment1–64, both trimmed;rpm_limit1–100 000 andtpm_limit1–100 000 000,nullfor no limit;cache_ttl_seconds1–86 400,nullfor no cache;allowed_modelsat most 100 model names, empty for any model;default_tagsat most 20 tags of 1–64 characters, the bounds ofx-spanlight-tags. Numbers are strict integers; repeated models or tags are422.fault_profile_idis the fault profile the key runs (see Gateway Lab below),nullfor none. - Bounds differ from the plan.
allowed_modelsholds at most 100 names anddefault_tagsat most 20 tags; the plan said 64 and 10. The backend’s values stand: 20 tags is also the bound ofx-spanlight-tags, which default tags are merged into. - Route. Without
route_ida new key uses the project’s default route, and the request is422 NO_DEFAULT_ROUTEwhen the project has none. Aroute_idthat is not one of the project’s routes is422 VALIDATION_ERRORonroute_id. The key keeps its route when the default moves later. - Edits.
PATCHchanges only the fields sent.rpm_limit,tpm_limitandcache_ttl_secondsare cleared by sendingnull, and so isfault_profile_id(detach);nullfor any other field is422. An edit that changes nothing is not audited. A revoked key cannot be edited (404). - Revoking.
DELETErevokes the key (204); revoking a revoked key is a204that changes nothing. The key stays listed withrevoked_atset, so the spans sent with it keep their attribution. - On
/api/v1. A gateway key authenticates the gateway only. Sent as a bearer to an/api/v1route it is an unknown credential:401 UNAUTHORIZED, like any credential that is not a project API key or an access token. - Span attribution. Spans written by the gateway record the key in
spans.source_key_id. Native and OTLP ingestion leave itnull: a project API key is not a gateway key. A span the gateway recorded belongs to the gateway: native or OTLP ingestion of a span with the same trace and span id is accepted (counted inaccepted, so a replay stays idempotent) but never overwrites it, so its cost, tokens and key cannot be rewritten from an SDK.
Gateway cache (added 2026-10-09)
Section titled “Gateway cache (added 2026-10-09)”- Cache key covers the route. The key is the SHA-256 of the surface, the route id, the route’s saved version and the canonical body, not the surface and body alone as the design listed. Routes carry the model aliases and credentials that decide which model answers, so two routes never share an answer, and saving or reverting a route (a new version) leaves its earlier entries unreachable until they expire. Entries stay scoped by project as well.
- Ignored body fields depend on the surface (2026-10-19). Chat Completions and Responses leave
stream,stream_options,userandmetadataout of the key; Anthropic/messagesleaves out onlystreamandmetadata. A field the surface does not accept (userorstream_optionson/messages) stays in the key, so a request the provider would reject is never answered from an entry made without it. Changing the set made the earlier/messagesentries carrying such a field unreachable; they expire. - Answers without usage are not stored. A
200whose body reports no token usage (some OpenAI-compatible servers omit it) is never cached: a hit must report the original usage of the answer it replays. Such calls always reach the provider. - Purge.
POST /api/v1/projects/{id}/gateway/cache/purge(gateway:write, owners and admins) answers204and deletes every cache entry of the project, expired or not. Purging an empty cache succeeds. The audit eventgateway_cache.purgetargets the project and records{"deleted": n}.
Gateway Lab: fault profiles (added 2026-10-09)
Section titled “Gateway Lab: fault profiles (added 2026-10-09)”- Routes. Under
/api/v1/projects/{id}/gateway/fault-profiles:GET(by name, unpaginated) needsproject:read;POST,PATCH /{profile_id}andDELETE /{profile_id}needgateway:write. Audit eventsfault_profile.create|update|deletetarget the profile. - Fields.
name1–100 characters, trimmed, unique per project (409 FAULT_PROFILE_NAME_TAKEN);scenarioone of the nine of the design;probabilitya JSON number from 0 to 1 with at most three decimals (default 1, a float in the response);enabled(defaulttrue);expires_atoptional, timezone-aware,422onexpires_atwhen it is not in the future (an unchangedexpires_atechoed back in aPATCHis not re-checked);nullremoves it. - Params. Validated per scenario with the design’s bounds; omitted
paramsare the scenario’s defaults, and the response always shows every param. A param that does not belong to the scenario, or a value out of bounds, is422onparams.<name>. In aPATCH,paramsmust be sent withscenario(422onparamsotherwise), and a differentscenariosent withoutparamsresets them to that scenario’s defaults. - Output.
activeisenabledand not expired at the time of the request;attached_key_idslists the active (not revoked) keys that run the profile, newest first. - Production guard. Attaching a profile (
PATCHof a key withfault_profile_id) to a key whoseenvironmentisproduction, or settingenvironmenttoproductionon a key that has a profile, is422 FAULT_PROFILE_ON_PRODUCTION_KEY. Afault_profile_idthat is not a profile of the project is422onfault_profile_id. The check runs under the key’s row lock. - Production means any casing. The guard compares
environmenttrimmed and case-insensitively, soProduction,PRODUCTIONandproductionall count as production, at attach time and again when the gateway decides a fault. - Reads are lenient. Stored params are loaded without validation, so a bound tightened later never fails a
GET; aPATCHorPOSTmeets the current bounds. - Delete. Deleting a profile is
204; the keys that ran it are detached by the database (ON DELETE SET NULLon the profile column), and the audit event lists their ids.
Gateway overview (added 2026-10-09)
Section titled “Gateway overview (added 2026-10-09)”- Route.
GET /api/v1/projects/{id}/gateway/overview?from&to&environment(project:read). The window is at most 7 days, else422 VALIDATION_ERRORon fieldfrom. It is always read from the raw spans of gateway keys (spans withsource_key_id), never the hourly rollups. - Cache hit rate.
cache.hit_rateishits / (hits + misses), not hits over all requests as the plan’s example had it. Calls of keys with the cache off and streams never look in the cache, so they are not in the denominator; neither is a call answered with anything but a200(since 2026-10-19: errors are never stored, so it reportsX-Spanlight-Cache: bypass, notmiss, in the header and on its span); whenhits + missesis zero the rate isnull(the UI shows “—”), so a project without caching does not read as 0 %. - Errors include Lab faults.
errorsanderror_ratecount calls failed by a Lab fault;faultslists the per-scenario counts so a client can show how many. - Fallbacks and retries are event counts.
fallbackssums the switches to another target andretriesthe repeated tries on one target, over all requests. A request with two fallbacks adds 2. - Targets.
by_targetgroups by the credential name recorded on the span and finds the credential id by organization and name (names are unique and cannot change).credential_idisnullonce the credential was deleted. Calls that never chose a target (blocked by a budget, rejected before routing) count in the totals but appear in no target. - Unknown values. A ratio is
nullwhen its denominator is zero,cost_usdisnullwhen no call of the key was priced, and a latency isnullwhen no call measured it.
Gateway HTTP layer: /gw/ (added 2026-10-09)
Section titled “Gateway HTTP layer: /gw/ (added 2026-10-09)”Every error under /gw/ uses the provider envelope of the path (OpenAI, or Anthropic for /messages and for /models with anthropic-version), with X-Request-ID and X-Spanlight-Code, never problem+json. The design’s error table covers the gateway’s own checks; these cover the HTTP layer around them.
- Unknown path.
404,spanlight_code: "NOT_FOUND", messageInvalid URL (<METHOD> <path>).with the path cut to 200 characters. OpenAI typeinvalid_request_error, Anthropic typenot_found_error. A known path with a trailing slash (/gw/v1/messages/) is such an unknown path, not the framework’s bare307redirect (2026-10-19). - Wrong method.
405,spanlight_code: "METHOD_NOT_ALLOWED", with anAllowheader. Typeinvalid_request_errorin both envelopes. - Database too busy. A pool timeout or a statement timeout while the key is checked is
503,spanlight_code: "SERVICE_UNAVAILABLE", withRetry-After: 5. OpenAI typeserver_error, Anthropic typeoverloaded_error. Both SDKs retry it. - Other HTTP statuses. Any other status the framework raises is rendered with
spanlight_code: "ERROR"(typeinvalid_request_errorbelow 500,server_error/api_errorfrom 500). A dashboardProblemErrorraised under/gw/keeps its own code as thespanlight_code. An unexpected exception is500 INTERNAL_ERRORwith the request id in the message. Retry-After. Whole seconds, rounded up, at least 1./models. NoX-Spanlight-Cacheheader: listing models is never cached and is not a model call. A passed-through answer carriesX-Spanlight-Attempts: 1.- Compressed bodies. A request body may be sent with
Content-Encoding: gzipordeflate; the 10 MB limit applies to the decompressed size as well (413 PAYLOAD_TOO_LARGE). Another encoding, or a body that does not decompress, is400 INVALID_REQUESTwithparam: null. - Streams. Streamed answers add
Cache-Control: no-cacheandX-Accel-Buffering: no, so no cache keeps them and no proxy buffers them. Atruncated_streamfault ends the response with a clean end of stream after the last forwarded frame (the connection is not aborted); the stream lacks its final event ([DONE]ormessage_stop). Its span is recorded as failed (status = error, message200 FAULT_TRUNCATED_STREAM: …, error classunknown) when the fault withheld anything; a stream with no more frames thanafter_chunkspasses whole and its span staysok. - Usage chunk the client did not ask for. A streaming chat completion without
stream_optionsis sent upstream withstream_options.include_usage: true, so its span has tokens and a cost. OpenAI then ends the stream with a chunk whosechoicesis empty and which carriesusage. The gateway reads that chunk for the span and does not forward it, so the client gets the stream it asked for (code that readschunk.choices[0]keeps working). A client that setsstream_optionsitself, with or withoutinclude_usage, gets the stream unchanged. - Provider answers over 10 MB. A non-streaming provider answer longer than 10 MB ends the attempt with
502 UPSTREAM_UNREACHABLE(message: the answer exceeds the gateway’s 10 MB limit), never retried or fallen back from. - Non-strict JSON. A request body with
NaNor an infinite number is400 INVALID_REQUEST(param: null), with no upstream attempt: it cannot be sent as strict JSON. Its span is still recorded, with each such number stored as the string"NaN","Infinity"or"-Infinity". - NUL and lone surrogates (added 2026-10-13). A
modelholding NUL or a lone UTF-16 surrogate is400 INVALID_REQUEST(param: "model") before the call starts, with no span, like any body without a usablemodel. Elsewhere in the body both are forwarded as sent (a lone surrogate as its\uXXXXescape); the call’s span stores the body with NUL removed and each lone surrogate replaced by?, as ingestion does. NO_COMPATIBLE_TARGETnames the surface. The message names the surface (chat_completions,responses,messagesormodels), not the request path:Route 'support' has no target that serves messages.- Rate limiter fails open. When the gateway’s rate-limit buckets cannot be read (their pool is exhausted or the database refuses), the call is let through rather than refused, as for the dashboard’s limiters; the failure is logged and counted in the rate-limit metrics.
- Overhead counts from authentication.
spanlight.gateway.overhead_ms, the overhead metric and the span’s start time count from when the gateway started checking the key, so authentication, limits and reading the body are part of the overhead.time_to_first_token_mscounts from the same moment. The route’stimeout_msbudget still starts when the call is routed. - Spans can be lost under overload or a long database outage. The gateway writes spans after the answer,
with at most
GATEWAY_RECORD_BACKLOGwaiting and at mostGATEWAY_RECORD_BACKLOG_MBof their payloads (stored JSON), each holding its input and output already redacted and cut to 1 MiB as stored. A failed write is retried after 0.1, 0.5 and 2 s (up to four tries; a span for a deleted project is not retried), each try one transaction that first checks whether an earlier try was stored after all; a span still not stored is counted asspanlight_gateway_record_failures_total{reason="error"}, and a span past the backlog is dropped and counted as{reason="overflow"}. Both also count ingateway_spans_loston/health/ready. A span whose input or output cannot even be prepared for the queue is counted as{reason="error"}too, and logged by error type only. The client’s answer is not affected. - A retried call can be billed twice. On a stored route, a read timeout or a connection error before the first
byte of the answer can lead to a second request for the same call, and the provider may already have received
and billed the first one; the gateway cannot tell. A retry (by default one more attempt on the target,
max_attemptsup to 5) happens only while the route’stimeout_msbudget has room for the wait. A fallback to the next target happens when the error matches the route’sfallback.onconditions (by defaultstatus_5xx,rate_limited,timeoutandconnection_error;auth_erroronly when the route lists it). The span records the usage and cost of the attempt that answered, counts every attempt inspanlight.gateway.attemptsand lists them inspanlight.gateway.attempt_log, so the failed attempt’s spend, if any, is not in Spanlight’s totals. A request that hangs uses the whole budget and is never retried, so it cannot be billed twice that way. Calls Spanlight makes for itself (judges, Explain with Claude, the playground, experiment items and the demo traffic job) go through a single-target route with one attempt and are never retried. - No CORS.
/gw/sends no CORS headers and answers a preflightOPTIONSwith405, so browsers cannot call the gateway from another origin. On purpose: a gateway key is a server-side secret and does not belong in a browser.
Alert channels (added 2026-10-10)
Section titled “Alert channels (added 2026-10-10)”- Routes.
GET /api/v1/orgs/{id}/alert-channelsandGET .../{channel_id}needorg:read(any member). Adding, editing, deleting and testing needalerts:write(admins and owners); a member is403, a non-member404. Channels are listed by name, unpaginated (at most 20 per organization). - Body.
{kind, name, config, secret}.configandsecretare validated for thekindsent with them, and errors nameconfig.<field>andsecret(not the kind).configmay be omitted forslack({}) andpagerduty({"severity": "error"}).secretis required forslack(the incoming-webhook URL, which must start withhttps://hooks.slack.com/) andpagerduty(a routing key of 32 letters and digits), and refused foremailandwebhook. Names are trimmed and compared exactly (409 CHANNEL_NAME_TAKEN). - Secrets are write-only. Responses carry
has_secret, never a Slack URL or routing key. A webhook’s signing secret (whsec_and 32 base64url characters) is generated by the server and returned assecretonly by the create and by aPATCHwithrotate_secret: true; every other response hassecret: nullor no such field. Those two routes take noIdempotency-Keyand answer withCache-Control: no-store. - Email recipients. 1 to 10 addresses, lower-cased and de-duplicated. Each must belong to a current member with
a verified email (
422 RECIPIENT_NOT_MEMBER, fieldconfig.to, naming the refused addresses) unlessALERT_EMAIL_ANY_RECIPIENT=true. - Webhook URL.
https://only, no user name or password, no fragment (a query string is allowed), and the host must resolve to public addresses only; any failure is422 UNSAFE_URLonconfig.url, including a host that does not resolve. Any@before the path is refused, an empty user name included.WEBHOOK_ALLOW_PRIVATE_TARGETS=trueallowshttp://and private addresses. - Edits.
PATCH {name?, config?, secret?, rotate_secret?}; the kind never changes.configreplaces the whole config.rotate_secreton a non-webhook channel is422onrotate_secret. A new config or secret clearsverified_at, since the last test reached the old target. APATCHthat changes nothing writes no audit event. - Test.
POST .../{channel_id}/testtakes anIdempotency-Key. It answers200 {delivery_id, status, error}withstatussent,failedorpending(a failure a retry might fix, left to the worker; also when the worker claimed the row first). An email channel sends to each recipient who is still a verified member, skipping the others; when none is left it is422 RECIPIENT_NOT_MEMBERand nothing is sent.delivery_idis the first row that did not go out, else the first row. A channel of another organization is404before the rate limit (10 per channel per hour,429 RATE_LIMITEDwithRetry-After) is consulted. - Errors.
409 NOT_CONFIGUREDfor a Slack, webhook or PagerDuty channel (create, new secret, rotation) whenCREDENTIALS_KEYSis not set, and for testing an email channel when no email provider is configured;422 LIMIT_EXCEEDEDfor the 21st channel of an organization. - Verified. A test that goes through sets
verified_atonly if the channel was not edited while the test was being delivered. - The webhook URL is not a secret.
config.url, query string included, is shown to every member. Receivers should authenticate deliveries with the signature, not with a token in the URL. - Delivery log.
GET /api/v1/orgs/{id}/alert-channels/{channel_id}/deliveries?status&limit&cursorneedsorg:readand lists the channel’s outbox rows newest first (created_at, thenid), one row per recipient for an email channel. Items are{id, kind, status, attempts, next_attempt_at, last_error, created_at, summary}withsummary: {event, event_id?, rule_id?, rule_name?} | null, read from the payload’ssummarykey, which settled rows keep. A row’s target (addresses) and payload are never returned. A channel of another organization is404. - Retry.
POST .../deliveries/{delivery_id}/retryneedsalerts:writeand answers the delivery as it now stands. Only afailedrow of the channel can be retried: its attempts reset to 0 and it is due at once, with the same id (soX-Spanlight-Deliveryis stable) and itslast_erroruntil the next attempt. Any other status is409 NOT_RETRYABLE; so is an email whose recipient is no longer a verified member of the organization (unlessALERT_EMAIL_ANY_RECIPIENTis on), without naming the address; a delivery that is not the channel’s is404. Audit actionalert_channel.retry_delivery.
Alert rules and events (added 2026-10-10)
Section titled “Alert rules and events (added 2026-10-10)”- Routes.
GET /api/v1/projects/{id}/alert-rules,GET .../{rule_id},POST .../alert-rules/previewandGET /api/v1/projects/{id}/alert-eventsneedproject:read. Creating, editing, muting and deleting rules and acknowledging events needalerts:write(admins and owners). - Budget rules are hidden. A budget owns a rule with
kind: budget. It is not listed, every/alert-rules/{id}route answers404for it, and it does not count toward the limit of 100 rules per project (budgets have their own limit). Its events are listed under/alert-eventswithrule_kind: budget. - Body.
POSTandPATCHtake the whole rule:{name, kind, metric, comparator, threshold, window_minutes, baseline_windows, sensitivity, filters, cooldown_minutes, enabled, channel_ids}. APATCHreplaces the rule (its kind may change) and writes no audit event when nothing changed.kindisthresholdoranomaly. A threshold rule needsthresholdand refusesbaseline_windowsandsensitivity; an anomaly rule needsbaseline_windows, defaultssensitivityto3.0and refusesthreshold.metriccannot bespend.filterskeys areenvironment,providerandmodel, values 1 to 200 characters.channel_ids(at most 10, repeats dropped) must be channels of the project’s organization, else422 UNKNOWN_CHANNELnaming the ids onchannel_ids. Rule names need not be unique. - Editing, disabling or deleting a firing rule resolves it and sends
alert.resolved. When aPATCHchangeskind,metric,comparator,threshold,window_minutes,filters,baseline_windowsorsensitivity, or setsenabledtofalse, and when a rule is deleted, its open event is resolved at that moment, its state becomesok(so the cooldown starts) andalert.resolvedis queued for the rule’s current channels unless it is muted, all in the same transaction as the change. Renaming, changingchannel_idsorcooldown_minutes, and enabling keep the state. - List. Firing rules first, then by name, unpaginated. Each rule embeds
state: {state, since, last_value, last_evaluated_at},nullbefore its first evaluation. - Mute.
POST .../{rule_id}/mute {"until": <datetime> | null}.untilmust be after the server’s clock and at most 30 days ahead, else422onuntil;nullunmutes. The response is the rule. - Preview. The body is the rule without
nameandchannel_ids. Points are spaced by the smallest multiple ofwindow_minutesthat is at least 60 minutes, over the last 7 days (at most 168 points: 168 for 60 or 5 minutes, 160 for 7 (every 63 minutes), 56 for 180). The last point ends at the current minute for windows under an hour and at the start of the current hour for longer ones, so older points read hourly rollups. Eachvalueis the metric over the rule’s own window ending atwindow_end. An anomaly rule’sthresholdat a point is computed as evaluation computes it: from thebaseline_windowsback-to-back windows ofwindow_minutesright before that point’s window,nullwhile fewer than 3 of them are known. Values and thresholds are decimal strings without an exponent; anomaly thresholds are rounded to 6 decimal places, as stored on events. At most 30 previews per person a minute (429 RATE_LIMITED). The route is aPOST, so a personal access token needs thewritescope: a read-scope token gets403 TOKEN_SCOPE, as on every non-GET route. - Anomaly baseline uses the sample standard deviation. The spec says “standard deviation” without a divisor.
The baseline’s spread is the sample standard deviation of its known windows (divisor
n - 1, notn), floored at 10% of their mean, so a baseline of few windows is judged a little more cautiously. At least 3 known windows are needed, son - 1is never 0. - Events.
GET .../alert-events?rule_id&state=firing|resolved&limit&cursor, newest first (started_at, thenid), keyset-paginated.stateis derived:firingwhileresolved_atis null.POST .../alert-events/{event_id}/acknowledgeworks on open and resolved events and answers the event; the second time is409 ALREADY_ACKNOWLEDGED.
Budgets (added 2026-10-10)
Section titled “Budgets (added 2026-10-10)”- Routes.
GET /api/v1/projects/{id}/budgetsandGET .../budgets/{budget_id}needproject:read;POST,PATCHandDELETEneedalerts:write(admins and owners). The list is sorted by name and unpaginated (at most 50 budgets per project,422 LIMIT_EXCEEDEDpast that). - Body.
POSTtakes{name, scope, scope_id, period, amount_usd, action, enabled, channel_ids}:scopeisproject,gateway_key,userormodel;scope_idis absent ornullforprojectand required (1 to 200 characters) otherwise;periodisdailyormonthly;amount_usdis above 0 with at most 4 decimal places;actionisnotifyorblock. Names are unique per project (409 BUDGET_NAME_TAKEN). Agateway_keyscope must be the id of a gateway key of the project, revoked or not (422 UNKNOWN_SCOPEonscope_id); it is stored in its canonical form.channel_idsfollow the rule’s rules (422 UNKNOWN_CHANNEL). - Edit.
PATCHis partial: fields left out are kept,nullis refused except forscope_id, andscope_idcan only be sent together withscope. Channels are checked only when sent, and the scope only when sent, so renaming a budget whose gateway key or channel is gone still works. An edit that changes nothing writes no audit event. - Periods. UTC calendar periods: a daily budget starts again at 00:00 UTC, a monthly one at 00:00 UTC on the 1st.
Spend is the
spendmetric (priced spans, likecost_usd) over the period so far, filtered by the scope only. The budget fires when spend is strictly aboveamount_usd, sendsbudget.exceeded, and resolves withalert.resolvedon the first pass that measures it at or below the amount. A budget still firing from an earlier period resolves on the first pass of the new period whatever that pass measures (spend of blocked, unpriced calls is unknown), then may fire again if the new period’s spend is already over. - State. Each budget embeds
state: {spent_usd, period_start, resets_at, state, last_evaluated_at},nullbefore its first evaluation.period_startandresets_atare the current period by the server’s clock. When the last evaluation belongs to an earlier period (the first minute of a new one, or evaluation stopped),spent_usdisnullandstateisokuntil the next evaluation.spent_usdis alsonullwhen spans exist and none was priced. - Edits that reset. Changing
amount_usd,scope,scope_idorperiodof a firing budget, disabling it, or deleting it resolves the alert at that moment and sendsalert.resolved(withbudgetas it was), in the same transaction. Changingscope,scope_idorperiodalso clears the measured spend, sostateisnulluntil the next evaluation, about a minute later. - A model with no price never fires. Spend counts priced spans only. A call to a model with no price in the
price table or the organization’s overrides (see
unpriced-models) has an unknown cost and adds nothing, so a budget whose spend comes from such calls stays at 0 (spent_usdisnullwhile spans exist and none was priced), never fires and, withaction: block, never blocks. Add a price override for the model to put its calls under the budget. - Hidden rule. Each budget owns an alert rule (
kind: budget) that is not reachable through/alert-rules. It is written in the same transaction as the budget, and deleting the budget deletes it with its events.
Weekly digest (added 2026-10-10)
Section titled “Weekly digest (added 2026-10-10)”- Project field.
Projectcarriesweekly_digest_enabled(boolean, defaulttrue);PATCH /projects/{id}accepts it withproject:writeand records it in theproject.updateauditchangeslike the other fields. - Schedule and switches. The
weekly_digestjob runs on Monday 08:00 UTC and exists only whileWEEKLY_DIGEST_ENABLEDis on. Without an email provider (EMAIL_PROVIDER=consolewith noEMAIL_CONSOLE_FILE) it endsdonewith the outcomeskipped_not_configuredand queues nothing. - Week. The seven whole UTC days that ended at the latest Monday 00:00 (a retry on Tuesday summarises the same week); changes compare against the seven days before. The opening line names the first and last day of the week.
- Who gets it. A project qualifies when its switch is on and its hourly rollups hold at least one span in the week (so a span ingested in the last few minutes may not count); the demo organization is excluded. Each organization member with a verified email gets one message per project, except the shared demo account.
- Copy. An unknown figure is “—” with no change. A change against an unknown or zero earlier value reads “new”, error rate included; when both weeks are zero the figure shows no change text. Spend and call counts change in whole percent, error rate in percentage points. “Top models by cost” lists up to five priced models by summed cost and is omitted when none was priced. The alerts line reads “No alerts fired this week.”, “1 alert fired this week.” or “N alerts fired this week.”. The subject collapses line breaks in the project name.
- Delivery. Rows are transactional mail (
channel_idnull). Each row’ssummary.digest_key(weekly_digest:<project>:<week start>) makes a retried run skip projects already queued. A run handles about 40 seconds of projects per attempt and has three attempts; projects left after the last attempt are logged asweekly_digest_incompleteand wait for the next week.
Span error class, request hash and finish reason (added 2026-10-10)
Section titled “Span error class, request hash and finish reason (added 2026-10-10)”- Error class. Spans carry
error_class(SpanOut.error_class): one ofauth,rate_limit,timeout,context_length,content_filter,provider_5xx,network,client,unknown(andgatewaysince 2026-10-15; see “Provider path”, which also amends the rules below), andnullexactly when the span did not fail. Ingestion computes it. A numeric status decides first, read from the first of the attributesspanlight.gateway.upstream_status,http.status_code,error.statusthat holds a whole number (an integer, a whole float or a string of digits): 401 and 403 areauth, 429rate_limit, 408, 499 and 504timeout, 413context_length, any 5xx (529 included)provider_5xx, any other 4xxclientunless the message says the context was too long or the content was filtered, which wins. Otherwise case-insensitive message patterns decide, in the order auth, rate limit, timeout (which includes “client disconnected” and “aborted”), context length, content filter, network, provider 5xx; anything else isunknown. The message matched is the stored (redacted) one. Spans ingested before the column existed keepnulleven when they failed. - Trace summaries.
TraceSummarygainserror_class: the class of the span that supplieserror_message(the earliest failed span, ties broken by span id).nullwhen no span failed or that span has no class. - Filter.
GET /projects/{id}/traces?error_class=<class>keeps traces with at least one span of that class (not only the earliest). Any other value is422 VALIDATION_ERROR. - Request hash. Spans carry
request_hash(32 lowercase hex), stored but not returned by the explorer. A span may send its own (SpanIn.request_hash, 32 hex; uppercase is lowercased) and it is stored as sent. A malformed value is ignored, never a reason to reject the span: the server computes its own instead. Otherwise the server computes it from the span’smodelandinputas received, before payload capture drops the input, so projects that store no payloads still get it: the first 32 hex characters of the SHA-256 of the canonical JSON (sorted keys,,and:separators, non-ASCII kept) of{"model": model, "input": cleaned}, wherecleanedisinputwithout the top-level keysstream,stream_options,user,metadata,idempotency_key,extra_headers,extra_query,extra_body,timeout,api_key,http_clientwheninputis an object.nullwhen the span has no input. - Finish reason.
SpanOut.finish_reasonis canonical:stop,length,tool_calls,content_filterorother(nullwhen the span reported none). The raw value comes from the first present ofSpanIn.finish_reason(at most 64 characters), the attributefinish_reason,response.finish_reasons[0],response.stop_reasonandgen_ai.response.finish_reasons[0](OTLP spans keep that attribute as sent). OpenAIstop,length,tool_calls/function_call,content_filterand Anthropicend_turn/stop_sequence(stop),max_tokens(length),tool_use(tool_calls),refusal(content_filter) map as named;pause_turnand every other value areother. Matching ignores case and padding. The raw value stays inattributes; a span that sent it only asSpanIn.finish_reasongets it as thefinish_reasonattribute. - Gateway spans. A fault that answers before the upstream call (
auth_expired,scope_denied,rate_limited,unsupported_parameter,provider_5xx,timeout) now setsspanlight.gateway.upstream_statusto the status the gateway answered with, andspanlight.gateway.retry_after_sto itsRetry-After(rate_limitedonly).spanlight.gateway.attemptsstays0, which tells a simulated status from a provider’s. The gateway computes the span’srequest_hashfrom the client’s body and the requested model (not the model that answered, so a fallback keeps the hash). A provider’sRetry-Afteris read fromretry-after-ms(divided by 1000) when present and valid, else fromretry-after; both headers are passed back to the client. Amalformed_jsonfault answers200, but its span is recorded as failed (status = error, message200 FAULT_MALFORMED_JSON: …, error classunknown): the client received a body it cannot parse. x-spanlight-release. The gateway reads the header into the trace’srelease: 1 to 128 characters after trimming, asTraceIn.release; a value outside those bounds is dropped, not rejected.
Releases
Section titled “Releases”- Scope. A release is the
releaseof a trace.GET /projects/{id}/releasesandGET /projects/{id}/releases/compareread the raw traces that started insidefrom/to(default last 24 h) and all spans of those traces, for any window up to 30 days; a longer window is422 RELEASE_WINDOW_TOO_LARGE(a window over 90 days is still422 VALIDATION_ERRORfirst). There is no rollup path, so percentiles are always exact.environmentfilters on the trace’s environment. Both routes are also readable by an API key withtraces:read, for its own project. ReleaseStats.tracescounts the release’s traces in the window;first_seen_at/last_seen_atare the earliest and latest trace start.llm_calls,unpriced_calls,error_rate(nullwithout calls),p50_msandp95_msare over spans of kindllm;cost_usdand token totals are over all spans of those traces, the same definitions as the metrics overview.cost_usd(a decimal string) is the sum of the priced spans:nullonly when nothing was priced, and a lower bound whileunpriced_callsis above 0.unpriced_callsis an addition to the spec’s field list. The list holds at most 200 releases, the one seen last first.compare.ais the baseline andbthe candidate;a == bis422 SAME_RELEASE(checked before the lookup) and a release with no traces in the window is404 UNKNOWN_RELEASE.deltashas one entry per headline figure (traces,llm_calls,error_rate,p50_ms,p95_ms,cost_usd,input_tokens,output_tokens) withabsolute = b - aandrelative = absolute / a(rounded to four places), both decimal strings; the entry isnullwhen either side is unknown andrelativeisnullwhenais 0.model_mix. Each model’s share of each side’s LLM calls (llm_callsof that release; at most the 100 busiest models per side are listed); a side without calls hasnullshares, and a model absent from a side has share0. A span without a model is the entry withmodel: null.error_classes. Exact counts of failed spans (any kind) pererror_class,nullfor failed spans that carry no class (ingested before the column existed).new_errors. Every failed span’s status message of the two releases is normalised in the database (UUIDs, hex ids of 12 or more digits and runs of 16 or more letters and digits with at least one of each become…, so a provider id such asreq_011CgBHmV…readsreq_…; other digit runs become#; whitespace is collapsed; at most 200 characters). The top 10 messages that at least one failed span ofbhas and no failed span ofahas are returned, most frequent first, with the number ofbspans ascountand the id of onebtrace asexample_trace_id.
Insight notifications (added 2026-10-10)
Section titled “Insight notifications (added 2026-10-10)”- Payload.
insight.openedfollows the alert payload’s conventions (version,event_id,occurred_at,org,project,url; datetimesYYYY-MM-DDTHH:MM:SSZ).insightcarries one addition to the spec’s field list:fingerprint, the stable 32-hex name of the problem within the project, which PagerDuty’sdedup_key(spanlight-insight-<fingerprint>) needs at send time.evidence.metricsvalues are decimal strings, integers ornull.occurred_atis the detection that opened the insight (itslast_seen_at). - Link.
urlis<APP_BASE_URL>/<org_id>/<project_id>/doctor/<insight_id>: the app’s routes are keyed by ids, as the alert payload’s links are, not by slugs. - Who is told. Only an insight that opens (new, reopened, or a mute that ended) with severity
criticalnotifies, once per channel in the project’sinsight_channel_ids; a channel deleted since is skipped. PagerDuty getstriggerwith severitycriticalwhatever the channel’s severity, and neverresolve. - Delivery log.
summarygainsinsight_id,project_idandtitleforinsight.openedrows (rule_idandrule_namearenullon them). A channel belongs to the organization, so its log lists rows from every project that uses it;project_idlets a client link to the insight in the right project. Rows queued before it was kept lack it (null). - Weekly digest. “Open” in the Insights section means status
openoracknowledged, as in the health score (muted and resolved insights are not counted). Listed insights are critical first, then the most recently seen. The section is shown when the project has an open insight or one first seen during the week; a reopened insight counts through the first condition only. - Evidence decimals.
evidence.metricsdecimals are written in the canonical form everywhere (stored evidence, API and payload): normalised, no exponent, zero as"0".
Insights (added 2026-10-10)
Section titled “Insights (added 2026-10-10)”- Reads. The list, one insight, the summary and the detector runs need
project:readand are not open to API keys; the actions (acknowledge, resolve, mute and unmute) andexplainneedinsights:manage(owner, admin). A non-member gets404. - Filters.
statusmay repeat (?status=open&status=acknowledged);severity,kindandtrace_idtake one value.trace_idkeeps insights whoseevidence.trace_idscontains the id exactly. An unknownstatusorseverityis422. The cursor is the(last_seen_at, id)of the last item. - Summary.
open_critical,open_warningandopen_infocount insights in statusopenoracknowledged(the ones that still need a person), despite theopen_prefix. A muted insight is not counted, even with its mute ended. - Evidence.
metricsvalues are decimal strings, integers ornull.labelis the catalogue’s name for the kind, or the kind itself when the catalogue no longer has it. - Detector rules the spec left open. The per-hour baselines (
cost_usd_per_hour,llm_calls_per_hour) divide by the baseline period’s active hours, those with at least one call, not by every hour, so a diurnal or batch workload is compared with its own busy hours.retry_stormonly counts spans of one flow (the same trace, or traces that share a non-null session): identical prompts from unrelated traces are not a retry.client_timeout_misconfiguredcompares the abort cluster with the model’s successes in the environments of the clustered aborts, leaving out fault-injected and cache-hit successes. Tool-span input hashes are SHA-256 of the stored jsonb’s text form, computed in the database; they are compared only with each other, never with a request hash. Every detector returns its findings in fingerprint-key order. - Mute.
untilmust carry a UTC offset (422otherwise, the usual validation error); a past or too distant value, or an empty, blank or over-500-character reason, is422 INVALID_MUTE. A mute may be set from any status, including to change the end or the reason of one that is already muted. - Detector runs.
limitis 1–100 (default 100), newest first.findingsisnullwhen the run failed anderrorsays why. - Detail.
explanationslists the insight’s explanations that have an answer, newest first, at most 20; one still being written, or a paid call that returned no answer, is not shown. - Explain.
POST /projects/{id}/insights/{insight_id}/explainanswers201with the explanation; it needs noIdempotency-Key(a repeat is a second, separately budgeted call). Checks run in this order:404for an unknown insight,409 NOT_CONFIGURED(budget0, noCREDENTIALS_KEYS, no Anthropic credential in the organization),409 EXPLAIN_MODEL_UNPRICED(also when the model’s price, an organization override included, is zero),402 EXPLAIN_BUDGET_EXCEEDED, then the call (one attempt, no fallbacks;502 EXPLAIN_FAILEDwhen it fails or its answer has no text). A failed call stays in the month’s spend as an explanation without text, which the detail never lists, whenever the provider may have billed it: at the cost of its reported usage, or at the worst case after a timeout, a connection error, an answer without usage or the client going away once the request was sent. Only a call the gateway never sent (refused before the provider, such as a projectblockbudget) or one the provider answered with an HTTP error status and no usage is not counted. Each excerpt is cut to 2048 characters and each payload to its first 1024 characters of compact JSON; every field is redacted before it is cut. The excerpt of a trace is its first failed span (llm spans first), else its first llm span, else its first span; a cited trace with no stored span is left out.cost_usdis the cost of the answer’s token usage; when the provider reported no usage it is the reserved worst case, so the budget never counts a call as free. A project’sblockbudgets apply to the call like any gateway call (a blocked call is502 EXPLAIN_FAILED). An insight deleted while the call ran answers404. A reservation still open after 10 minutes (its request died before it settled, for example a process killed mid-call) is completed by the cleanup job at its worst-case cost, without text; it is never deleted, because the provider may have billed it. - Health.
GET /projects/{id}/healthneedsproject:read(no API keys).from/todefault to the last 24 h like the metrics routes; the baseline is the window of equal length before it.valueisnullwhen the window has no LLM calls (andcomponentsis then empty). Components arefindings,errors,latencyandcost;observedandbaselineare decimal strings ornull(findingsobserves the count of open critical and warning insights;findingsanderrorshave no baseline).error_rateis a fraction.approximateis true when any metric read was a histogram estimate.
Users (added 2026-10-10)
Section titled “Users (added 2026-10-10)”- Reads.
GET /projects/{id}/usersandGET /projects/{id}/users/{external_user_id}needproject:readand are also open to an API key withtraces:readfor its own project, like the metrics routes. A non-member gets404. - Source. The figures come from
user_stats_daily, one row per UTC day and end user (traces.external_user_id), which therefresh_user_statsjob recomputes for the last three UTC days (today, yesterday and the day before) every 15 minutes, and therefresh_user_stats_deepjob for the last eight days once a day (runnable from 02:00 UTC), each for the projects with a user-tagged trace since the first day it recomputes (USER_STATS_ENABLEDturns both off; until 2026-10-16 the 15-minute pass covered yesterday and today only and there was no nightly pass). A trace counts for the day it started.llm_calls,errorsandtokensare over the trace’s spans of kindllm(an error is a span with statuserror, soerrorsdiffers from theerror_countofrecent_sessions, which counts failed spans of every kind; tokens are input plus output, input already including cached tokens). A day older than eight days is only as complete as the jobs were while it was in their window: a span that arrives later is not counted, and there is no backfill. Rows older than the project’sretention_daysare deleted by the retention job. - Window.
from/to(default last 24 h, at most 90 days) are widened to whole UTC days:fromfloored,toceiled. The response’swindow(start,end, midnights in UTC,endexclusive) says which days were summed. cost_usd. The sum of the priced calls over the days with activity:nullwhen calls were made and none was priced, a lower bound when only some were (unpriced_callscounts the calls without a price, as on releases). A user (or a day) with no LLM call has cost"0".- List.
sortiscost(default),errorsortraces, always descending, users without a value (unknown cost) last, then byexternal_user_idascending.limitis 1–200 (default 50). The cursor encodes the sort value and the id of the last item; one issued for anothersortis not rejected but pages meaninglessly. A cursor that does not decode is422 VALIDATION_ERROR, like every other keyset cursor, not theINVALID_CURSORthe spec named.first_seen_dayandlast_seen_dayare the first and last day in the window with activity. - Detail.
dailyhas one entry per UTC day of the window: a day with no traces is all zeros withcost_usd"0", a day with calls but none priced hascost_usdnull.recent_sessionsis the user’s 20 sessions with the latest activity, over all time (not limited to the window), in the shape ofGET /sessions. A user with no activity in the window is404 NOT_FOUND. The id is URL-encoded by the client; a/in it is accepted. Browsers resolve.and..path segments even when percent-encoded, so the id has a one-character escape: the client prefixes~when the id is.or..or starts with~, and the route strips exactly one leading~before the lookup (~~anames the user~a,~.names.,anamesa). A path with NUL is422 VALIDATION_ERROR.
Prompts, scores, datasets, evaluators and experiments (added 2026-10-11)
Section titled “Prompts, scores, datasets, evaluators and experiments (added 2026-10-11)”These routes follow the conventions above (problem+json, keyset pagination, decimal-string money, null for an
unknown value). Dashboard routes are under /api/v1/projects/{project_id}; POST /v1/scores and
GET /v1/prompts/{name} are key-authenticated routes beside the ingestion ones. Permissions: viewers read;
members hold prompts:write, datasets:write, scores:write, annotations:write and experiments:run;
evaluators:write is admin and owner only (a judge spends money unattended). A personal access token with the
read scope can use none of the write permissions.
New problem codes. NAME_TAKEN, KIND_MISMATCH, NO_CHANGES, PROMPT_IN_USE, DATASET_IN_USE,
SCORE_CONFIG_IN_USE, RUN_IN_PROGRESS, ITEM_NOT_ASSIGNED_TO_YOU, ITEM_NOT_PENDING (all 409);
LABEL_RESERVED, DATASET_FULL, SCORE_NOT_IN_QUEUE, SCORE_REQUIRED, UNKNOWN_SCORE_CONFIG,
SCORE_FILTER_NEEDS_NAME, BUDGET_REQUIRED, RULE_NEEDS_EXPECTED_OUTPUT, CHANNEL_KIND,
JUDGE_TEMPLATE_VARIABLES, EVALUATOR_MODEL_UNPRICED, EXPERIMENT_MODEL_UNPRICED, TARGET_OVERRIDE_INVALID
(all 422); UNSUPPORTED_FILE (415); EVALUATOR_BUDGET_EXCEEDED (402); EVALUATOR_FAILED (502).
Prompts
Section titled “Prompts”- Names. A prompt name is 1 to 128 characters, case-sensitive and unique in the project (
409 NAME_TAKEN). It is segments joined by/; every segment matches[A-Za-z0-9][A-Za-z0-9_.-]*. A segment starting with a letter or digit means.and..can never be a segment, which matters because HTTP clients and browsers resolve those before sending. A segment may not beversionsorlabels, the words that follow a name in the routes, soa/versions/bis never ambiguous (422 VALIDATION_ERROR). The database repeats both rules in a CHECK. - The
~escape. The dashboard routes strip exactly one leading~from the name segment before the lookup, the same helper the Users routes use. No valid prompt name starts with~or., so the escape is never needed for one; it is kept so the two families of routes behave alike. The key routeGET /v1/prompts/{name}does not strip it. - A prompt keeps its kind. A version whose
template.kinddiffers from the prompt’s is409 KIND_MISMATCH.POST /promptsrepeats the kind askind, andkindmust equaltemplate.kind(422). - No duplicate versions. A new version whose
templateandmodel_configequal the latest version’s is409 NO_CHANGES. At most 100 versions per prompt per UTC day; the 101st is429 RATE_LIMITEDwithRetry-Afterset to the seconds until midnight UTC. - Versions are immutable. The database rejects any
UPDATEof aprompt_versionsrow; a delete of the whole prompt cascades.variablesis filled by the server and cannot be sent. - Labels. A label is
[a-z0-9][a-z0-9_-]{0,31}.PUT …/labels/latestis422 LABEL_RESERVED:latestalways means the highest version. Pointing a label at a version of another prompt, or deleting a label that does not exist, is404. PROMPT_IN_USE. Deleting a prompt is409 PROMPT_IN_USEwhile an evaluator’sconfig.prompt_version_idor an experiment’sconfig.target.prompt_version_idnames one of its versions.- Reading with a key.
GET /v1/prompts/{name}?label=|version=needs theprompts:readscope (403 KEY_SCOPEwithout it) and shares the per-key request limit with ingestion.labeldefaults toproduction.latestis accepted here as a read alias for the highest version, although it cannot be set as a label.labelandversiontogether are422 VALIDATION_ERROR. An unknown name, label or version, and a name that exists only in another project, are404 NOT_FOUND. The response is{name, version, version_id, kind, template, variables, model_config, labels}withETag: "<version_id>-<label hash>"andCache-Control: private, max-age=60; a matchingIf-None-Match(weak or strong, a list, or*) is an empty304with the same headers. The spec’s ETag was the version id alone; the body lists the version’s labels, so the tag adds the first 12 hex digits of the SHA-256 of the sorted labels, and moving a label onto or off the served version changes it. - Compiled size. A compiled prompt (the text, or all messages together) is at most 256 KB of UTF-8, checked before
anything is rendered, so a small template repeating a large variable cannot allocate hundreds of megabytes. Past it
the server raises
TemplateTooLargeError(the SDK raises the same name): a playground config gets the errorprompt_too_large, an experiment item the item errorprompt_too_large, and a judge run fails withjudge_prompt_too_large. The shared template fixture has no case near the cap. GET /prompt-versions/{version_id}. The dashboard route that reads a version by its id, with the name of its prompt (project:read). Evaluators and experiments store version ids, so a client needs it to show their names. A version of another project’s prompt is404.- Usage.
GET …/versions/{version}/usagecovers the last 7 days of raw spans.cost_usdandp95_msarenullwhen unknown;has_unpricedsays some LLM calls had no cost, which makescost_usda lower bound. - Spans. A span may carry
prompt_version_id(native JSON ingestion only). There is no foreign key: the id of a version the server has not seen, or that was deleted, is stored as sent.GET …/tracesacceptsprompt_version_idto keep traces with a span of that version. - The SDK’s one exception.
spanlight.prompts.getraisesPromptUnavailableError(a plainException) when nothing is cached, the fetch failed (including401,404and5xx) and no validfallbackwas passed, and also, without a request, for a name that is empty or has an empty,.or..segment. Every other SDK call still never raises into your code.
Scores
Section titled “Scores”- Implicit score configs. A score with a name that has no config creates one (
implicit: true) with the type the first score implies:string_valueis categorical, a JSONtrue/falseboolean, any other number numeric, with no range and no category list. Two first scores racing agree on one config (the loser re-reads it). Editing a config, or creating one in the dashboard, makes it explicit.POST /score-configsfor a name that already has a config, implicit or not, is409 NAME_TAKEN. - Config changes.
PATCH /score-configs/{id}takesdata_type,min_value,max_value,categoriesanddescription(notname), and the result is validated as a whole config. Changingdata_type, or deleting the config, is409 SCORE_CONFIG_IN_USEwhile any score carries the name. A new range or list applies to new scores only. Review queues keep config ids without a foreign key; deleting a config that a queue lists is also409 SCORE_CONFIG_IN_USE, since its items could never be completed. - At most 500 score names per project. A score whose new name would create the project’s 501st config is rejected on
its own (
name: too many score names in this project (at most 500)), and so isPOST /score-configs(422 VALIDATION_ERROR).GET /score-configsstays unpaginated and returns at most 500 configs; concurrent writers can pass the cap by a few rows, which the list then leaves out. - NUL and lone surrogates. NUL is removed from
string_value,commentandspan_idbefore validation, and a lone UTF-16 surrogate is replaced by?, because PostgreSQL refuses both and one bad score would otherwise fail its batch’s insert. Astring_valuethat is empty afterwards is rejected on its own. Judge reasoning gets the same treatment. - Primary key and client ids. A score’s key is
(project_id, id), notidalone. With a global key, two tenants who happened to choose the sameidwould collide invisibly (row-level security hides the other tenant’s row, soON CONFLICT DO NOTHINGwould silently drop the second score). A client-suppliedid(a UUID) makes a retry idempotent: a repeat counts asacceptedand stores nothing. OnPOST /traces/{trace_id}/scoresa repeatedidreturns the stored score as it is, even if it belongs to another trace or author. - Values. Score values are JSON numbers within ±1e9, rounded to four decimals (half to even, so
1.23456is stored as1.2346), and are serialised as JSON numbers (not strings) in every response. A JSON boolean is accepted invalueand stored as1or0; it makes an implicit config boolean, and a boolean config also accepts the numbers0and1. Exactly one ofvalueandstring_valueis allowed. A name is 1 to 64 characters[a-z0-9][a-z0-9_.-]*. - Batch results.
POST /v1/scorestakes 1 to 1000 scores. An empty, oversize or misshapen body is422. A score that fails validation is listed inrejectedas{index, reason}withreasonwritten<field>: <message>, and never fails the others. The body is read after the key is checked, bounded like span ingestion and may be gzip or deflate encoded. Thetrace_idneed not exist yet (a score may arrive first), butPOST /traces/{trace_id}/scoreson the dashboard is404for a trace the project does not have.GET /traces/{trace_id}/scoresis200for such a trace, on purpose: it lists the scores that already name it, which is[]when there are none, so scores sent ahead of their trace stay readable. A score sent with anidthe project already stores is counted inacceptedand ignored: the first write wins, also whenvalue,string_valueorcommentdiffer, so a retry can never overwrite. To change a score, delete it (DELETE /api/v1/projects/{project_id}/scores/{score_id}, see Deleting a score) and send the corrected one. Only a signed-in person can delete: a score a person wrote on the dashboard by its author or a project admin, any other score (one sent through/v1/scoresincluded) by a project admin. A project API key cannot delete (403 KEY_SCOPE), so a client that only holds an API key cannot correct a score. - Traces flag.
traces.has_scoresis set when a score is written and never cleared, so after the last score of a trace is deletedhas_scores=truestill keeps the trace. It is a filter hint. A trace upsert keeps the flag when it is already set, and a small sweep inside the rollup job (every 5 minutes, scores of the last 15 minutes) sets it on traces whose first score raced the trace’s own ingestion, so the flag can lag by a few minutes. - Retention. Scores, online evaluator runs and review-queue items follow the project’s
retention_days, deleted by the hourly retention job on the traces’ cutoff (bycreated_at, orstarted_atfor runs), since a comment or a judge’s reasoning can quote the trace. Online runs are kept until their UTC month ends because the evaluator’s budget sums them, and queued or running ones are left to the reaper. Datasets, experiments and their items are kept. - Trace filters.
score_min,score_maxandscore_valueapply to the latest score ofscore_name(highestcreated_at, ties by the higher id) and are422 SCORE_FILTER_NEEDS_NAMEwithoutscore_name.has_scoresis true or false. A trace in the list carrieshas_scoresand up to 10scores(name,value,string_value). - Deleting a score. Your own
humanscore, or any score as a project admin; others are403 FORBIDDEN. Deleting a score is logged but has no audit action.
Datasets and review queues
Section titled “Datasets and review queues”- Names. Prompt, score-config, dataset, annotation-queue, evaluator and experiment names are each unique per
project (
409 NAME_TAKEN). Dataset names are trimmed and 1 to 100 characters;GET /datasets?name=is an exact match, the handle the CLI and the Action use. - Dataset limits. 10 000 items per dataset (
422 DATASET_FULL, nothing stored);inputandexpected_outputat most 64 KB each andmetadataat most 8 KB as compact JSON; NUL is removed from strings.inputmay be any JSON exceptnull. - Upload.
.jsonlor.csv(415 UNSUPPORTED_FILE), UTF-8 with an optional byte order mark. The file is at most 5 MB:413 PAYLOAD_TOO_LARGEpast that (the request body limit of this one route is 5 MB plus 64 KB). A bad line is not an error of the request: it is reported inrejectedas{line, reason}(at most 100), and the rest is accepted. A reason never repeats the line. CSV needs a header withinput; an unreadable record ends the file there. Parsing runs in a thread pool and stops once a dataset would be full. - From traces.
POST …/items/from-tracestakes 1 to 200 trace ids (32 hex digits, upper case accepted and lowered; a malformed id is422for the request) and lists each trace that gave no item inrejected(trace not found,no llm span,span not found,no payload stored) instead of failing the request. Adding traces to a review queue differs: if any trace id is not a trace of the project, the whole batch is refused (422 VALIDATION_ERRORnaming each unknown trace) and nothing is queued. - In use.
DELETE /datasets/{id}is409 DATASET_IN_USEwhile an experiment names the dataset. - Queues. A queue lists 1 to 20 score configs (
422 UNKNOWN_SCORE_CONFIGfor one the project lacks) and all of them are required when an item is completed (422 SCORE_REQUIRED; a name outside them is422 SCORE_NOT_IN_QUEUE). A trace (or a trace and span) already in the queue is skipped and counted inskipped_duplicates. - Claim.
POST …/claimgives the lowest-idpendingitem that is unassigned, already the caller’s, or claimed over 30 minutes ago, or204when there is none. Completing, skipping and releasing need the item to be the caller’s (409 ITEM_NOT_ASSIGNED_TO_YOU) and stillpending(409 ITEM_NOT_PENDING). A completion that fails writes no score. - Row-level security through the parent. Tables without a
project_id(prompt versions and labels, dataset items, queue items, evaluator runs, experiment runs and items) have noproject_idcolumn, so their policies areEXISTSsubqueries through the parent that does (prompts, datasets, queues, evaluators, experiments), plus the usual worker bypass. Like every project table they have row-level security both enabled and forced, so a forgotten filter returns nothing instead of another tenant’s rows.
Evaluators
Section titled “Evaluators”- Shape.
configis a discriminated union onkind:heuristic(with acheckholding onerule),llm_judge(prompt_version_id,credential_id,model,max_tokens1 to 4096 defaulting to 1024,temperatureunset by default) orwebhook(channel_id). A name follows the score-name rule, because the scores it writes carry it.PATCHtakesname,enabled,sampling_rate,filters,monthly_budget_usdand a partialconfigthat merges into the stored one and cannot changekind. judge_templatealternative. When creating or patching a judge,judge_template(relevance,faithfulness,toxicityorformat_compliance) may be sent instead ofprompt_version_id(exactly one of them). The server saves the built-in template as the chat promptjudges/<name>version 1 on first use and stores that version’s id, so a stored judge always names a prompt version.- Checks. A judge needs a positive
monthly_budget_usd(422 BUDGET_REQUIRED), a chat prompt version of the project using only the variablesinput,outputandexpected_output(422 JUDGE_TEMPLATE_VARIABLES), an Anthropic or OpenAI credential of the organization, and a model with a positive price (422 EVALUATOR_MODEL_UNPRICED).exact_matchandlevenshteinwithsampling_rate > 0are422 RULE_NEEDS_EXPECTED_OUTPUT. Yes/no rules (regex,json_valid,contains,exact_match) write boolean scores;lengthandlevenshteinnumeric ones.levenshteincompares at most 2 000 characters of each side (the spec said 10 000): the pure-Python distance takes about 9 seconds at 10 000, and it runs in a thread so it never blocks the worker’s event loop. A webhook evaluator on a channel that is not a webhook channel of the organization is422 CHANNEL_KIND. A prompt version, credential or channel the caller cannot see is404, never a hint that it exists elsewhere. evaluator_runs.sourceand uniqueness. A run’ssourceisonline,testorexperiment. A partial unique index on(evaluator_id, trace_id, coalesce(span_id, ''))wheresource = 'online'makes a rescan of the sampler’s overlapping windows queue nothing twice, while tests and experiment items may repeat a trace. All three sources write real run rows, so every judge call counts against the evaluator’s monthly budget.- Budget. The month is the UTC calendar month by
started_at. Before a judge call the run reserves an estimate (ceil(ASCII characters / 3) + non-ASCII charactersinput tokens plusmax_tokensoutput tokens, at the model’s price) under an advisory lock per evaluator. Admitted when settled spend + reservations of running runs + the estimate ≤ the budget; otherwise the run isfailedwithbudget_exceededand no request is sent. Arunningrun with no heartbeat for 10 minutes becomesfailed,abandoned, charged its reservation.GET …/budgetreturnsmonthly_budget_usd,spent_usd,reserved_usd,remaining_usd(null without a budget) andmonth_start. - Thinking models (2026-10-19). Claude models that think by default (generation 5 on, Fable, Mythos) spend
thinking tokens out of
max_tokens; at the old 256 default a Haiku 5.5 judge ran out before answering on every call. The judge now sendsoutput_config: {"effort": "low"}to those models only (Haiku 4.5 and Sonnet 4.5 rejecteffort, and older Opus models do not think unasked), and the defaultmax_tokensis 1024. Stored judges keep themax_tokensthey were saved with. A call that stopped on its token limit (max_tokensfrom Anthropic,lengthfrom OpenAI) without a readable verdict fails withjudge_truncatedinstead ofjudge_no_answerorverdict_unparseable, and is charged like them. - Test.
POST …/{id}/testwith{trace_id}evaluates the trace’s last LLM span now and returns{scores, cost_usd, reasoning}without storing scores. A judge’s test reserves like a sampled run. Over budget it is402 EVALUATOR_BUDGET_EXCEEDED; any other failure is502 EVALUATOR_FAILEDwith the run’s error code inerror.404for a trace not in the project;422for one with no LLM span or no stored input or output. - Sampling volume.
EVAL_SAMPLER_MAX_RUNS_PER_CYCLEdefaults to 50 runs per evaluator per minute (evaluation jobs share the worker’s short-job lane with alerts and rollups), and a judge whose committed spend already reaches its monthly budget is not sampled at all. A queued online run whose job died is failedabandonedby the reaper instead of staying queued for ever (the trace is not sampled again for that evaluator). - A run that outlives its row. A judge call that comes back after its run was failed
abandonedby the reaper changes nothing and is counted once, where the reaper counted it. A call that comes back after its evaluator or project was deleted has no run row to settle: it is counted once asfailedwith the errorrun_deleted, and the cost it would have been charged is added tospanlight_evaluator_cost_usd_total. - Never sampled. The sampler skips traces in the environments
evals,playground,experimentsanddoctorand traces taggedspanlight:judge, and traces that ran longer than an hour. Online runs have no expected output. - Webhook evaluators. The request carries
X-Spanlight-TimestampandX-Spanlight-Signature(the alert webhook signature, same secret) but no event or delivery header: there is one attempt and no retry.
Experiments and the playground
Section titled “Experiments and the playground”- Targets.
config.targetis{kind: "prompt_version", prompt_version_id, credential_id, model, params?}or{kind: "messages", credential_id, model, messages, params?}.max_cost_usdis above 0 and at most 1000 (default 5);max_itemsdefaults to 1000 and may not exceedEXPERIMENT_MAX_ITEMS(422). An unpriced model is422 EXPERIMENT_MODEL_UNPRICED. An experiment cannot be edited or deleted. - One run at a time.
POST …/{id}/runsanswers202with aqueuedrun and supportsIdempotency-Key. A second run while one is queued or running is409 RUN_IN_PROGRESS, enforced by a partial unique index. A run whose worker died is failed (abandoned) first, so it does not block new runs forever. target_override. The optional body{"target_override": {"prompt_version_id"}}runs a prompt-version experiment with another version of the same prompt, keeping its credential, model and parameters; anything else is422 TARGET_OVERRIDE_INVALID. Every run stores its effective target (experiment_runs.target) and returns it astarget.- Run
number. Runs have a storednumber: 1, 2, 3 in the order they started, unique per experiment and never reused. The experiment list carriesrun_countandlast_run. - Cost limit. The limit covers the target’s calls only and is reserved: before each item, the cost so far plus the
estimates of the calls in flight plus this item’s estimate must stay within
max_cost_usd, else the run endsfailedwithcost_cap_reachedand its stored items stay. Attached evaluators spend under their own monthly budgets; an evaluator over budget leaves that item’s score out and does not fail the item or the run. - Totals.
cost_usdis the sum of the known item costs,nullwhen none is known, andhas_unpricedsays it is a lower bound. Latency percentiles are nearest-rank over answered items. A numeric score reportsmeanandcount; a categorical one reportscategoriescounts and anullmean. Run lists carry the totals stored when each run ended (nullbefore); one run’s read computes them live. - Compare.
GET …/runs/{a}/compare/{b}returns deltasb − aat four decimals; for a boolean score the values count as 1 and 0. Higher is better for every score, so a positive delta is an improvement. A categorical score has no delta (equal isunchanged). An item present in one run has anullside and no deltas and is listed after matched items.cost_delta_usdandlatency_p95_delta_msarenullwhen either side is unknown. Either run unfinished is409 RUN_IN_PROGRESS; a run that is not a run of this experiment is404. - Playground.
POST …/playground/runs(experiments:run, supportsIdempotency-Key) takes 1 to 3 configs and answers200with one result per config. One failing config yieldserroron its own result only (BUDGET_EXCEEDED,missing_variables: a, b, or the failure);ttft_msis alwaysnull. A credential outside the organization or a version outside the project is404for the request. It is limited to 30 runs a minute per user (429 RATE_LIMITED) and has no spend cap beyond the project’s blocking budgets.
Full test pass 2026-10-13
Section titled “Full test pass 2026-10-13”- A body not fully sent. A client that disconnects before its request body is complete is answered
400 BAD_REQUEST(“The request body was not fully sent.”) onPOST /v1/traces,/v1/otlp/tracesand/v1/scores, and400 INVALID_REQUESTwithparam: null(“Request body was not fully sent.”) under/gw/. Nobody reads the answer; it keeps a dropped upload from being logged as a server error. - Cut or empty compressed bodies. A
gzipordeflatebody whose stream ends before its end marker is400 BAD_REQUEST(“The request body could not be decompressed.”) on the ingestion routes and400 INVALID_REQUESTunder/gw/, even when the part that arrived would parse. An empty body sent withContent-Encoding: gzipordeflatehas no end marker either, so it gets the same answer (it was400 INVALID_JSONon ingestion). - Card numbers in payloads. Redaction searches card-shaped runs: digit groups separated by single spaces or dashes, every group of four or more digits but the last, which has two or more. Any 13-19 digit stretch of whole groups that passes the Luhn check is replaced, so a card next to a CVV, an expiry or a quantity is caught and the neighbouring digits are kept. Lists of small numbers, dates and phone numbers are left alone; a card typed in groups of one to three digits, or glued to a word, is not detected.
Owner decisions 2026-10-13
Section titled “Owner decisions 2026-10-13”Calls made after the full test pass on behaviour the spec left open.
- Model names: whitespace stripped, case kept, pricing case-sensitive. Every span (
POST /v1/traces,POST /v1/otlp/tracesand the gateway’s own spans) has the whitespace around itsmodelstripped once, at ingest, and the stripped name is what is stored; a model that is only whitespace is stored asnull. Case is kept, and the price lookup now compares the name as stored, case included, as alert rules, rollup filters and budgets already did. So" gpt-4o "is stored asgpt-4oand priced, whileGPT-4ois stored as sent, costsnull(“—”), sets the trace’shas_unpricedand appears among the project’s unpriced models. Before this, pricing lower-cased the name and so pricedGPT-4owhile a budget or alert ongpt-4odid not count it. Price overrides are still stored lower-case, so an override cannot price an upper-case model name; send the provider’s own (lower-case) model id. - OTLP values that do not fit. On
POST /v1/otlp/tracesa resource’sdeployment.environment(.name)longer than 64 characters orservice.versionlonger than 128 is dropped (the trace field staysnullor keeps an earlier value) and the span is kept. A token count sent as a whole double (12.0) is stored as the integer (12); one with a fraction, or NaN or an infinity, is dropped, so the span is kept with that count unknown and its costnull. A count sent as the string"NaN","Infinity"or"-Infinity"cannot be told apart from such a double once decoded and is dropped the same way. One warning,otlp_values_dropped, is logged per batch with the number of values of each kind dropped, never the values. Everything else that does not fit (an over-longuser.idorsession.id, a negative or too-large count, any other count sent as a string) still rejects the span with a per-span reason, as onPOST /v1/traces, which stays strict: there an over-longtrace.environmentortrace.release, or ausagecount that is not a JSON integer, rejects the span. POST /v1/tracesreads any content type. The body is parsed as JSON whateverContent-Typesays (text/plain, a form type, or none), so loosely labelled clients keep working. The route needs a bearer API key, so a cross-site form cannot reach it.POST /v1/otlp/tracesstill answers415 UNSUPPORTED_MEDIA_TYPEfor a type other thanapplication/jsonorapplication/x-protobuf, because it must know which encoding it was sent.- Card numbers sent as JSON numbers are not redacted. Redaction reads strings only. A Luhn-valid 13-19 digit
JSON integer (
{"card": 4242424242424242}) is stored as sent: about one in ten numeric ids of that length (snowflake ids, order numbers) passes the Luhn check, and replacing them would corrupt ordinary data. Send card numbers as strings, or keep them out of traced payloads. A card number glued to a word or an underscore (card4242424242424242) is not detected either (see “Card numbers in payloads” above). - Constraint validation in migrations sees every row. Migrations run as the table owner, which row-level
security also applies to (
FORCE ROW LEVEL SECURITY), and Postgres validates a foreign key with a query that the policies filter. EachVALIDATE CONSTRAINT(migrations 0013, 0400, 0401 and 0505) now runs with the worker bypass set for its own transaction, so a dangling reference fails the upgrade instead of being marked valid. A CHECK constraint is validated by scanning the table, which the policies never filtered; those migrations take the same path so every validation follows one rule.
Enterprise edition (added 2026-10-15)
Section titled “Enterprise edition (added 2026-10-15)”- Code under
ee/directories is under the Spanlight Enterprise License and runs only with a license key inEE_LICENSE_KEY(ADR 16). No route uses it yet. - A route of an enterprise feature is mounted whether or not the instance is licensed. Unlicensed, or once the
license has expired, it answers
403 EE_LICENSE_REQUIREDwith afeaturemember naming the feature, for example{"code": "EE_LICENSE_REQUIRED", "feature": "scim", ...}. The usual authentication and tenancy checks run first, so a non-member still gets404 NOT_FOUNDfor another organization’s resources.
Auditable cost: tiers, fees, cut streams, explanation, billing sources, reconciliation (added 2026-10-16)
Section titled “Auditable cost: tiers, fees, cut streams, explanation, billing sources, reconciliation (added 2026-10-16)”- Usage fields.
usage.service_tieris one ofstandard,batch,priority,flexor absent; onPOST /v1/tracesany other value rejects the span with a per-span reason (the native schema is strict, like the other usage fields).usage.server_tool_use.web_search_requestsis a JSON integer from 0 to 100,000 (a negative, larger or non-integer value rejects the span); other keys underserver_tool_useare ignored.cost_usdincludes the web search fee, and the fee alone is reported again asfee_usd, so every existing sum ofcost_usdincludes fees. Spans stored before 2026-10-16 have neither field and were priced at the standard rate without a fee. - Tier spellings are normalized everywhere but the native route. OTLP, the gateway and the Python SDK map the
provider’s own spelling before building the native shape: the value is trimmed and lower-cased (a decision the
design left open), the four canonical names are accepted from any provider (the batch recorders send
batchwhatever the provider), then the provider’s spellings are read against the provider after its aliases: Anthropicpriority_on_demandispriorityandflex_discountisflex; OpenAIdefault,autoandscalearestandardandfastispriority. A spelling that maps to nothing leaves the tiernull(priced as standard) and is kept, trimmed, inattributes["usage.service_tier_raw"]; the span is not rejected. A blank spelling is no spelling. The kept spelling is cut to its first 64 characters on every path (the gateway, OTLP and the Python SDK), so an upstream behind a custom base URL cannot push a span past the attribute limit with it. - Span read model. Spans of
GET /traces/{trace_id}carryservice_tier,web_search_requestsandfee_usd, eachnullwhen not reported or not priced.fee_usdis the non-token part ofcost_usd. - Provider trimmed at ingest. Like the model since 2026-10-13, the span’s
provideris stored without surrounding whitespace on every path, and an empty or whitespace-only provider is stored asnull(an empty string was a422). A span without a provider gets no fee. - Fees. The fee is
web_search_requests × rate / 1,000, the rate being the latest row of the snapshot’s fee table for the span’s canonical provider and the toolweb_searchin force at the span’s start (USD 10.00 per 1,000 foranthropicandopenai; Google has no row). The token cost times the tier factor and the unrounded fee are added and rounded once, half up to 8 decimals, intocost_usd;fee_usdis the fee rounded the same way. The tier factor never applies to the fee.web_search_requests: 0is a known zero fee. A count without a provider or without a rate leavesfee_usdnull. A fee is priced even when the token cost is not: the span then hasfee_usdset andcost_usdandpricing_versionnull, the trace’s totals leave the span out, and the trace’shas_unpricedis set. A wrapper or the gateway reads the count from the provider’s answer: Anthropicusage.server_tool_use, OpenAI Responsesweb_search_callitems with statuscompleted(a Responses answer with no such item sends no count). An OpenAI Chat Completions answer does not count searches, so the SDK setsattributes["usage.fee_unknown"] = "openai_chat_web_search"when the request hadweb_search_options, and no fee is priced. - Tier factors and fees are part of the price snapshot, not of a price row. A span priced by a seed row uses the
factors of that row’s snapshot; one priced by an override uses the snapshot in force at its start. An override cannot
change a factor or a fee. Snapshot
2026-10-16: Anthropic batch 0.5 and priority 1.0, flaggedcommitment_billed(Anthropic bills Priority Tier as a commitment); OpenAI batch 0.5 and flex 0.5; Google batch 0.5. The 2026-10-01 and 2026-10-15 snapshots were released before tiers were modelled and carry no table of their own; a span priced under them takes the factors of the oldest snapshot that carries a table (2026-10-16), because batch and flex were half price on those dates too. Released rates are never edited. Fees are not back-dated: a web search on a span dated before 2026-10-16 hasfee_usdnull and its explanation names the missing rate. - OpenAI priority (Fast) is priced at the standard rate and counted as an unmodelled modifier; the page publishes
per-model rates, not a multiplier (the ratio to standard runs from 1.67 to 2.0, and none of the OpenAI models the
snapshot prices is 2.0). Generally, a non-standard tier with no factor in the snapshot that is not commitment-billed
is priced at the standard rate and its cost explanation carries
tier_note: "unmodelled". - Unpriced request options. Data residency (
inference_geo) and fast mode (speed: "fast") are not priced. When the Python SDK (keywords orextra_body) or the gateway (request body) sees one, the span is priced at the standard rate andattributes["usage.pricing_modifier_unknown"]isinference_geo_<value>(trimmed and lower-cased; a value that is not 1 to 32 characters ofa-z,0-9,_or-is reported asinference_geo_other) orfast_mode. Data residency wins when both are sent.inference_geo: "global"is the default routing at the standard price and sets nothing. The SDK and the gateway set the attribute only on spans with usage: a call that failed before any usage came back was not priced at any rate, so the SDK drops the attribute it set when the call started. The daily cost rollup counts the attribute (unmodelled_modifier_calls) only on spans with an input token count, so a span sent by another client obeys the same rule. - Provider aliases for pricing only. For the price and fee lookup, the provider is trimmed, lower-cased and folded:
gemini,google_genai,google-genai,google_ai,vertex_ai,vertexai,gcp.gemini,gcp.vertex_aiandgcp.gen_aitogoogle;claudetoanthropic. Any other name is compared lower-cased as it is.azureandazure_openaiare deliberately not folded ontoopenai: Azure bills its own rates and no Azure price is seeded, so an Azure span stays unpriced (null) unless an override forazureorazure_openaiprices it. The storedprovidernever changes, and filters, alert rules, budgets and rollups compare what was stored. Both sides are folded: the span’s provider and each price row’s or override’s, so an override saved asgeminiorvertex_aimatches a span sent asgoogleand the other way round. Overrides saved under two spellings of the same provider compete by the usual tie-break (tier, then pattern length, theneffective_from); with all three equal, the greaterversion(override:<id>, compared as text) wins, so the result does not depend on load order and a replay prices the same. The fee lookup folds both sides the same way. The Python SDK’swrap_openairecords anAzureOpenAIorAsyncAzureOpenAIclient asazure_openaiunlessprovider=is passed. - Gemini output tokens include thinking. By convention
output_tokensof a Gemini span is the sum of its answer and thinking tokens (candidates_token_count + thoughts_token_count), both billed at the output rate; an integration that sends only the answer’s tokens under-prices. Gemini prices have no cache-write rates (Gemini bills cache storage per hour, which is not modelled). - OTLP. The tier is read from
openai.response.service_tier, thengen_ai.openai.response.service_tier, thenspanlight.usage.service_tier; the web search count fromspanlight.usage.web_search_requests(OpenTelemetry has no convention). The count is fitted like a token count: a whole double is stored as an integer, a fraction, NaN or infinity is dropped (the fee is then unknown), and any other invalid value rejects the span. - Streams cut before the final usage. A stream that ends without the provider’s final usage event stores the input
counts the stream’s first event reported,
output_tokens: nullandcost_usd: null, never a placeholder count, withattributes["usage.unreported_reason"]one ofclient_disconnected,stream_error,stream_cut_by_fault(a gateway Lab fault),include_usage_not_set,no_final_usageornot_exported. The gateway also usesno_final_usagefor an OpenAI Chat Completions stream whosestream_optionsleaves outinclude_usage(with nostream_optionsit asks for usage itself). A native client may send the attribute withoutput_tokensabsent ornull; another string is stored as sent, read as no reason by the cost explanation and not counted by the daily cost rollup. Renamed: the Python SDK’s formerusage.missing_reason = "stream_options.include_usage not set"is nowusage.unreported_reason = "include_usage_not_set". Such a span sets the trace’shas_unpriced; the trace’soutput_tokensstays the sum of the known counts (0 when none is known). Theunpriced_spenddetector needs both counts, so it ignores these spans. The playground’s runusage.output_tokensis nullable for the same reason, with the run’s cost thennull. - Cost explanation.
GET /projects/{project_id}/traces/{trace_id}/spans/{span_id}/cost-explanationneedsproject:read, or an API key withtraces:readfor its own project; a span that is not visible is404. It rebuilds the stored figure from the row named by the span’spricing_version(the snapshot’s factors and fee rate in force then) and never re-prices with the current snapshot.status, in order of precedence:usage_rollup(in the enum; no span has it today),cache_hit(cost 0;original_usageholds the usage of the call that filled the gateway cache, the only status that carries it),no_usage(input or output count missing; the web search line is kept when searches were counted and a rate exists),no_price,price_version_missingandoverride_deleted(both show the stored figure as priced, withpricenullwhen the row cannot be produced), thenokormismatch.messageis set for every status butok, where it is set only when searches were counted without a rate. Lines areuncached_input,cache_read,cache_write_5m,cache_write_1h,outputandweb_search; reasoning tokens are a split ofoutput(output_breakdown), never a line. A rate keeps the scale it is stored with ("3.000000"); a line’susdis unrounded, written with at least 8 decimals and as many more as it has, and"0"for a zero line;recomputed_usdis the sum rounded once, ascost_usdis.usage.unreported_reasonoutside the six values isnull. Each read countsspanlight_feature_used_total{feature="cost_explanation"}; amismatchalso countsspanlight_cost_explanation_mismatch_total(per read, so one span read three times counts three) and logscost_explanation_mismatchwith the project, the span and both figures. - Billing sources.
/orgs/{org_id}/billing-sources:GET /(unpaginated, by name) andGET /{id}needorg:read;POST /(201),PATCH /{id},POST /{id}/rotateandDELETE /{id}(204) needbilling_sources:manage, a write permission of admins and owners. A non-member gets404. Every response that describes a source carriesCache-Control: no-store, and no route takes anIdempotency-Key(a kept request could only be matched by keeping something derived from the key).kindisanthropic_admin_api,openai_costs_apiorcsv_upload;provideris derived for the API kinds (422onproviderwhen sent and different) and required forcsv_upload(anthropic,openaiorgoogle).admin_keyis required for the API kinds and refused forcsv_upload(422onadmin_key); it is trimmed and must be at most 512 characters of visible ASCII starting withsk-ant-admin(Anthropic) orsk-admin(OpenAI), else422onadmin_keywith “does not look like an Admin API key” (the value is never echoed). The key is write-only: no response, audit event or log line carries it; a source showskey_setandkey_id.nameis trimmed, 1 to 128 characters and unique in the organization (409 BILLING_SOURCE_NAME_TAKEN);provider_scope1 to 128 characters.project_ids(at most 500, may be empty) must be projects of the organization (422onproject_ids), and a project is compared with one source per provider: a project another source of the same provider covers is409 BILLING_SOURCE_PROJECT_TAKENwitherrors[].fieldproject_idsand adetailnaming that source.PATCHchanges only the fields sent;project_idsreplaces the whole mapping,provider_scope: nullclears it, andnullforname,enabledorproject_idsis a422.POST /{id}/rotate({admin_key}) is for the API kinds only: a CSV source is422onkind. An API kind needsCREDENTIALS_KEYS, which seals the key: without it, create and rotate answer409 NOT_CONFIGUREDwith “Billing sources that fetch from a provider are encrypted with a server key that isn’t set. Ask your administrator to set CREDENTIALS_KEYS, or add a CSV source.” Create and rotate of an API kind run one check of the key (one request for yesterday’s costs to the provider’s fixed host, 10 s at most); the source is stored whatever the outcome, which setsstatus(okorfailing) andlast_error(an HTTP status line such as401 Unauthorized, or a fixed phrase such astimeout, never a response body). WithBILLING_SOURCES_ENABLED=falseno provider is called: the check is skipped andstatusisnever_fetched. The key checks are throttled per organization (Postgres token bucketbilling_key_check:{org_id}, one a minute, burst 5, creates and rotations together): past it, create and rotate of an API kind answer429 RATE_LIMITEDwithRetry-Afterbefore anything is checked or stored. A CSV source is neither checked nor throttled, and nothing is throttled whileBILLING_SOURCES_ENABLEDis off. The request’s database transaction is ended before the check, so no connection waits on the provider.DELETEremoves the source with its project mappings, fetch runs and provider cost lines. - Billing source tenancy.
billing_sourcesand its tables are organization-scoped without row-level security, likeprovider_credentials: every query filtersorg_id.billing_source_projectsis the documented exception to the rule that a table withproject_idgets a policy: it maps a source to projects, is read only through its source’sorg_idand holds no telemetry; composite foreign keys make a mapping to another organization’s project, or one that disagrees with its source’s provider, impossible to store. - Billing source audit events.
billing_source.create,billing_source.update,billing_source.rotate,billing_source.delete,billing_source.refreshandbilling_source.upload, target typebilling_source, metadata{kind, provider, project_ids}, never the key or a file’s content. A refresh addsrun_id; an upload addsrun_id,lines(the lines written) anddays(how many distinct days the file holds, a count rather than the list of days the spec’s response carries). - Billing source runs. Three more routes under
/orgs/{org_id}/billing-sources/{id}:POST /refresh(202 {run_id}) andPOST /upload(200 {run_id, lines_upserted, days},daysthe distinct days of the file, ascending) needbilling_sources:manage;GET /runsneedsorg:read. Their schemas are namedRefreshAccepted,CsvUploadOutandBillingRunOut. A run hastrigger(scheduled,manualorcsv_upload),day_fromandday_to(both included),status(running,okorfailed),error,lines_upserted,pages(0 for an upload, which reads no provider page),requested_by(a user object,nullfor a scheduled fetch),started_atandfinished_at.GET /runsis keyset-paginated by(started_at desc, id),limit1 to 100 (default 20) withcursor, and carriesCache-Control: no-store. A failed run’serroris an HTTP status line or one of the fixed phrases oflast_error, plussource disabled: a manual refresh whose source was turned off before the worker picked it up is closed failed with that phrase, so the source does not look busy. A fetch job that finds the source deleted, orBILLING_SOURCES_ENABLEDoff, ends done with the metric outcomeskipped; a refresh queued just beforeBILLING_SOURCES_ENABLEDwas turned off leaves its runrunninguntil it goes stale (15 minutes). The daily fetch job closes every run stillrunningafter 15 minutes as failed withtimeout, whatever its source (a disabled one included), and a run closed that way is never turnedokby the fetch that was working under it: that fetch stores nothing. A provider bucket of a day outside the fetch’s window is dropped, and a provider count nobigintholds (reported, or summed across rows) fails the fetch withinvalid response. - Refresh.
POST /refreshis for the API kinds only (422onkindfor a CSV source). The checks come in this order: the source is turned off,409 BILLING_SOURCE_DISABLED;BILLING_SOURCES_ENABLEDis off,409 NOT_CONFIGUREDwith “BILLING_SOURCES_ENABLED is off, so this server calls no provider’s cost API. Ask your administrator to turn it on, or add a CSV source.”;CREDENTIALS_KEYSis unset,409 NOT_CONFIGUREDwith the create detail; the rate limit (Postgres token bucketbilling_refresh:{source_id}, one every 300 seconds, burst 2),429 RATE_LIMITEDwithRetry-After; then a run of the source stillrunningand younger than 15 minutes,409 BILLING_FETCH_IN_PROGRESS. The token is taken before the last check, so a refresh refused as in progress still spends it. Two concurrent refreshes create one run. The fetch covers the last 7 whole UTC days, or 31 while the source has never had a successful fetch; the daily scheduled fetch (from 03:30 UTC) uses the same window. Each accepted refresh countsspanlight_feature_used_total{feature="billing_source_refresh"}. - CSV upload.
POST /uploadtakesmultipart/form-datawith one fieldfile(another body is415 UNSUPPORTED_MEDIA_TYPE; a form without the field, or one that cannot be read,422onfile). The file may be 5 MB: the upload path is exempt from the 1 MiB API body limit and has its own of 5 MB plus 64 KB of framing, checked while the body streams, and the file itself over 5 MB is413 PAYLOAD_TOO_LARGE. Only a CSV source takes uploads (422onkindotherwise); a source that is turned off still accepts them, and an upload is refused with409 BILLING_FETCH_IN_PROGRESSlike a refresh. The format is the spec’s ten columns; the header must name all ten (in any order, none twice, no other), and “optional” means the cell may be blank. Rows are numbered as a spreadsheet shows them (the header is row 1, row 0 is the file as a whole). A refused file is422 VALIDATION_ERRORwith up to 20errors[]of{field: "file", row, column, message},columnnullwhen no single column is at fault; messages name a column or a row, never a cell’s content, and nothing is stored. Adaymust lie within the 400 days up to the upload’s UTC date, that date included (the 400 days provider lines are kept): an older or a later day is refused onday(“day must be within the 400 days up to today (UTC)”). Without the bound one row dated9999-12-31or0001-01-01opened a run window across centuries and queued a backfill per month of it. The same day and line twice in one file (the line identified by scope, description, token type, tier, model and kind) is refused naming the earlier row. An upload’s run covers every day from the file’s first day to its last, and the report reads, for each day, only the lines of the latest successful run that covers it: a file that skips a day inside its range hides the lines earlier runs stored for that day. The file’s bytes are never stored or logged. After the upload Spanlight recomputes the cost rollups of the source’s projects for those days at once when the file spans at most 31 days, and otherwise queues them as backfill jobs. Each upload countsspanlight_feature_used_total{feature="billing_csv_upload"}. - Reconciliation report.
GET /{id}/reconciliationandGET /{id}/reconciliation.csvneedorg:readand are markedCache-Control: no-store.fromandtoare date-times (a value without an offset is UTC), widened to whole UTC days; withouttothe window ends at the start of today (UTC), and withoutfromit starts 14 days beforeto.422 VALIDATION_ERRORontofor a window of more than 31 days, one ending after today, or afromafter the start of today with noto; onfromwhenfromis not earlier thanto. Reading the report does not depend onBILLING_SOURCES_ENABLED. Arithmetic: per UTC day and group,residual_usd = provider_usd − spanlight_usd − explained_usd, signed and never folded into a line;variance_ratiois the residual overprovider_usdwith four decimals,nullwhen the provider billed 0. A day isprovisionaluntil 48 hours after it ends and until a successful run covering it finished after that point (fetched_atis when the latest such run finished). Credits (creditlines) and charges Spanlight cannot see (code_execution,other) are listed increditsandotherand never enterprovider_usd, the residual or the variance. Groups are the seed or override pattern each side’s model matches (unmapped:<name>per side otherwise), matched when the report is read against the organization’s patterns of that moment; the pattern stored on a line or a rollup row is only a cache, so an override created or deleted after the lines were stored never splits one model into two groups. Beyond the spec’s example a day carriesnote("rollup pending"ornull) andunattributed_calls(calls whose span named no provider), and a group carriesnote(“no provider line: wrong source scope, or usage billed under another account” when Spanlight has calls and the provider no line, only on a day the provider’s side was read). Unknown isnull: a day no successful run covers hasfetched_at,provider_usd,credits_usd,other_usd,explained_usd,residual_usdandvariance_rationull, keeps only the explained lines computed from Spanlight’s side, without USD, and its groups haveprovider_tokensallnull. A day whose rollups are pending has the Spanlight figures,explained_usd, the residual and the variancenullandincomplete: true; the report builds missing rollups inline up to 31 project-days per request and queues the rest (onerollup_cost_daily_backfilljob per project, deduplicated per project, window and UTC date of the request, so a backfill that failed is queued again the next day). A day past a mapped project’s retention isincomplete, gets thebeyond_retentionline, and itsbatch_spend,server_tool_feesanduntraced_trafficlines carry no USD.priority_commitmentcarries minus the token cost Spanlight recorded for commitment-billed (Anthropic priority) calls,nullwhen the provider bills a prioritytokensline. In a rollup,fee_usdsums the fees of spans whose cost is known whileweb_search_requestscounts every span’s searches, so theserver_tool_feesamount compares known-cost fees and its note counts all recorded searches.summary.days_within_2pctandsummary.max_abs_variance_ratioread only settled days that are notincomplete, and the ratio only groups the provider billed at least $0.01. Each read countsspanlight_feature_used_total{feature="reconciliation_report"}("reconciliation_csv"for the file). - Reconciliation CSV. One row per day and group:
day,provisional,incomplete,group,model_pattern,provider_usd,spanlight_usd,explained_usd,residual_usd,variance_ratio,explained_kinds(;-separated, in the report’s order). An unknown value is an empty cell; booleans aretrueandfalse. A money or ratio cell that is a strict decimal (-0.005) is written as a plain number, so a spreadsheet reads a negative figure as a number; every text cell, and a number cell that is not a strict decimal, goes through the export’s formula escaping. The file is namedreconciliation-<source id>-<first day>-<last day>.csv, both days included. - Users:
computed_at.GET /projects/{id}/usersand…/users/{external_user_id}returncomputed_at, the latest time a day of the window (or of the user, in the window) was recomputed,nullwhen none has been; see “Users” for the refresh schedule. - Batch spans from the Python SDK.
wrap_anthropicrecords the results read throughmessages.batches.results, andrecord_openai_batch_output(lines, *, batch_id, provider="openai", spanlight=None) -> intthe lines of an OpenAI Batch output or error file. Each request is onellmspan namedbatch <model>in a trace namedbatch <batch_id>taggedspanlight:batch. Ids are deterministic, so reading the same results again updates the same spans: trace idsha256("spanlight:batch:" + batch_id)[:32], span idsha256(batch_id + ":" + custom_id)[:16]. The span starts and ends at the moment the result is read (duration 0,timing.synthetic: true), has no input (batch.request_available: false), carriesbatch.id,batch.custom_idandbatch.result_type, and records its usage withservice_tierforced tobatch(the Batches APIs bill every request at the batch rate); the provider’s value is kept inusage.service_tier_raw. A failed request is an error span without usage, except an OpenAI Responses body withstatus: "failed", which keeps the usage it reports.
Payload cap of 1 MiB (added 2026-10-19)
Section titled “Payload cap of 1 MiB (added 2026-10-19)”- The cap is 1 MiB per
inputand peroutput(was 32 KB), as JSON text after redaction; over it, the rule above is unchanged (a string holding the first 1 MiB of the JSON text,truncated: true). It is a constant, not a setting. The 5 MB request body, 1,000 spans per batch and 64 KB of attributes per span are unchanged; a span with both payloads full is 2 MiB. - Trace detail has an 8 MiB payload budget.
GET …/traces/{trace_id}returns spans in start order with their payloads while the payloads returned so far stay within 8 MiB of stored JSON; every later span comes back withinputandoutputnull andpayload_omitted: true(a new span field, false everywhere else). A trace under the budget, nearly every trace, is returned as before. One span always fits. GET /api/v1/projects/{project_id}/traces/{trace_id}/spans/{span_id}(project:read, API keys too) returns one span with its payloads, the same shape as a trace-detail span;404for an unknown span. The dashboard calls it for a span returned withpayload_omitted.spans.payload_bytes(migration1008) holds the stored JSON bytes of a span’s input and output together, written at ingestion, so the budgets above are planned without reading a payload. Spans stored before it have none and count as 64 KB, the most the old cap allowed. It is not part of any response.- JSONL exports cut a batch at 64 MiB of stored payloads as well as at 2,000 spans, and read a trace past either bound in chunks of 32 spans (was 500). The file’s content and order are unchanged.
- Gateway backlog.
GATEWAY_RECORD_BACKLOG_MB(default 128) bounds the stored payload bytes of the spans waiting to be written, next to theGATEWAY_RECORD_BACKLOGcount; a span past either is dropped as{reason="overflow"}, except when no other span is waiting. - Python SDK. Input and output strings are cut at 1,000,000 characters (was 32,000); attribute strings stay at
32,000, since a span whose attributes pass 64 KB is refused. A cut attribute now sets the span’s
truncatedflag, as a cut input or output already did. The OpenAI and Anthropic wrappers report cuts of the request they record. - Datasets keep their 64 KB item limit. Adding a span whose input or output is larger to a dataset is refused per trace, as before (“larger than 64 KB”).
OpenAI price coverage and cache writes (added 2026-10-19)
Section titled “OpenAI price coverage and cache writes (added 2026-10-19)”- Price table
2026-10-19adds the OpenAI text models published on 2026-10-19: GPT-6 (gpt-6-astra,gpt-6.1-sol,gpt-6-sol,gpt-6-luna), GPT-5.6 (sol,terra,luna,cyber), GPT-5.5 down to GPT-5 with their mini, nano and pro models,gpt-5.3-codex,chat-latest, the GPT-Rosalind models,gpt-4.1-nano,o3,o3-pro,o1,o1-proand the legacy models. Anthropic and Google rows, tier factors and fees carry over unchanged;2026-10-16is kept as a historical snapshot. Realtime, audio, image, transcription and embedding models are not priced. - Dated rows win.
gpt-4o-2024-05-13is billed at twice thegpt-4orate; under the longest-pattern rule its own row now prices it. Spans stored under earlier snapshots keep thegpt-4ocost they were given. - OpenAI cache writes (GPT-5.6 and later, 1.25 times the input rate, one 30-minute lifetime) are stored in
cache_write_5m_per_mtok, the column for the provider’s default cache-write lifetime;cache_write_1h_per_mtokstays null for OpenAI. The SDK and the gateway readprompt_tokens_details.cache_write_tokens(Chat Completions) orinput_tokens_details.cache_write_tokens(Responses) intousage.cache_write_tokens; OpenAI counts the writes inside the input count, as Spanlight does. The cost-explanation linecache_write_5mis labelled “Cache write” in the dashboard, since its lifetime depends on the provider. - Long prompts. OpenAI models with a 1.05M context window bill prompts over 272,000 input tokens at 2 times the
input and cache rates and 1.5 times output for the whole request: a second row with
min_prompt_tokens272001. - Not modelled yet: OpenAI Fast and Ultrafast modes (the model pages now give Fast as 2 times the applicable
rates;
openai/prioritystays unmodelled until checked per model), the GPT-5.6 Sol promotional rate after 2026-11-21, and the 10 % regional and FedRAMP uplift.
Provider path (added 2026-10-15)
Section titled “Provider path (added 2026-10-15)”- Span fields.
SpanOutgainsprovider_error_typeandprovider_error_code(each at most 64 characters) andrate_limit_kind, allnullwhen unknown. Ingestion computes them on every path (native, OTLP, the gateway’s recorder) from the span’sprovider.*attributes. Type and code are an error envelope’s, so a span that did not fail keeps bothnulleven when its attributes carry them, and its kind isnull. Spans stored before migration0700keepnullin all three, and a span sent again is classified again, like its cost. - Error class
gateway.error_classgainsgateway: Spanlight’s gateway answered without a provider verdict and without a caller mistake. A span whosespanlight.gateway.error_codeis one of the gateway’s own codes takes its class from the code and no other rule runs:INVALID_REQUEST,NO_COMPATIBLE_TARGET,MODEL_NOT_ALLOWED,PAYLOAD_TOO_LARGE,NOT_FOUND,METHOD_NOT_ALLOWEDareclient;UNAUTHORIZEDisauth;RATE_LIMITEDisrate_limit(kindgateway);BUDGET_EXCEEDED,UPSTREAM_BLOCKED,INTERNAL_ERROR,SERVICE_UNAVAILABLEaregateway;UPSTREAM_TIMEOUTistimeout;UPSTREAM_UNREACHABLEisnetwork. Any other value (a Lab fault’sFAULT_<SCENARIO>) falls through to the status rules.spanlight.gateway.statusis the last of the status attributes, aftererror.status, so it never hides an upstream status. So theprovider_5xxshare no longer includes Spanlight’s own 500 and 503, and a gateway body that cannot be forwarded as strict JSON (NaN) is a400 INVALID_REQUESTof classclientwith a status on its span (it used to beunknown). - Two class refinements. A
400whose message containsYou have reached your specified API usage limitsorYou have reached your specified workspace API usage limits(Anthropic’s self-set spend limit), and either starts with it or hasprovider.error.typeinvalid_request_error, israte_limit, notclient, and its kind isspend_cap. The second case covers the messages the gateway and the SDKs write, which put the status and the error type in front (400 invalid_request_error: You have reached …). A529staysprovider_5xx; its kind isoverloaded. - Rate-limit kind. One of
requests,tokens,spend_cap,acceleration,overloaded,gateway,unknown, ornullwhen the span is not a capacity refusal. A refusal is a span of classrate_limit, aprovider_5xxspan the provider’s table calls overloaded (status 529,overloaded_error,server_is_overloaded,service_unavailable_error, or “overloaded” in the message), or a span withspanlight.gateway.error_codeRATE_LIMITED. The rules, first match wins: the gateway’s own limiter isgateway; a Lab fault (FAULT_<SCENARIO>) isunknown; an admittedprovider_5xxis alwaysoverloaded, whatever its message says (a provider outage is never charged to the caller’s quota); then, forrate_limitspans, the provider’s table, by the provider lowercased:anthropic,openai(also used foropenai_compatible) or a generic one. Each table tries money first (Anthropicenforced_spend_limit_reachedor a usage/spend limit message; OpenAIinsufficient_quota,credit_balance_exhausted,organization_spend_limit_exceeded,project_spend_limit_exceeded,organization_usage_limit_exceeded,billing_hard_limit_reached, or a quota/billing/credit message), then acceleration (acceleration; OpenAIslow_down), tokens, requests (OpenAI also byerror.typetokens/requests), overloaded, and last the*-remainingheaders at"0"; every table ends inunknown. The generic table reads the message only (quota|billing|spend|credit|usage limit,acceleration|slow down,token,request,overloadedor status 529). Headers never make a refusal: a successful span is never classified. provider.*attributes.provider.error.typeandprovider.error.code(strings, at most 64 characters),provider.request_id(at most 128),provider.retry_after_s(a non-negative finite number; booleans and numeric strings do not count) andprovider.ratelimit(a flat object of header name to string value). Ingestion filters them whoever sent them, the native route, an OTLP client that sets them itself, or the gateway: a value of the wrong shape is dropped from the stored attributes, never a reason to reject the span. The OTLP mapper itself never makes them fromgen_ai.*attributes, so an OTLP span gets its kind from the message rules unless its client sets them.- Rate-limit header allowlist.
provider.ratelimitkeeps at most 29 lowercase names:retry-after,retry-after-ms, OpenAI’sx-ratelimit-{limit,remaining,reset}-{requests,tokens,project-tokens}, and Anthropic’santhropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-{limit,remaining, reset}andanthropic-priority-{input-tokens,output-tokens}-{limit,remaining,reset}. A value that is not printable ASCII of at most 64 characters is dropped, and so is a name that is not ASCII. Nothing else survives, soauthorization,x-api-key, cookies and Anthropic’s organization and workspace ids never reach a span. The gateway and the Python SDK keep the whole set on a failed call and only the headroom headers on a success (names ending in-limitor-remaining, and OpenAI’sx-ratelimit-limit-*andx-ratelimit-remaining-*). Ingestion applies the allowlist again but not that trim, so a native span that reports a success keeps any allowlisted header it sent. - Gateway span attributes.
spanlight.gateway.status(the status the gateway answered with) is always set.spanlight.gateway.error_codeholds thespanlight_codeof an error the gateway made, and is absent for a success and for a provider error passed through (UPSTREAM_ERROR), which setsprovider.error.typeandprovider.error.codefrom the provider’s envelope instead (OpenAIerror.code, Anthropicerror.details.error_code).provider.request_idcomes fromx-upstream-request-id.provider.request_idandprovider.ratelimitare set only when the provider answered: never for a cache hit, and never from a gateway-made error’s own headers. A stream that fails after a200keeps that answer’s headroom snapshot. - Attempt log.
spanlight.gateway.attempt_logis an array of at most 10 objects, one per attempt in order:target(the index in the route’s targets),name(the credential’s name,nullwhen unknown),status(nullwithout a response),error(timeout,connection_error,blocked,cancelled,response_too_largeornull),duration_ms,retry_after_s. Past 10 attempts it keeps the first 9 and the last;spanlight.gateway.attemptskeeps the true count. It holds no headers, bodies or key material. - Provider headers passed to the client. The gateway now also passes the provider’s
anthropic-priority-*response headers back to the client, matched by prefix likex-ratelimit-*andanthropic-ratelimit-*. - Key-limit refusals are spans. A call the
/gw/v1router refuses with429 RATE_LIMITED(the organization ceiling, the key’s requests or tokens per minute) is recorded as a span under the key’s project, with the key as its source: name = the surface (chat_completions,responses,messages,models),model,input,request_hashand the targetnull(the body was never read), classrate_limit, kindgateway, andspanlight.gateway.retry_after_stheRetry-Afterthe client was told. Such spans are queued at low priority: once half of the recorder’s backlog is taken they are dropped and counted asgateway/overflowlosses. They do not count in the key’s per-minute usage. The Gateway overview’s overhead percentiles include them. - Refusal spans are sampled. Each gateway process records at most one refusal span per gateway key per second.
Every refusal span carries
spanlight.gateway.refusals_suppressed: the refusals of that key since its previous span that got no span of their own (0when none). Each refusal span starts a trace of its own: a trace id or parent span id the client pinned withx-spanlight-trace-idis ignored for a refusal, so a flood never grows one trace. So refusal counts in charts and in the rate-limit kind table are a lower bound; addrefusals_suppressedfor the true number. The client’s429answer is unchanged. - Refusals and the Doctor, costs and alerts. Refusal spans (
rate_limit_kind = 'gateway') are left out of the spans the Doctor’s detectors read, before the per-run span cap, so no detector judges the gateway’s own limiter as a provider problem and a flood cannot push real calls out of the cap. They are also left out of a trace’shas_unpricedflag: a refusal never reached a provider, so its cost is a known zero, not an unknown one. They stay in the unfiltered alert metrics (error_rate,error_calls,llm_calls,p95_ms), because the client did get a429; a rule filtered with arate_limit_kindother thangatewayleaves them out. - Credential names in the attempt log. The attempt log stores the name of every credential tried, not only the last target’s, and anyone who can read the project’s traces (viewers included) can read it. Names are never key material, but do not put anything sensitive in a credential’s name.
- A busy database is a 503 inside a call too. A pool timeout or a statement timeout while the gateway serves a
call (for example during the budget check) now answers
503 SERVICE_UNAVAILABLEwithRetry-After, like the same failure while checking the key, instead of500 INTERNAL_ERROR. It is logged asgateway.db_busyat warning level without a traceback; its span is classgateway. auth_errorfallback.fallback.onacceptsauth_error(a provider401or403). It is off unless listed: the default offallback.onis the other four conditions, for new and stored routes alike. A route that lists it needs at least two targets on different credentials, else422withfallback.on: auth_error needs two targets on different credentials. The check does not look at surfaces: a route with one OpenAI and one Anthropic credential passes, but a call has only one compatible target, so it never falls back.401and403are still not in the defaultretry.on_statuses.- Retry cost.
traces.retry_callsandtraces.retry_cost_usd(TraceSummaryand trace detail). Among a trace’sllmspans with a request hash, gateway cache hits and Lab-faulted spans left out, every span of a hash but the last by(started_at, span_id)is superseded;retry_callscounts them andretry_cost_usdsums their cost. Retries are counted within a trace only. The cost isnullwhen any superseded span has no cost, or when the sum is too large fornumeric(14, 8); a superseded call that failed before generating (a429, an early5xx) reports no usage, so its cost, and the trace’s retry cost, isnull. A trace with no superseded span has a known0for both. Traces last recomputed before migration0700have bothnulluntil a span of theirs is sent again. An SDK span that is the parent of a gateway span for the same call has the same request hash and counts as one retry; this happens only when the client passesx-spanlight-trace-idandx-spanlight-parent-span-iditself. - Trace list.
has_retries=truekeeps traces withretry_calls > 0andhas_retries=falsethose withretry_calls = 0; a trace whose count is unknown is in neither.sort=retry_cost_usdorders by retry cost descending with unknown costs last, thenstarted_atandtrace_iddescending, with keyset pagination on those three. No index backs this sort. clientobject onPOST /v1/traces. A spans batch may carry{"sdk", "version", "exporter_id", "dropped_total", "failed_total"}besidespans.exporter_idis 32 hex characters; the totals are cumulative for the exporter’s lifetime, each in[0, 2^63). Anything else makes the server ignore the whole object; the batch is still accepted. The server stores the increase since the last totals of that(project, exporter_id)assdklosses (dropped,failed) in the current hour: a first sighting counts its whole totals once, equal totals write nothing, a smaller total writes nothing. Exporters not heard from for 7 days are forgotten, so one that reports again counts its whole totals once more. The Python SDK sends it from 0.2.0 (a forked child gets a new id and counters starting at 0); a server older than this ignores it.- Telemetry losses.
telemetry_losses_hourlycounts lost spans per project, hour, source and reason:gateway(overflow,error,rejected),ingest(validation,usage,too_old,in_future,self_parent,attributes_too_large,cost_too_large,duplicate_in_batch,unprocessable,other; the stored value is the bucket, never the rejection text) andsdk(dropped,failed). The hour is the wall-clock hour of the loss, not of the span. Ingest rejections of native and OTLP batches are written in the batch’s transaction. Gateway losses are counted in process memory (at most 10,000 keys, beyond which new reasons count asother) and written every 60 seconds per project, and once more at shutdown for at most 5 seconds; a killed process loses up to a minute of counts, and a flush cut short may count a project twice. A count stops at the largestbigintinstead of overflowing. GET /projects/{id}/telemetry-completeness.stored_spans(every kind, read like the metrics: rollups for whole hours, raw spans for the rest),lossesby source and reason over every hour that overlaps the window (a partly covered hour counts whole),completenessandlower_bound.completenessisstored / (stored + losses)to four places rounded down, so any loss keeps it below"1.0000";nullwhen nothing was stored and nothing lost,"0"when nothing was stored and something was lost.lower_boundis true when SDKfailedand an ingest rejection are both counted (one span may be in both). Counts above2^53 - 1are sent as2^53 - 1; the ratio uses the true totals.GET /projects/{id}/providers/health. Providers seen in the window (calls, failures or retry figures), busiest first (by calls, then failures; on a tie the unknown provider,provider: null, comes last).llm_callscounts every llm span, Lab-faulted ones included, whileerrors,by_classandrate_limit_kindsleave faults out, as the error rollup does.error_rateis a decimal string of at most six places, rounded half to even,nullwithout calls.with_retry_aftercounts refusals that carried aretry-after(the provider’s, or the one the gateway told the client). Whole hours older than the previous hour come from the hourly error rollup and the rest from raw spans, for the kind table too, at any window length (exact as of the rollup’s last run);approximateis true when a rollup hour was read. Retry figures cover the traces that started in the window, each under the provider with most of its llm spans (ties by name), so providers add up;retry_callsandretry_cost_usdarenullwhen any such trace is unknown,retry_cost_unknownsays so for the cost, andretry_cost_noteexplains it. A trace whose retry figures are unknown (stored before the retry columns existed) makes every provider’sretry_callsandretry_cost_usdnull, not only its own provider’s: such a trace is not split by provider, so no provider’s figure can be known complete. Each provider lists up to 20 API-affected incidents that overlap the window, with no grace period; an open incident counts as ending now.GET /projects/{id}/providers/errors/timeseriesand…/errors/samples. The timeseries answers one bucket per hour or day withby_class(classes without failures absent) andapproximate. Samples are the newest failed llm calls with their provider fields,attempts(gateway calls only) andrequest_id; a window longer than 24 hours is cut to its last 24 hours with"clamped": true,limitis 1 to 50 (default 20), and an unknownerror_classorrate_limit_kindis422. The cursor is opaque.- Provider incidents. Stored globally (no tenant data) from three fixed feeds: Anthropic
https://status.claude.com/api/v2/incidents.json, OpenAIhttps://status.openai.com/api/v2/incidents.json(withhttps://status.openai.com/history.rsswhen the JSON is not a valid200) and Googlehttps://status.cloud.google.com/incidents.json, filtered to incidents naming a Vertex AI or Gemini product. Polled every 5 minutes by the worker.api_affected: Anthropic when a component name containsAPI; OpenAI when the title or newest update names an API product (API, Chat Completions, Responses API, Embeddings, Batch, Realtime, Agents, Fine-tuning, Moderations, Files), or for an RSS row its affected components; Google always.impactisnone,minor,major,critical(Google: outagecritical, disruptionmajor, informationminor) orunknown;statusisinvestigating,identified,monitoring,resolved,postmortemorunknown(Google:investigatinguntil it ends, thenresolved).urlis the provider’s own status page:https://status.claude.com/incidents/<id>andhttps://status.openai.com/incidents/<id>built from the feed’s id (an id that is not a safe path segment skips the incident), and Google’surionhttps://status.cloud.google.com/; any other link skips the incident. An RSS row has impactunknownand, once resolved,resolved_at= its newest update’s time; a later JSON poll upgrades it, and an RSS poll never downgrades a row a JSON poll wrote. A row follows the feed while the feed lists it, and keeps its last state after. A304counts as a successful poll (it resetsconsecutive_failures). Incidents are deleted 730 days after they started. GET /projects/{id}/provider-incidents. An incident overlaps[from, to)when it started beforetoand was not resolved byfrom;api_onlydefaults totrue;providerisanthropic,openaiorgoogle(else422);limit1 to 100 (default 50), newest first by(started_at, id).sourcesgives each feed’slast_success_at,last_polled_at,last_outcomeandconsecutive_failures. WithPROVIDER_STATUS_POLLING_ENABLED=falsethe answer says"polling": "disabled"and still lists what was stored.provider_incidentcitation. The finding cites the newest incident of its provider that touched the API and overlaps the detector’s 30-minute window, allowing 15 minutes afterresolved_at(an open incident counts as ending now):evidence.metricsgainsincident_id,incident_title,incident_urlandincident_impact(allnullwithout one), and the summary ends withMatches the provider's status page incident “<title>” (<impact>).orNo matching incident on the provider's status page.The citation only explains: thresholds, severity and the fingerprint ignore it. The provider is matched by exact string, so a span whose provider isOpenAIis not matched to theopenaifeed.- Feedback.
PUT /projects/{id}/insights/{insight_id}/feedbackwith{"verdict": "right"|"wrong", "note"?}(note at most 500 characters) answers{verdict, note, updated_at}and replaces the caller’s earlier vote, note included (a vote sent without a note clears the earlier one): one vote per person per insight. It needs the new permissioninsights:feedback(member and above, classwrite, so a read-only token gets403 TOKEN_SCOPE); a resolved or muted insight can be voted on;404when the insight is not in the project.DELETEwithdraws the vote (204,404when there is none). A deleted user’s votes are deleted with them.GET /projects/{id}/insights/{insight_id}gainsfeedback: {mine: {verdict, note} | null, right, wrong}. - Precision.
GET /projects/{id}/insights/precisionlists every detector kind the project has an insight of:right,wrong,n,precision(right / n) and its Wilson 95 % interval (wilson_low,wilson_high, z = 1.96), decimal strings rounded half up to four places,nullwhennis 0.meets_targetis precision ≥ 0.8 withwilson_low≥ 0.6 and n ≥ 20, compared on the unrounded values, so0.79996shows as"0.8000"and does not meet it, and 17 of 21 showswilson_low"0.6000"and does not meet it.fromandtofilter on the votes’updated_at(default: all time;frommust be earlier thanto). Across projects, the same table is the operator CLIspanlight doctor precision [--org <id>]; no API reads across projects. - Alert rules. A new metric
error_callscounts the failed llm calls of the window (0when none; migration0705adds it to the metric enum).filtersaccepterror_classandrate_limit_kind, one known value each, onerror_rateanderror_callsonly. They narrow the failed calls, never the calls a rate is divided by:error_ratewitherror_classis the failed calls of that class divided by all llm calls. A filtered metric counts classified, non-fault failures, so a Lab drill fires unfiltered rules only, and some class and kind pairs can never count (rate_limit_kindonly exists onrate_limitandprovider_5xxspans). On another metric the filter is422 VALIDATION_ERRORnaming the filter sent:filters.error_class: applies to error_rate and error_calls, orfilters.rate_limit_kind: …when only the kind is sent. - Feature counters.
spanlight_feature_used_total{feature}countsprovider_health,provider_errors_samples,provider_incidents,telemetry_completeness,insight_feedback(each put and delete) andinsight_precision. The errors timeseries is part of the health page and counts nothing.spanlight_provider_status_polls_total {provider, outcome}counts every feed request, a failed primary and its fallback separately. - Migrations
0700–0705.0700adds the span and trace columns and replaces theerror_classcheck;0701–0704add the error rollup, the provider incident tables, the telemetry loss tables and the feedback table, each project-scoped one under row-level security;0705addserror_calls. Downgrading below0700deletes the spans of classgateway, whose class the older check does not allow.
Trace metadata and attribution (added 2026-10-15)
Section titled “Trace metadata and attribution (added 2026-10-15)”TraceIn.metadata. A trace carries a map of up to 32 string keys to string values, sent asspans[].trace.metadataon any span of the trace. A key matches^[a-z0-9_.-]{1,64}$(lower case only, no folding); a value is 1 to 256 characters, counted in code points as received. A JSON number or boolean is stored as its JSON text (42is"42",trueis"true",1e3is"1000.0"); anullvalue drops its key and never deletes one; an object or an array is invalid. An empty object is allowed and means nothing to merge. The native route is strict: the first violation rejects the span alone, reported inrejected[]astrace.metadata: must be an object(the field is not a JSON object),trace.metadata: at most 32 keys(counted afternullvalues are dropped), ortrace.metadata.<key>: <rule>with the rulekey must match ^[a-z0-9_.-]{1,64}$,value must be 1 to 256 characters,value must be a finite numberorvalue must be a string, number or boolean. Entries are checked in code-point key order, so the reported key is the first invalid one in that order. The message names the key as sent; it is returned to the caller and never logged. The schema types the field as any JSON value (so the OpenAPI document shows it untyped): every rule, the object check included, is applied by the server after parsing, so the messages are the ones above rather than a schema validator’s.- Merge rules. Within a batch the spans of a trace merge in order, and across batches the trace upsert applies
the same rule: per key the incoming value wins; a key the trace already has is always updatable, whatever the
count; a new key is added only while the trace has fewer than 32, the new keys in Unicode code-point order, so the
result does not depend on which batch arrived first; nothing is ever deleted through ingestion. “Lexical order” is
code-point order everywhere (
ORDER BY key COLLATE "C"in the database, Python’ssorted()in the server and the SDK), so two databases created with different collations keep the same keys. Sources are not ranked: the SDK, native JSON, OTLP and the gateway header merge by arrival order, and a gateway call that joins a trace adds its header keys to it. Replaying a batch leavesmetadataunchanged. A new key that the cross-batch merge leaves out because the stored trace already has 32 keys is dropped without a count: the design counted it asreason="count", but only the cap within one batch (OTLP and gateway) is counted inspanlight_trace_metadata_dropped_total. - Redaction. Metadata values go through the same secret redaction as payloads, after the length check, so the
256-character limit applies to the value as received; a value redaction replaces is kept under its key as
[REDACTED]. Keys are not redacted (the key pattern admits no secret shape). Since 2026-10-19 (owner decision) the tracenameand every spannamego through the same redaction, on every path.tags,user_id,session_id,environmentandreleaseare stored as sent, unredacted: they are identifiers, and redacting them would merge distinct values into one[REDACTED]and break session, user and release lookups. Redaction is pattern-based only: a labelled secret without a known shape (password: "hunter2") is not redacted anywhere. Email addresses and JWTs are not redacted, in metadata as in payloads. Metadata is stored whether or not the project captures payloads. - No index on
metadata. The design planned a partial GIN index ontraces.metadata. It was not built:tracesforces row-level security, and Postgres uses a condition as an index condition only when its operator is leakproof, which the jsonb operators@>,?and->>are not, so under the app role the index could never narrow a read. Every metadata read is bounded instead: the trace list and the trace export scan the traces of theirstarted_atwindow throughtraces_project_started_idx, as tag filters do; the attribution view reads the window’s traces (24 hours or less) or the daily rollups; budget spend starts from the period’s spans. A metadata filter over a long window on a very large project therefore scans that window’s traces. The storage cost is the column alone (about 130 bytes for three short pairs, at most about 10.5 KB for 32 maximum-length pairs). - OTLP. Span and resource attributes named
spanlight.metadata.<key>are trace metadata, read on any span, not only the root. The key is the rest of the name, lower-cased; when several spellings fold to one key, the one that sorts last wins and each other counts as a dropped entry. The span’s entries win over the resource’s per key, and the 32-key cap applies after that merge, in code-point order. The attributes are removed from the span’s storedattributes. Like the rest of the mapping, invalid entries are dropped one by one and never reject the span. A batch that dropped any answers withpartialSuccesscarryingrejectedSpans: 0(or the number of spans rejected for other reasons) and the notetrace metadata: <n> entries dropped (invalid spanlight.metadata.* key or value, or over 32 keys). Only metadata drops are named there: the environment, release and token-count drops of the OTLP mapping are still logged only, as before. The count is an upper bound: an invalid resource entry is counted once per span of the resource, and a key cut by the cap on both the span and the resource counts twice. A metadata value sent as a non-finitedoubleValuearrives as the string"NaN","Infinity"or"-Infinity"and is kept as that text. Drops are counted inspanlight_trace_metadata_dropped_total{path, reason}(pathotlporgateway;reasonkey,value,type,countorheader), never with the key or value. - Gateway headers.
x-spanlight-metadatais one JSON object of at most 8,192 bytes. Send it as ASCII, with non-ASCII characters as\uXXXXescapes (whatjson.dumpswrites by default): header bytes outside ASCII are read as Latin-1, so raw UTF-8 arrives garbled. A header that is too large, is not valid JSON, is nested too deeply or is not an object is dropped whole (reason="header") and the call proceeds without metadata; inside a valid object, invalid entries are dropped one by one and the first 32 valid keys in code-point order are kept. An 8 KiB header holds fewer than 32 maximum-length pairs.x-spanlight-trace-namenames the trace: trimmed, 1 to 256 characters, else dropped. It sets the name even on a call that joins a trace you started (sending it is asking for that name). Neither header is forwarded upstream. A header sent more than once is read by its last value, for these two as for every otherx-spanlight-*header (the design said the first value; one rule for all the headers was kept). - Gateway traces are named by the requested model. A gateway call that starts a new trace names it
"<surface> <model>"with the model of the request body as the client sent it, before any route alias and before the provider’s echo, cut to 256 characters; a body without a model names it by the surface alone. The span’s own name and itsmodelkeep the model the provider answered with. Before this release the trace took the span’s name, so for a provider that echoes a snapshot (gpt-4.1-minianswered asgpt-4.1-mini-2025-04-14) the trace name now carries the requested name; traces stored earlier keep their names. The name is known before the call, so the budget guard, the stored trace and the spend measurement agree on it. A call that joins a trace and sends nox-spanlight-trace-namesends no name, so the name the SDK gave the trace is kept. GET /api/v1/projects/{id}/tracesgainsname(exact match on the trace name;qstays a substring search),metadata_key(the key pattern) andmetadata_value(at most 256 characters). Key and value keep the traces whose metadata contains the pair; the key alone keeps the traces without that key (the attribution view’s “none” drill-down); a value alone is422 VALIDATION_ERRORonmetadata_value(metadata_value needs metadata_key.). Both are bound as parameters.TraceSummaryOut, and so the trace detail, gainsmetadata(an object of strings,{}when none).GET .../filtersgainsmetadata_keysandtrace_names: the distinct metadata keys and trace names of the traces started in the last 7 days (not 30 like the other lists, which would scan 30 days of metadata), at most 100 each, sorted.GET /api/v1/projects/{id}/attribution. Readable by every role and by an API key withtraces:read. Parameters:group_by(metadata,trace_name,userorrelease; required),key(a metadata key, required withmetadataand refused with any other kind:422 VALIDATION_ERRORonkey), the sharedfrom/towindow (default the last 24 hours, at most 90 days),environment(at most 64 characters),sort(cost,calls,errorsorretry_cost; defaultcost),limit(1 to 200, default 50) andcursor.usergroups by the trace’s end-user id,trace_nameby its name,releaseby its release. A window of 24 hours or less is read live from the traces (source: "raw",updated_within_minutes: 0, the window as requested, any metadata key). A longer window is read from the daily rollups (source: "rollup",updated_within_minutes: 15) over the UTC days it touches,fromfloored andtoceiled to midnight, andwindowechoes those days; only promoted keys have rollups, so another key is422 KEY_NOT_PROMOTED(Promote `<key>` in project settings to group over more than 24 hours). Omittingenvironmentsums every environment;environment=with an empty value selects the traces whose environment is the empty string, as on/traces, not “any”.- Attribution envelope.
itemsholds the values, sorted bysortdescending with unknowns last, then by value ascending;next_cursoris opaque (it holds the last sort value and value) and a forged one is422.noneis the traces without the dimension andother, on the rollup path only, the values folded past the daily cap; both are envelope members on every page, never items, andnullwhen they hold no traces.totalscovers every trace started in the window, whatever its group. The ledger property holds:cost_usd,traces,llm_calls,errors,unpriced_callsand the token sums of the items,noneandotheradd up tototals, which equal the window’s traces’ own figures. Token and cost sums take every span of the trace; the counts take itsllmspans.cost_usdis a decimal string,nullwhen nothing in the group was priced (and from USD 1,000,000 up, as everywhere).share_of_costis the item’s cost overtotals.cost_usdas a number rounded to 4 places,nullwhen either is unknown or the total is zero.retriesandretry_cost_usdarenullwhen they were not computed (traces stored before retry tracking was added), never 0;retry_cost_usdis alsonullwhen a retried call has no price. A known 0 is 0. - Daily cap and
folded_values. The rollup keeps at most 1,000 values per project, UTC day, environment, kind and key, ranked by cost (unknown last), then LLM calls, then value; the rest of the day are summed into oneotherrow. The window total stays exact, since every trace is in exactly one row per day.folded_valuesis the sum of the per-day, per-environment folds, so a value folded on two days counts twice: it is an upper bound on the distinct values inother, and the dashboard reads it as “Up to N more values, daily cap of 1,000” (the design said “N more values”). The raw path has no cap and never returnsother. - Promoted keys.
PATCH /api/v1/projects/{id}takespromoted_metadata_keys: at most 5 keys after repeats are dropped, in the order sent, each matching the key pattern (422 VALIDATION_ERRORotherwise);[]promotes none. It needsproject:write(admins and owners), andProjectOutreturns the list. A newly promoted key is backfilled over the last 7 UTC days (today included) by the next refresh; a key removed from the list has its rollup rows deleted by the same pass, so re-promoting it brings back 7 days, not more. Theproject.updateaudit event keepschanges(the fields sent, as everywhere) and, when the promoted list changes,previous: {"promoted_metadata_keys": [...]}with the list it replaced (the design saidchanged). Keys are identifiers, never values, so they may be audited. - Refresh. The attribution refresh runs every 15 minutes over yesterday and today (UTC); the first pass of each
UTC day also recomputes the 6 days before them, for late spans and price replays. Spans later than that need
spanlight attribution backfill --project <id> --from <day> --to <day> [--key <key>], bounded by retention. Rollup rows follow the project’s retention.ATTRIBUTION_ENABLED=falsestops the refresh: windows over 24 hours stop updating, shorter ones stay live. - Exports.
POST /api/v1/projects/{id}/exportstakeskind,tracesby default, so every earlier body is unchanged.exports.kindis the existingexport_kindenum, which gains the valueattribution(the design described a text column with a CHECK). An attribution export is{"kind": "attribution", "format": "csv", "group_by", "key", "filters": {"from", "to", "environment"}}, CSV only, checked like the attribution read before anything is queued (the key rule,422 KEY_NOT_PROMOTEDover 24 hours), with the window at most 90 days. Its columns aregroup_by, key, value, bucket, traces, llm_calls, errors, unpriced_calls, input_tokens, output_tokens, cost_usd, retries, retry_cost_usd, folded_values: values first by cost descending with unknowns last, then thenonerow, thenother;bucketisvalue,noneorother; an unknown is an empty cell; every cell gets the formula protection. More than 100,000 rows ends itfailedwithEXPORT_TOO_LARGE.ExportOutgainskind,group_byandkey; an attribution export’sfiltersechoes its window and environment. Trace exports acceptmetadata_keyandmetadata_valuewith the trace list’s meaning (but notname); the trace CSV gains a last columnmetadata, the JSON object with sorted keys and non-ASCII characters as they are, and JSONL ametadataobject. - Export validation paths. Trace-export
422 VALIDATION_ERRORfields keep their paths (format,filters.to, and so on). An attribution body’s errors are named under its kind:format: "jsonl"withkind: "attribution"is reported onattribution.format. A trace export’smetadata_valuewithoutmetadata_key, and a window whosefromis not earlier thanto, are reported onfilters, not on the field itself. - Budget scopes.
scopegainsmetadataandtrace_name. Ametadatascope’sscope_idis<key>=<value>, split at the first=(a key has no=): the key matches the key pattern and the value is 1 to 256 characters, stored as sent (a leading space makes a different value). Atrace_namescope is the trace’s name, trimmed and stored trimmed, 1 to 256 characters.scope_idmay now be 400 characters (it was 200; a metadata scope reaches 321). The key need not be promoted. Ascope_idits scope cannot use is422 UNKNOWN_SCOPE, now with the detailThe budget's scope_id does not name anything its scope can measure.for every scope, and ascope_idfield error that says why:Not a gateway key of this project,A metadata budget's scope is `key=value` with a valid keyorA trace name budget's scope is the trace's name. Thebudget.exceededandalert.resolvedwebhook payloads carry the newbudget.scopevalues, with the pair or the name inbudget.scope_id;scope_idis still kept out of the audit metadata. - Budget spend and matching. A metadata or trace-name budget measures the priced spans of the period whose trace
carries the pair or has the name, read from the spans and their traces rather than the hourly rollups, like a
userbudget. Metadata belongs to the trace, so a key added to a trace later puts that trace’s earlier spans of the period under the budget. The gateway’s guard matches a keyed call by its headers first. A call that joins a trace by id and lacks either header adds the stored trace’s name and metadata (one primary-key read; the header wins per key); a trace no span of which is stored yet gives nothing, so matching is best effort for joining calls (send the headers to be sure). A call that starts a new trace matches by the name it will get (see above). In-process callers (judges, the playground, experiments, the demo job) keep the project, model and user checks. - Blocking is a soft limit. The
402 BUDGET_EXCEEDEDmessage, and the demo job’s skip reason, end with(soft limit, measured every 60 s), for exampleBlocked by a Spanlight budget: Budget "Acme daily" is exhausted: $201.10 of $200.00 spent today (soft limit, measured every 60 s).Theactionvalues are unchanged:blockstill reads evaluated state, which the alert worker writes every 60 seconds. On 2026-10-14 a $0.002 budget started blocking 85.5 s after its limit was crossed and spend reached $0.0122, six times the limit. - Attribution page. The
otherrow reads “Up to N more values, daily cap of 1,000” (see above) and is not a link. A row links to the trace list with the same window and environment: a metadata value withmetadata_keyandmetadata_value, the metadatanonerow withmetadata_keyalone, a trace name withname, a release withrelease, and a user with the trace list’s ownuseraddress parameter (the design saiduser_id). Thenonerows of the trace name, user and release kinds (“No trace name”, “No user”, “No release”) are not links, since the trace list cannot filter on a missing name, user or release. Anullretries cell has the tooltip “Retries are not computed for traces stored before retry tracking was added”. - Feature counters.
spanlight_feature_used_total{feature}countsmetadata_set(an ingest batch that stored spans carrying metadata, even if the merge added nothing),attribution_view,attribution_export,budget_metadata_scopeandbudget_trace_name_scope(a budget created with the scope).spanlight_attribution_refresh_duration_secondstimes each project’s refresh, andspanlight_attribution_folded_values_totalcounts values folded intootheron every refresh that folds them. - Migrations
0800–0804.0800addstraces.metadata(NOT NULL DEFAULT '{}', no backfill, a CHECK that it is an object) and themerge_trace_metadatafunction. The function keeps Postgres’s defaultEXECUTEforPUBLIC: it is pure (IMMUTABLE) and runs on every trace upsert, so a deploy that skipsspanlight ensure-app-rolestill ingests; no extra grant step is needed for it.0801addsprojects.promoted_metadata_keys,0802theattribution_rollups_dailytable under row-level security,0803theattributionexport kind and0804the two budget scopes with the 400-characterscope_id. Downgrades are lossy: below0804the budgets of the two scopes and every budget whosescope_idis longer than 200 characters are deleted; below0803the attribution exports are deleted and their files are left in the bucket; below0800every trace’s metadata is lost.
OTLP (POST /v1/otlp/traces) conventions, usage rollups and usage state (added 2026-10-15)
Section titled “OTLP (POST /v1/otlp/traces) conventions, usage rollups and usage state (added 2026-10-15)”This section supersedes, for OTLP spans, the “Kind” bullet and the content-attribute sentence of the “OTLP” section
above, and the status attribute list of “Span error class, request hash and finish reason”. The public mapping
reference is docs-site/src/content/docs/reference/otlp-mapping.mdx.
- Conventions and detection. Each span is mapped on its own, never from its parent, so a trace may mix
conventions and a batch may arrive in any order. The first matching rule wins:
openinference.span.kindpresent isopeninference;ai.operationIdpresent, or anyai.*key together withai.model.id, isvercel_ai;traceloop.span.kindpresent istraceloop; anygen_ai.*key isgen_ai; otherwisenone(the earlier kind rules:httpfor a client span withhttp.*/url.*,retrievalwithdb.*, elseother; no provider, model, usage or content). When the detected convention is notgen_aiand the span hasgen_ai.*keys, thegen_aireading fills every field the detected mapper leftnull, usage field by field; the detected mapper’s metadata keys win, the consumed keys are united, and the kind is always the detected mapper’s. spanlight.conventionandspanlight.operation. Every OTLP span storesspanlight.convention(gen_ai,openinference,vercel_ai,traceloop,none), overwriting a client value.spanlight.operationis the convention’s finer kind, stored only when non-empty:gen_ai.operation.nameas sent, the OpenInference span kind trimmed and lower-cased,ai.operationIdas sent (lower-casing it would lose the camel case of the Vercel ids) andtraceloop.span.kindas sent (Traceloop emits only its lower-case enum, so a padded value is stored as sent). A client-sentspanlight.operationis removed when the mapper gives none.- Convention counter.
spanlight_otlp_convention_spans_total{convention}counts accepted OTLP spans. It is incremented in the router after the batch is stored, from the spans that were not rejected, rather than in the mapper as the design placed it, so a span that validation rejects is never counted. - Field tables. The attributes read for each field, per convention, are listed in the mapping reference. Calls
the design left open: OpenInference reads
llm.systembeforellm.provider(the vendor prices are keyed on);input.value/output.valueare parsed as JSON only when the mime type is exactlyapplication/json; the Vercel provider drops a.chat,.messages,.responses,.completionor.embeddingending; the Vercel output is an object{text, tool_calls, object}with absent members left out,ai.toolCall.resulton tool spans, andnullwhen empty; a root span’s trace name comes fromai.telemetry.functionId, ortraceloop.workflow.namethentraceloop.entity.name, before the span name. Mapper output carries three fields the design did not list:trace_name,output_tokens_impliedanddropped_messages(and the usage carries the unknown tier spelling forusage.service_tier_raw); convention metadata is passed asdict[str, object], raw. - One “first value” rule. Every mapper reads with the rule the usage reader already had: the first key present
whose value is not an empty string. The mappers’ read helper wraps that function rather than copying it. The
gen_aiusage still goes through the existing usage reader, so tier spellings,usage.service_tier_rawandspanlight.usage.web_search_requestsbehave as before; the other conventions fill only the four token counts, and the tier and web searches reach their spans only through thegen_aifallback. - Content attributes are consumed by presence. Every content attribute a convention defines is removed from the
stored
attributeswhen it is present, whether or not it was the one read, so prompt text is stored only asinput/output, under the payload capture setting, the redaction and the 1 MiB cap, and a large unread copy cannot trip the 64 KB attributes limit and reject the span.gen_ai:gen_ai.input.messages,gen_ai.prompt,gen_ai.output.messages,gen_ai.completion,gen_ai.system_instructions(also when it is not prepended), and the flattenedgen_ai.prompt.*/gen_ai.completion.*keys when they are read. OpenInference:input.valueandoutput.valueeven when flattened messages win (the design keptinput.valueinattributes), and the flattenedllm.input_messages.*/llm.output_messages.*keys; the mime types stay. Vercel:ai.prompt.messages,ai.prompt,ai.toolCall.args,ai.value,ai.values,ai.response.text,ai.response.toolCalls,ai.response.object,ai.toolCall.result. Traceloop:traceloop.entity.input,traceloop.entity.output. Usage, mime types, finish reasons, ids,ai.usage.reasoningTokensandai.usage.totalTokensstay. A span carrying two different texts in one slot keeps only the one read. - Flattened messages.
prefix.<index>.<rest>keys are grouped by index in numeric order. An index is0*[0-9]{1,4}: leading zeros are free and four significant digits remain (00012is 12;012345is not an index and its key stays inattributes). At most 256 indices per prefix (the lowest kept) and four levels of lists, the message index included; a key past a limit, or one that would overwrite a value already set, is dropped and counted. Dropped keys are still removed fromattributes. The count is logged asmessagesinotlp_values_droppedand is not named inpartialSuccess. - Kinds.
gen_ai.operation.name:chat,text_completion,generate_content,embeddingsarellm;execute_toolistool;invoke_agent,create_agent,invoke_workflowarechain;retrievalisretrieval; any other value, a whitespace-only one included, isother; absent or empty isllm(the old rule). Changed: agen_aispan withexecute_toolorinvoke_agentwasllmand is nowtoolorchain, so it no longer counts as an LLM call. OpenInferenceLLM/EMBEDDINGllm,TOOLtool,CHAIN/AGENTchain,RETRIEVER/RERANKERretrieval, anything elseother. Vercel…doGenerate/…doStream/…doEmbedllm,ai.toolCalltool, any otherai.*operationchain. Traceloopworkflow/task/agentchain,tooltool, anything elseother. Values are compared trimmed and lower-cased. No newSpanKindvalue. Changed 2026-10-19: agent spans areagent, notchain(see “Agent span kind and trace compare”). - Error classification.
STATUS_ATTRIBUTESisspanlight.gateway.upstream_status,http.response.status_code,http.status_code,error.status,spanlight.gateway.status.error.typeholding 1 to 3 ASCII digits is a numeric status read after those five (503isprovider_5xx). Otherwise its text is matched against the message patterns before the status message, as the more precise text, with one floor: whenerror.typeclassifies asclient(BadRequestError, which OpenAI raises for context-length and content-filter 400s too), the status message is tried andclientis only the fallback, so adding an attribute never makes the class worse. A span whose status is noterrorhas no class whatevererror.typesays. Patterns added:ratelimiterror(rate limit),permissiondenied(auth),contextwindowexceeded(context length) andbadrequesterror(a newclientgroup, matched last so it never shadows a more specific phrase).authenticationerror,apitimeouterrorandapiconnectionerror, which the design listed, are not added:authentication,timeoutandconnectionalready match them as substrings. - Finish reasons. New attribute sources after the existing four:
ai.response.finishReason,llm.response.finish_reason,gen_ai.response.finish_reason,gen_ai.completion.0.finish_reason,llm.finish_reason. New canonical values: Vercelcontent-filtercontent_filter,tool-callstool_calls,errorandunknownother; Geminisafety,recitation,blocklistcontent_filter,malformed_function_callother. On OTLP the mapper passes its convention’s raw value as the span’s own finish reason (the first source, at most 64 characters):gen_ai.completion.0.finish_reasonforgen_ai, which must be read before the flattened completions are consumed,llm.finish_reasonfor OpenInference andai.response.finishReasonfor Vercel. Thegen_ai.completion.0.finish_reasonentry in the attribute source list therefore only serves spans sent to the native endpoint. - Time to first token.
ai.response.msToFirstChunkin milliseconds;gen_ai.client.operation.time_to_first_chunk, elsegen_ai.response.time_to_first_chunk, in seconds times 1000. Numeric text ("0.42") is read as the number. A value that is negative, not finite or not numeric is dropped and counted asttft; the span is kept. - Embeddings. An embeddings span (
gen_ai.operation.name = embeddings,llm.request.type = embedding, OpenInferenceEMBEDDING, a Vercel operation ending.doEmbed) with an input count and no output count storesoutput_tokens = 0and the attributeusage.output_tokens_implied = true. Without an input count nothing is implied. usage.unreported_reason = "not_exported". Set on an OTLP span mapped asllmwith a model and none of the four token counts (input, output, cached, cache write).- Usage rollups. A span is a usage rollup when it has an input or output token count and a descendant in the same
(project_id, trace_id), reached throughparent_span_id, has one too. Stored asspans.usage_rollup(migration0900:boolean NOT NULL DEFAULT false, catalog-only,lock_timeout = '5s'; the downgrade drops it). The upsert column list does not include it, so a replay never resets it. Every batch recomputes the flags of its traces in its own transaction, after the span upsert and before the trace totals, under the same trace locks; the recursion stops at depth 64, so a parent-id loop of usage spans flags both. The flag ignores the gateway-ownership guard of the upsert (it is derived, not client data). Rows are kept as sent: tokens, cost, model and pricing version. - Who leaves rollups out. Every sum of tokens or cost, and every count of unpriced calls; counts of spans, calls
and errors are unchanged. Trace totals (
input_tokens,output_tokens,cost_usd,has_unpriced) now exclude rollups, so a trace’s cost can be lower than the sum of its spans’ costs; the hourly rollups (tokens, cached tokens, cost, unpriced calls), the raw path of the metrics endpoints for windows up to 24 hours (KPIs, timeseries, models), alert metrics and budget spend, the Doctor’s LLM-span context, Users (tokens,cost_usdand alsounpriced_calls, which the design listed among the unchanged counts), prompt usage, the daily cost rollups behind reconciliation (sums only), attribution, and the unpriced-models list. Retry grouping skips rollups, so an SDK span that is the rollup parent of a gateway span for the same call is not a retry. The Doctor’s context filter also hides a failed rollup span from its error detectors; the failure still counts in the trace and the error metrics. The gateway overview’s per-key spend and the demo job’s month-to-date spend are not filtered: they read gateway and demo spans, which are not parents of other usage spans. - Rollups elsewhere. The cost explanation of a rollup has the status
usage_rollupand the message “Usage rollup: this span repeats the usage of its child spans and is excluded from totals.”, ahead of every other status. Each span of a JSONL trace export carriesusage_rollupaftercost_usd; there is no CSV column, because span rows exist only in JSONL (a trace CSV row has the trace totals, which already exclude rollups). The hourly rollups recompute the last 48 hours (the last three every run, the rest about once an hour); a flag that flips on an older hour is corrected byspanlight rollups backfill. - No backfill in the migration. Rows stored before
0900keepfalseuntil a batch touches their trace. The admin commandspanlight recompute-usage-rollups --since <Nd|Nh> [--batch-size 500]recomputes the flags and the trace totals of the traces started in that window, one transaction per batch of traces of one project, through the worker’s row-level-security bypass;--sinceis required, and a value that is notNdorNhis a usage error. - Span read model.
SpanOutgainsusage_rollup: boolandusage_state, an enum computed on read fromkind,model, the two token counts,cost_usdandusage_rollup(in the OpenAPI schema), never stored. Precedence:rollup,priced,not_applicable,model_unknown,usage_not_reported,partial_usage,no_price.rollupcomes beforepriced(the design listedpricedfirst): a rollup’s usage is counted on its children even when it has a cost of its own. The trace page shows a rollup with the badge “Counted on child spans” (the design’s tooltip read “Counted on its child spans”), and apartial_usagecost with the tooltip “Usage is incomplete, so no cost was computed” (the design was silent). - Metadata from conventions. Trace metadata has one owner on OTLP: the
spanlight.metadata.*reader. The mappers collectai.telemetry.metadata.*,ai.settings.context.*(the first wins per key),traceloop.association.properties.*and the OpenInferencemetadataJSON object, keys lower-cased and values raw, and the reader cleans them leniently and puts them under the resource’s and then the span’sspanlight.metadata.*, before the 32-key cap. Numbers and booleans become their JSON text; arrays and objects are dropped and counted (the design JSON-encoded non-string OpenInference values; the trace metadata contract treats objects and arrays as invalid). A key is never rewritten. The OpenInferencemetadataattribute is removed from the span only when it is an object. ThepartialSuccessnote still reads “invalid spanlight.metadata.* key or value, or over 32 keys”, and its count includes the entries dropped from the conventions’ attributes. - Support promise. A library and version is supported when it has a green fixture: a recording of what its
exporter sends, under
sdks/fixtures/otlp/<library>/<version>.json, replayed through the mapper by the test suite. A weekly workflow (otlp-matrix-weekly.yml) records the latest versions against the committed fixtures and keeps one labelled issue up to date when they differ. The recipes pin the library and every package they import beside it, the model clients, LangChain,@ai-sdk/openai(one major peraimajor) andzodincluded; dependencies those packages pull in resolve at record time. The weekly job installs theai-v5,ai-v6andlatestdist-tags. A fixture is recorded withrecord.py --library <library> --version <version>(Python) ornode record.mjs --version <version> --mode genai|legacy(Node);--checkre-records and diffsexpectedfield by field against the committed file. Both recorders run withTMPDIR=/tmp, so no per-user temporary path reaches a committed fixture. The Node recorder seedsMath.randomand stepsperformance.nowby one millisecond per call, so span ids and the time to first token in the Vercel fixtures are stable harness values, not measured latencies. The published Vercel AI SDK recipe is the file thevercel-ai-genairecorder imports; there is no separate recipe test or CI job for it. Not recorded yet: theopeninference-anthropiclibrary, the LiteLLM Anthropic cache read and write scenarios and the Traceloop embeddings scenario (they need captured Anthropic and embeddings response bodies), and a successfulgenerateObject(the fake provider has no JSON-object body, so that scenario records the failure path). The recording harness’s fake provider picks its default scenario per route (chatfor Chat Completions,responses,embeddings,messages), notchatfor every route as first designed; thex-fixture-scenarioheader still overrides it. - What the recorded exporters send, where it differs from the design (recorded 2026-10-17).
- Traceloop 0.62.4 (
opentelemetry-instrumentation-openaiandtraceloop-sdk) sends a provider span’s messages asgen_ai.input.messagesandgen_ai.output.messages, not the flattenedgen_ai.prompt.<N>.*/gen_ai.completion.<N>.*keys the design expected; its only other mode (use_attributes=False) moves content to log events. The flattened keys are still read for older releases. - Traceloop sends the finish reason
tool_call(the OpenTelemetry GenAI singular). It is now canonicaltool_calls. - LiteLLM 1.105.1 writes
gen_ai.usage.input_tokens/gen_ai.usage.output_tokensandgen_ai.input.messages/gen_ai.output.messagesin its default (legacy) mode too, notgen_ai.usage.prompt_tokensand flattened messages; the modes differ in the provider key (gen_ai.systemorgen_ai.provider.name), the span name (litellm_requestorchat <model>) and theraw_gen_ai_requestchild, which only the default mode opens. LiteLLM sendsgen_ai.response.finish_reasonsas text holding a JSON array ('["stop"]'); the finish reason reader now parses such text inresponse.finish_reasonsandgen_ai.response.finish_reasonsas the list (text that is not a JSON array is still not read). On the OpenAI route LiteLLM reports cached prompt tokens only inside themetadata.usage_objecttext, so those spans have no cached count. - OpenInference sends the time to first token of a stream as a span event (
First Token Stream Event), not as a metric as the design assumed. Span events are not read, so the field staysnull; the compatibility page marks it “not read”. On a failed callopeninference-instrumentation-openai0.1.65 sets nollm.model_name; the request model is only inside thellm.invocation_parametersJSON, which is not a model key, so the span ismodel_unknown. - The OpenInference LangChain instrumentation puts LangChain’s run metadata (
ls_provider,ls_model_name,ls_model_type,ls_integration,ls_retriever_name) in themetadataattribute, so those keys land in trace metadata and count towards its 32 keys; object values such aslc_versionsare dropped and counted. - The GenAI mode of the AI SDK 7 (
OpenTelemetryfrom@ai-sdk/otel1.0.142) sends no runtime-context metadata, and sendsfunctionIdasgen_ai.agent.name, which is not read as the trace name, so its traces have no metadata and are named after the root span (invoke_agent <model>). The legacy mode (LegacyOpenTelemetry) sends the metadata asai.settings.context.<key>and the trace name asai.telemetry.functionId.
- Traceloop 0.62.4 (
Review loop (added 2026-10-15)
Section titled “Review loop (added 2026-10-15)”-
Queue items carry a
reason.annotation_queue_items.reasonis one ofmanual,doctor_finding,error,cost_outlierandrandom, defaultmanual, so every item that existed before readsmanualand an item added withPOST …/annotation-queues/{queue_id}/itemsismanualwith no detail.reason_detailis a JSON object ornulland holds numbers, ids and the trace name only, never payload text:doctor_finding{insight_id, kind},error{error_count, first_error_span_id, error_class}(error_classisnullwhen the span has none),cost_outlier{cost_usd, p99_usd, trace_name, sample}(money as 4-place strings) andrandom{rate}(a 4-place string), each withalso, the list of lower-priority reasons the same trace matched.QueueItemOutgainsreasonandreason_detail;QueueOut.progressgainsby_reason,{reason: {pending, done, skipped}}with every reason present, zeros included;GET …/items?reason=keeps one reason. -
One review policy per project.
GET,PUT,DELETE /api/v1/projects/{project_id}/review-policy,POST …/review-policy/runsandGET …/review-policy/selections. Reads needproject:read; set, delete and run needannotations:write. These routes take a session or a personal access token, never a project API key.PUTcreates or replaces the policy and omitted fields take their defaults (enabledtrue,random_rate0.0100, every include true,daily_cap50,environmentsempty).random_rateis 0 to 0.05 at 4 places;daily_capis 1 to 500;environmentsholds at most 20 names of 1 to 64 characters, and naming an internal environment (evals,playground,experiments,doctor) is422 VALIDATION_ERROR. A policy whose includes are all off with a random rate of 0 is422 NOTHING_SELECTED. A queue of another project is404. -
next_run_atis the next 01:00 UTC strictly after the moment of the read (at exactly 01:00 it is the next day’s), andnullwhile the policy is disabled. Re-enabling a policy resumes from the next night; missed days are not caught up (run them by hand). -
The selection window. Every UTC day from 01:00 the worker selects the traces that ended during the previous UTC day:
ended_atin[00:00, 24:00), half-open, so a trace ending exactly at midnight belongs to the next day. The scan is bounded onstarted_at(no earlier than one hour before the day starts), so a trace that ran longer than an hour is never selected. A candidate has at least onellmspan, is not in an internal environment (even when the policy lists one, for a row written before the check), carries none of the tagsspanlight:judge,spanlight:playground,spanlight:replayorspanlight:experiment:*, and, when the policy names environments, is in one of them.REVIEW_SELECTION_ENABLED=falsestops the nightly run; manual runs still work. -
Priority and the random floor. Reasons rank
doctor_finding(the trace is inevidence.trace_idsof anopeninsight; acknowledged, resolved and muted insights do not select) >error(error_count > 0) >cost_outlier>random. A trace becomes one item under its highest reason, the others listed inreason_detail.also. The lastceil(daily_cap / 4)slots are held for random picks and released to the higher reasons only when there are fewer random candidates. Inside a reason the order is the sampling hash (sha256("<policy_id>:<trace_id>"), the evaluator sampler’s function salted with the policy id), never time, so a re-run picks the same traces and a higher rate only adds traces. Each reason’s read stops atdaily_caprows. -
The cost outlier is a trace whose
cost_usdis strictly abovepercentile_disc(0.99)of the known costs of the traces with the samenamethat ended in the 7 UTC days ending with the selected day, when that group has at least 30 known costs. A trace with no name or no known cost is never an outlier. The 7-day group is every named, priced trace of the project in those days: it is not narrowed by the policy’s environments or by the candidate rules (internal traces carry their own names, so they do not mix with an application’s). -
Dedupe. The candidate reads skip any trace that already has an item in the policy’s queue, whatever its
span_id(a span queued by hand keeps the policy from queueing its trace), and the insert isON CONFLICT DO NOTHINGon(queue_id, trace_id, span_id). A trace is selected only for the day it ended in; a late span that movesended_atinto the next day can make it a candidate again, and the conflict skips it unless the policy points at another queue by then (the trace then appears in both).skipped_duplicatescounts the conflicts plus the traces of the day the queue already held, so it is not only “picked and then refused”.cap_hitis true when the cap was full and a candidate was left out (counted in distinct traces). -
One selection per project and day. The per-project job claims the
review_selections (project_id, day)row first, then reads, inserts the items and completes the row in one transaction, so a crash leaves nothing and a second run for the same day (two workers, a retry, a manual run) endsdonewith outcomealready_selectedand queues nothing. Migration1003addsalready_selectedto the allowed values ofjobs.outcome. A policy deleted or disabled after its job was queued releases the claim and selects nothing.candidatesandqueuedhold the four reasons as keys, zeros included; a reason the policy leaves out reads 0. -
Manual runs.
POST …/review-policy/runs {day}covers a completed UTC day from yesterday back to 7 days: today or later is422 DAY_NOT_COMPLETE, older is422 DAY_TOO_OLD, a day that already has a selection row is409 ALREADY_SELECTED, no policy is404. It answers202 {day, job_id}, wherejob_idis the job’s id as an integer (jobs ids are 64-bit integers, not UUIDs). The run enqueues the same job and dedupe key as the nightly run, so a run already queued for the day is returned, not duplicated. Known limit: a day whose job failed all three attempts cannot be requested again (the dedupe key stays taken). -
Selection history.
GET …/review-policy/selectionslistsSelectionOutrows newest day first, at most 30 a page, keyset byday. The history outlives the policy and the queue: deleting the queue setsqueue_idtonull. -
QUEUE_IN_USE.DELETE …/annotation-queues/{queue_id}is409 QUEUE_IN_USEwhile a review policy fills the queue (the foreign key refuses it too). Delete the policy or point it at another queue first. Chosen over a cascade so that a policy never stops silently. -
The failure category is a convention. A failure category is an ordinary categorical score named
failure_category.POST …/score-configs/failure-category {categories}(scores:write) creates the explicit categorical config with 2 to 30 distinct categories that must includenone, and answers201; a config of that name already existing is409 NAME_TAKEN(edit it withPATCH /score-configs/{config_id}).noneis checked only at creation: a laterPATCHmay remove it. A review policy’s queue must list that config in itsscore_config_ids, elsePUT /review-policyis422 QUEUE_LACKS_FAILURE_CATEGORY. Afailure_categoryconfig that is not categorical (an implicit numeric one created by a stray score) counts as missing: the policy is422 QUEUE_LACKS_FAILURE_CATEGORYand the rates read below is404. -
Failure-category rates.
GET …/releases/failure-categories?from&to&environment(project:read, and a project API key withtraces:readfor its own project) uses the window rules ofGET /releases(default last 24 hours, at most 30 days, the same releases in the same order, traces without a release asrelease: null). A trace counts once, under its latestfailure_categoryscore (highestcreated_at, ties by the higher id, whatever itsspan_id).reviewedis the number of such traces of the release; each category hascount,rate = count / reviewedand the Wilson 95 % boundsci_low,ci_high, all 4-place strings. A category no trace of the release carries iscount 0with the interval of 0 of n. A release withreviewed = 0hasrates: {}, never 0 %. Categories come in the config’s order; a label no longer in the config is still counted, under its own name, after the configured ones (alphabetically).404 NOT_FOUNDuntil the project has a categoricalfailure_categoryconfig. -
Replay classification.
GET …/traces/{trace_id}/spans/{span_id}/replay-source(project:read) classifies a span without calling a provider;POST …/playground/replaysapplies the same rules. The first match wins:- not an
llmspan:REPLAY_NOT_LLM; - no stored input:
REPLAY_NO_PAYLOAD; truncatedwith a string input:REPLAY_PAYLOAD_TRUNCATED(ingest stores a cut payload as a string; a truncated span with an object or list input had its output cut, and replays);[REDACTED]or[MAX_DEPTH]in any key or string of the input, at any depth:REPLAY_PAYLOAD_REDACTED(a prompt that contains the literal text[REDACTED]is refused too; edit it out);- an object with a string
modeland either amessageslist or aninputfield:exact. The surface is the gateway’s recordedspanlight.gateway.surfacewhen it names a request surface (chat_completions,responses,messages), elsemessagesfor an Anthropic span,responsesfor a body withinputand nomessages, elsechat_completions; - a non-empty list of
{role, content}objects (what OTLP ingest stores):messages_only, sent as{model: <span model>, messages}; - anything else (a bare string, an object without a model):
REPLAY_UNSUPPORTED_INPUT.
SDK and gateway spans store the provider-native request, so they replay exactly; OTLP spans store only the messages, never the tools or parameters, so they replay as
messages_only(not exact). Anthropic spans need an Anthropic credential; every other provider (openai,azure,groq,ollama, unknown) speaks the OpenAI API and needs an OpenAI or OpenAI-compatible credential. - not an
-
What a replay changes. Every key of the stored body is passed through untouched (
messages,input,system,tools,tool_choice,response_format,text,stop,temperature,seed,metadata,reasoning,thinking, and any other) except:streamandstream_optionsare removed (a replay is never streamed;ttft_msisnull);nabove 1 becomes 1; every output cap present,max_tokens,max_completion_tokensand the Responses API’smax_output_tokens, is clamped tomin(stored, max_tokens override, 4096)(the design named only the first two; without the third a Responses body could bypass the cap); amessagesbody with no cap getsmin(1024, override), and achat_completionsorresponsesbody with no cap getsmax_completion_tokensormax_output_tokensofmin(override, 4096)(added 2026-10-18: without it the provider’s own default, often far above 4096, could bill past the reserved worst case);modelis replaced by an override only when it differs from the body’s. Each change is listed inchanged, plusbodywhenedited_bodywas sent.fidelityisexactonly when the source was exact andchangedholds nothing butstreamandstream_options; otherwisemodified, and amessages_onlysource staysmessages_only. An output cap that is not an integer isREPLAY_UNSUPPORTED_INPUT. -
Options billed beyond tokens are refused (added 2026-10-18).
service_tier: "auto"may route to a priority tier, so it is sent as the surface’s standard tier (standard_onlyonmessages, elsedefault) and listed inchanged. Any otherservice_tierbut absent,defaultorstandard_only(priority, flex and scale bill at other rates), a chatweb_search_options, or atoolsentry that is not billed by tokens alone is422 REPLAY_UNPRICED_OPTION, naming the option. Tools are an allowlist: notype,function,custom, and the tools the caller’s own code runs (types startingbash_,text_editor_,computer,memory_,browser_,local_shell,apply_patch, and OpenAI’sshellonly withenvironment.typelocal, since its hosted container is billed); any other type, a provider-run server tool known or new, is refused, so a new paid tool cannot slip past the budget. The replay estimate and its settledcost_usdprice tokens at the standard rate only, so such a call would spend past the budget and record the wrong cost. Pricing the tier was the alternative; it still left server-tool fees with no worst case to reserve, so refusing is the smaller complete rule. The check runs onPOST …/playground/replays(and on an edited body), not inreplay-sourceclassification, so the button stays enabled and the refusal arrives with the run. -
Edited bodies.
edited_bodyreplaces the stored request as a whole: a JSON object of at most 256 KB (REPLAY_BODY_TOO_LARGE), no NUL characters, re-checked by rules 4, 5 and 7, so markers cannot be sent and the body stays a request. An edit keeps the stored span’s surface. -
A replay needs a model. A
messages_onlysource whose span recorded no model, with nomodelin the request, isREPLAY_UNSUPPORTED_INPUT; the dashboard asks for a model. -
Provider mismatch is refused, not converted. A credential of the other family is
422 REPLAY_PROVIDER_MISMATCH. Converting Chat Completions to Messages would rewrite tools, system handling, stop sequences and response formats, and the result would no longer be the stored request. The playground’s typed messages are the cross-provider route, and are not a replay. -
The replay call. One attempt through one credential of the caller’s organization (another organization’s credential is
404), no fallbacks,experiments:run,Idempotency-Keyaccepted, 30 a minute per person shared with playground runs (429 RATE_LIMITED). The call is a trace in environmentplayground, namedreplay, taggedspanlight:replay, with the caller’s user id and the source trace’s release; the span carriesspanlight.replay.source_trace_id,spanlight.replay.source_span_id,spanlight.replay.fidelity,spanlight.replay.changedandspanlight.replay.id. The project’sblockbudgets apply: the result’serrorisBUDGET_EXCEEDEDand the ledger row isfailedwithgateway_budget_blocked. A provider failure is the result’serror, not an HTTP error.same_request_hashistruewhen the replayed span’s request hash equals the source’s,falsewhen it differs, andnullwhen the replayed span was not stored (nothing to compare). -
The per-project monthly replay budget. Replays spend at most
REPLAY_MONTHLY_BUDGET_USD(default 5.00) per project per UTC month;0turns replay off with409 NOT_CONFIGURED, as do a missing gateway runtime and missingCREDENTIALS_KEYS. Each replay reserves its worst case (the body’s text as input tokens, every allowed output token) under a per-project lock before the call, so concurrent replays cannot pass the limit together. A replay that does not fit sends nothing and is409 REPLAY_BUDGET_EXCEEDEDwithspent_usd,reserved_usd,budget_usdandmonth_startas extension members; the ledger keeps afailed/budget_exceededrow for it. A model with no price, or a price of zero (an override), is422 REPLAY_MODEL_UNPRICED: the budget could not count it. A call that breaks after it was sent is charged its estimate; a replay that stops sending its heartbeat for 10 minutes is failed by the reaper (abandoned) and charged its reservation.GET …/playground/replays/budget(project:read) answers{monthly_budget_usd, spent_usd, reserved_usd, remaining_usd, month_start};GET …/playground/replays?trace_id&span_idlists the ledger newest first. -
Statistics. A boolean score (its config says boolean, or every value is a JSON boolean; 0 / 1 numbers under a boolean config count as boolean) gains
passed,ci_lowandci_highin a run’s totals: the Wilson 95 % score interval (z = 1.959964, no continuity correction), 4 places, half-even, clamped to[0, 1].n = 0givesnull, never[0, 1]; numeric and categorical scores carrynull; totals stored before this change lack the keys and read asnull. A comparison’sScoreDeltaOutgainsci_a,ci_b(boolean only) andtest: the exact McNemar test on the items scored in both runs for a boolean score (improved= fail in A and pass in B,regressed= the reverse, concordant pairsunchanged), the paired sign test (ties dropped) for a numeric score, both an exact two-sided binomial capped at 1,p_valueat 4 places,significantwhenp < 0.05decided on the exact value.n = 0(no item changed) isp = 1.0000, not significant.testisnullfor a categorical score and when fewer than 2 matched items carry the score. Alpha is not a setting. The eval gate still compares the mean with its threshold; the interval is reported beside it. -
Usage counters.
spanlight_feature_used_total{feature}countsreview_selectiononce per completed project-day selection,review_item_completedper completed item whose reason is notmanual,failure_categoryper failure-category config created,replayper replay answered,dataset_item_from_reviewper dataset itemPATCHwhosemetadatasets areviewobject (the dashboard sends it after copying a reviewed trace into a dataset), andexperiment_intervalper run read or comparison whose answer carries an interval or a test.spanlight_review_selected_total{reason}counts items queued by the policy;spanlight_replay_runs_total{outcome}(done,failed,budget_exceeded,gateway_budget_blocked,abandoned) andspanlight_replay_cost_usd_totalcount replays and their charge. -
Annotate shortcuts. On the annotate screen
Ropens the replay of the item’s failing span andDsaves the trace as a dataset item. The design saidSfor save;Swas already Skip, so it stays Skip. -
Audit.
review_policy.create,review_policy.update,review_policy.delete,review_policy.run(metadata{day}) andreplay.run(metadata{source_trace_id, source_span_id, credential_id, model, fidelity, cost_usd}, never content). A replay the reaper settled first is still audited when its call returns. -
New problem codes.
409:QUEUE_IN_USE,ALREADY_SELECTED,REPLAY_BUDGET_EXCEEDED.422:QUEUE_LACKS_FAILURE_CATEGORY,NOTHING_SELECTED,DAY_NOT_COMPLETE,DAY_TOO_OLD,REPLAY_NOT_LLM,REPLAY_NO_PAYLOAD,REPLAY_PAYLOAD_TRUNCATED,REPLAY_PAYLOAD_REDACTED,REPLAY_UNSUPPORTED_INPUT,REPLAY_PROVIDER_MISMATCH,REPLAY_MODEL_UNPRICED,REPLAY_BODY_TOO_LARGE. Run-level strings (not HTTP): a replay row’serrorbudget_exceeded,abandoned,gateway_budget_blockedor the call’s own error (replay_failed, or the provider’s message); a result’serrorBUDGET_EXCEEDED; the job outcomealready_selected. -
Migrations.
1000addsreasonandreason_detailtoannotation_queue_itemswith the index(queue_id, reason, status);1001addsreview_policiesandreview_selectionsand the new audit actions;1002addsreplay_runs;1003widensjobs.outcome. The three new tables are project-scoped with forced row-level security; the nightly listing of enabled policies is the only new read that bypasses it. -
POST …/datasets/{id}/items/from-tracesreturnsitem_ids. The answer gainsitem_ids(the ids of the items it created, in insertion order) next to the existing counts, so “Save as dataset item” from a review queue can writemetadata.reviewon the new item withPATCH …/items/{item_id}. The change is additive. -
Edit and replay. A span refused as
REPLAY_PAYLOAD_REDACTEDorREPLAY_PAYLOAD_TRUNCATEDcan be replayed when the request carriesedited_body: the edited body is checked again (size, markers, request shape, provider family) and the replay is alwaysmodified, neverexact. Every other refusal stays a refusal with or without an edit.
Agent span kind and trace compare (added 2026-10-19)
Section titled “Agent span kind and trace compare (added 2026-10-19)”- New
SpanKindvalueagent. OTLPgen_ai.operation.nameinvoke_agentandcreate_agent, OpenInferenceAGENTand Traceloopagentareagent(they werechain).invoke_workflow,CHAIN,workflowandtaskstaychain; Vercel AI SDK legacyai.*operations staychain(AI SDK 7 agents arrive through the GenAI convention). Native ingestion accepts"kind": "agent". Spans stored earlier keepchain; there is no backfill.agentspans are not LLM calls: every metric that countsllmspans is unchanged. The recorded OTLP fixtures’ expected kinds changed accordingly. The Python SDK’sSpanKinddoes not listagentyet: older servers reject an unknown native kind, so the SDK adds it in a release that requires this server. - Migration
1006adds the enum value outside the migration’s transaction (ALTER TYPE span_kind ADD VALUE IF NOT EXISTS 'agent'); no row is read or rewritten. Postgres cannot drop an enum value, so the downgrade setsagentspans back tochainand deletes theagentrows ofspan_rollups_hourly(the rollup job rewrites them), with the worker bypass; the value stays in the type. During a rolling deploy an older app instance fails to read anagentspan until it is replaced. GET /api/v1/projects/{project_id}/traces/compare?a=&b=compares two traces of the project. Access is trace detail’s (project:read; API keys withtraces:read). If either trace is not in the project the answer is the same404 NOT_FOUNDas trace detail, without saying which. The answer has both trace summaries,totals(B minus A: duration, tokens, span and error counts;cost_usdnull when either trace has an unpriced call),stepsandtruncated.- Steps. Spans pair by name and occurrence (the first span of a name in start order is occurrence 1), in trace
A’s order; a step only in B follows the last step before it that A also has. Each step has both sides (
a,b, null when missing), the deltas B minus A (null unless both sides have the value) andinput_changed/output_changed. Payloads are compared by an md5 of their JSONB text in the database and are not returned; the flags are null when a side is missing, a payload was not captured, or the payloads match but a span was truncated at ingest. Parallel spans of one name that start at the same instant are ordered by span id and may pair crosswise. At most 2,000 spans of each trace are compared (truncated: truewhen one has more);totalsalways cover the whole traces.
Dataset reproducibility: run copies, locked datasets, repeated traces (added 2026-10-20)
Section titled “Dataset reproducibility: run copies, locked datasets, repeated traces (added 2026-10-20)”- Runs keep a copy of each item. Every
experiment_itemsrow stores theinput,expected_outputandmetadataof the dataset item exactly as the run read it (migration1010, columnsinput,expected_output,item_metadata), on error rows as well (missing_variables,BUDGET_EXCEEDED,cancelled,internal_error). A CHECK (experiment_items_copy_check) keeps the copy whole:inputanditem_metadataare both set, or all three areNULL;expected_outputalone may beNULL. A JSONnullinput is stored as the JSONB valuenull, so aNULLinput always means “no copy”. - Run items.
GET …/runs/{run_id}/items(ExperimentItemOut) gainsinput,expected_outputanditem_metadata, allnullon a row stored before this release; such rows are not filled from the live dataset, whose items may have changed since. It also gainsposition, the item’s 1-based rank by dataset item id within that run, so deleting or adding dataset items never renumbers an old run. - A run scores a fixed item set. When a run starts it takes the ids of the dataset’s first
max_itemsitems in id order, in the same transaction that moves it torunning, and then reads their content page by page by those ids. An item added during the run is not scored; an item deleted during the run is skipped; an item edited during the run is scored on the content its page read returned, and the copy records exactly that. - Locked datasets.
POST /api/v1/projects/{project_id}/datasets/{dataset_id}/lockand…/unlockanswer200withDatasetOut, which gainslocked_at(nullwhen unlocked). Both need the new permissiondatasets:lock(classwrite: admins and owners; members and viewers get403, non-members404, and an admin’s or owner’s read-scoped personal access token gets403 TOKEN_SCOPE). Both are idempotent: locking a locked dataset keeps its firstlocked_atand writes no audit event, and unlocking an unlocked one changes nothing. A change is audited asdataset.lockordataset.unlockwith the dataset’s name (migration1011adds the actions). 409 DATASET_LOCKED. On a locked dataset, adding items (POST …/items), uploading a file (POST …/items/upload; the file is checked first, so a wrong type or an oversized file still gets its415or413), adding from traces (POST …/items/from-traces), editing an item (PATCH …/items/{item_id}), deleting an item and deleting the dataset answer409 DATASET_LOCKED, “This dataset is locked. An admin can unlock it.” The check runs under the dataset’s row lock, so a write either commits before the lock or is refused. Reading the dataset and its items, changing its description (PATCH /datasets/{dataset_id}), locking, unlocking and running experiments on it still work.- Repeated traces. In
POST …/items/from-traces, a trace whose chosen span is already an item of this dataset (the samesource_trace_idandsource_span_id) is not added and is listed inrejectedwith the reasonalready in this dataset. The same trace with another span is a different case and is added.TraceAddedOutgainsalso_in: [{trace_id, datasets: [{id, name}]}], one entry per added trace that already has an item (from any span) in another dataset of the project; it is[]when there is none. Row-level security keeps the lookup inside the project. Items added as JSON or by upload carry no source trace and are not checked. Migration1012adds the partial indexdataset_items_source_trace_idxthat serves both lookups. - Compare.
ItemDeltaOutgainsinputandexpected_output(run A’s copy, else run B’s, elsenull),position(run A’s rank, else run B’s) andchanged:truewhen both rows have a copy and theirinputorexpected_outputdiffer (JSON equality),falsewhen both match,nullwhen the item is in one run only or either row was stored before this release. Item metadata is not compared, because evaluators never see it. A changed pair hasnulldeltas and is left out ofimproved,regressed,unchangedand the paired test, since its two scores were measured against different references;ComparisonOut.changed_itemscounts such pairs. Each run’s means, intervals, cost and latency still cover all of its items.
Audit log hardening (added 2026-10-20)
Section titled “Audit log hardening (added 2026-10-20)”- Event fields.
AuditEventOut(list and CSV) gainsactor_type(user,tokenfor a personal access token,systemfor the app itself,clifor an admin command;nullon events written before migration1013, not known),actor_email(the actor’s email when the event was written, so it survives the user’s deletion; for older events the API falls back to the live user’s email),credential_id(the token used, whenactor_typeistoken),project_id(set on events written by a project route),outcome(successorfailure; every earlier event is a success),reason(a failure’s code),user_agentandrequest_id(the request’sX-Request-ID). The IP and user agent are recorded only when the actor is the one making the request; an event with no actor carries neither. The demo account’s requests record no IP or user agent. - Filters.
GET /orgs/{org_id}/auditand its exports takeproject_id,outcome(success,failure) andactor_type(user,token,system,cli) too; any other value is422. The CSV columns areid, created_at, action, actor_id, actor_email, actor_type, credential_id, project_id, target_type, target_id, outcome, reason, ip, user_agent, request_id, metadata. - New actions.
user.login(metadata.method:password,totporoauth; a wrong password for a known account or a wrong two-factor code isoutcome: failurewithreasoninvalid_passwordorinvalid_totp; an unknown address belongs to no org and is only counted inlogin_attempts; the two-factor challenge step writes nothing),user.logout,user.session_revoke(one session: targetsession; every other session:metadata.count),token.createandtoken.revoke(targettoken,metadataname and scope; a repeated revoke writes nothing),export.create(targetexport,metadatathe request body),audit.export(written before the file streams,metadata{format, filters}with the filters that were set) andaccess.denied(a member refused on POST, PUT, PATCH or DELETE, never in a demo org;reasonisforbiddenfor a role without the permission,two_factor_requiredin an org that requires two-factor authentication,token_scopefor a read-only personal access token;metadatapermission, method and path; written in its own transaction, as the refused request’s rolls back). - Hash chain (migration 1014). Every event gets
seq(1, 2, 3 … per org),prev_hashandhash(hex SHA-256 over the event’s canonical JSON,prev_hashincluded), filled by a database trigger under a per-org advisory lock, soseqfollows commit order. Events written before 1014 were chained by the migration as stored then.actor_user_idno longer has a foreign key (a cascade would rewrite a hashed column); the id andactor_emailstay after the user is deleted.GET /orgs/{org_id}/audit/integrity(audit:read) →{ok, checked, first_seq, head_seq, head_hash, first_broken_seq}; the oldest stored event is trusted as the start, since retention removes older ones. CLI:spanlight audit-verify [--org <slug>], exit1on a break. - Audit streaming (MIT, no license gate). One HTTPS endpoint per org.
GET /orgs/{org_id}/audit/stream(audit:read) → the stream ornull;PUT{url, enabled}(org:security, owners) creates it, withsecretreturned once, or changes it;POST …/stream/rotate-secretreturns a newsecretonce;DELETEremoves it;POST …/stream/testsends oneaudit.testevent now →{ok, error}(10 an hour,429). Without a stream, rotate, delete and test answer409 AUDIT_STREAM_NOT_SET_UP; a URL the webhook rules refuse is422 UNSAFE_URL; no CREDENTIALS_KEYS is409 NOT_CONFIGURED. The worker jobdeliver_audit_streams(every minute) POSTs{"events": [AuditEventOut…]}inseqorder, up to 500 per request and 20 requests per stream per run, signed like alert webhooks,X-Spanlight-Event: audit.events,X-Spanlight-Delivery: <org id>:<first seq>-<last seq>.delivered_seqmoves only on a 2xx; a failure backs off 1, 2, 4 … minutes (at most an hour). A new stream starts after the newest event. The audit trail records the URL’s host only, never its path or query. New actions:audit_stream.create,.update,.delete,.rotate_secret,.test. - JSON Lines export.
GET /orgs/{org_id}/audit/export.jsonltakes the same filters and limit as the CSV (audit:read,422 EXPORT_TOO_LARGEpast 50 000) and streamsapplication/x-ndjson, oneAuditEventOutobject per line, newest first, for log tools. Account-level events (sign-in, sign-out, sessions, tokens) are written to each non-demo org of the user, likeuser.password_reset. - Append-only. A trigger refuses
UPDATEandDELETEonaudit_events, except a foreign-key cascade (an org’s deletion deletes its log; a user’s deletion nullsactor_user_id) and the retention purge.ensure-app-rolerevokesUPDATEon the table from the app role. - Retention.
AUDIT_RETENTION_DAYS(default 365,0keeps everything); the hourly retention job deletes older events in batches of 5,000. - Redaction. Metadata keys naming a secret (
secret,password,token,api_key,authorization,cookie,hash,private_key, as a whole word of the key) are stored as[REDACTED], and string values go through the span payload redactor. - Who may read the log is unchanged:
audit:read, admins and owners.
Security review fixes (added 2026-10-20)
Section titled “Security review fixes (added 2026-10-20)”- Ingestion bounds.
POST /v1/traces,/v1/otlp/tracesand/v1/scorescount spans (or scores) before decoding any of them: over 1000 is413 PAYLOAD_TOO_LARGEstraight after the JSON parse or the protobuf parse, before the depth check and the cleaning walk. A gzip or deflate body that inflates past 1 MiB and by more than 100 times its compressed size is413 PAYLOAD_TOO_LARGE(“may inflate at most 100-fold”); real batches stay well under it (one long prompt repeated in every span is about 45:1). Parsing, validation, redaction and truncation run in a worker thread, off the event loop. - OTLP protobuf is counted from the wire.
/v1/otlp/traceswalks a protobuf body’s wire format before parsing it, so a body of millions of empty messages is refused before any is built. More than 1000ResourceSpans,ScopeSpansor spans is413 PAYLOAD_TOO_LARGE. More than 300,000 messages in all (1000 spans with the OTel SDKs’ default limit of 128 attributes, two messages each, is 256,000) or messages nested more than 100 deep (upb’s own limit) is400 INVALID_OTLP. Deviation: a body with group-encoded fields (wire types 3 and 4, deprecated and never sent by OTLP exporters) is400 INVALID_OTLP; the protobuf runtime alone would keep them as unknown fields. - Demo account.
POST /api/v1/orgsandPOST /api/v1/invites/acceptanswer403 FORBIDDENfor the shared demo user, like tokens and two-factor already did. - Two-factor setup needs the owner.
POST /api/v1/auth/totp/setuptakes an optional body{password}. An account with a password must send its current one: missing or wrong is422 INVALID_PASSWORD; five attempts per 15 minutes per user, then429 RATE_LIMITED. An account without a password (GitHub or Google only) must have signed in within the last 10 minutes, else403 REAUTH_REQUIRED.enableneeds a pending setup, so it is covered too. Deviation: the body was empty before. - Password reset revokes personal access tokens. Completing a reset by email and the CLI
reset-passwordrevoke every personal access token of the user’s that still works, and theuser.password_resetevent’smetadata.tokens_revokedsays how many. - API reference.
/api/docs(Swagger UI, whose script comes from jsDelivr) is served only whenAPI_DOCSallows it; unset, it is on whenAPP_BASE_URLis http and off (404) when it is https./api/openapi.jsonis always served. The Caddy policy for/api/docsallows scripts fromcdn.jsdelivr.net/npm/swagger-ui-dist@5/only, no longer all of jsDelivr. - Gateway deadlines. A streamed answer may run at most 600 seconds after its first chunk (the official OpenAI
client’s own default); past that the stream is cut and the span records
stream_error: the stream ran past 600 s. The request body must arrive within 30 seconds, else the provider envelope’s400“Request body was not sent in time.” - Exports. A project may have at most 5 exports
queuedorrunning; another request is409 TOO_MANY_EXPORTSuntil one finishes. - Audit streaming. Each POST to a receiver, including
…/stream/test, has a 15-second deadline for the whole exchange; a timeout counts as a failed delivery and backs off. Streams are served least recently delivered first, so one slow receiver cannot starve the others.
This page is generated from docs/api-deviations.md in the repository.