Changelog
All notable changes to Spanlight are recorded here. The format follows Keep a Changelog, and the project is pre-1.0, so minor versions may change behaviour; breaking changes are called out.
[Unreleased]
Section titled “[Unreleased]”- Locked datasets. Admins and owners can lock a dataset that must not change, such as a holdout set:
POST /api/v1/projects/{project_id}/datasets/{dataset_id}/lockand.../unlock, under the newdatasets:lockpermission (read-scoped tokens are refused). While a dataset is locked, adding items (as JSON, from a file or from traces), editing or deleting an item and deleting the dataset answer409 DATASET_LOCKED; reading it, changing its description and running experiments on it still work. Both routes are idempotent,DatasetOutcarrieslocked_at, and each change is audited asdataset.lockordataset.unlock. - Runs keep a copy of every item they score. Each experiment item stores the dataset item’s
input,expected_outputand metadata as the run read them, error items included, and the run items API returns them asinput,expected_outputanditem_metadata, withposition, the item’s number within the run. Editing or deleting a dataset item no longer changes what an earlier run shows it was measured against. Items of runs made before this release returnnullfor the copy; they are not filled in from the live dataset. - Repeated traces are caught when adding from traces. A trace whose span is already an item of the dataset is rejected as
already in this datasetinstead of being added twice, and the answer’s newalso_inlists the other datasets of the project that already hold each added trace, so the same conversation reaching a tuning set and a holdout set is visible.
Changed
Section titled “Changed”- A run scores a fixed set of items. It takes the ids of the dataset’s first
max_itemsitems when it starts: items added while it runs are not scored, and items deleted while it runs are skipped. Before, a run read the dataset page by page and could pick up items added mid-run. - Compare leaves out items edited between the runs. When both runs kept a copy and an item’s
inputorexpected_outputdiffers, the item getschanged: trueand no deltas, and is left out of the improved, regressed and unchanged counts and of the significance test; the comparison’s newchanged_itemscounts them. Each run’s means, intervals, cost and latency still cover all of its items. Compared items also carryinput,expected_outputandpositionfrom the runs’ copies. Item metadata is not compared.
- Item metadata is kept when adding items as JSON or uploading a file. Both paths stored
{}instead of the metadata sent; editing an item’s metadata afterwards already worked.
Upgrading
Section titled “Upgrading”Run spanlight migrate after upgrading. 1010 adds the copy columns to experiment_items and a CHECK on them (catalog only; the CHECK is then validated with one scan of the table that does not block writes); its downgrade drops the columns and loses every stored copy. 1011 adds datasets.locked_at (nullable, catalog only) and the dataset.lock and dataset.unlock audit actions; its downgrade deletes those audit events and forgets which datasets were locked. 1012 builds the partial index dataset_items_source_trace_idx CONCURRENTLY, which does not block reads or writes but waits for transactions already open on the table to finish; if the build is interrupted, running spanlight migrate again recovers (it drops the invalid leftover first). During a rolling deploy, a run finished by an older worker stores no copy and shows as an older run, and an older api replica can miss a new lock for the seconds both versions run.
[0.7.0] - 2026-10-20
Section titled “[0.7.0] - 2026-10-20”Agent traces and long payloads: compare two traces step by step, search what the model was sent and said, keep inputs and outputs up to 1 MB, and trace Node.js apps with a TypeScript SDK. Dashboards load faster on large projects, and an overloaded api process answers quickly instead of stalling.
- TypeScript SDK (
sdks/typescript, packagespanlight, not yet on npm).wrapOpenAItraceschat.completions.createandresponses.createandwrapAnthropictracesmessages.create, plain and streamed, including the providers’.stream()helpers;observe,withSpanandupdateTracetrace your own code, with the current span kept inAsyncLocalStorage. Spans go toPOST /v1/traceswith the Python SDK’s guarantees: background batching, gzip by default, retries withRetry-After, a bounded queue with loss counters, and errors from your code and the providers reaching it unchanged. Usage is recorded as the Python SDK records it:input_tokensincludes cache reads and writes, OpenAIcache_write_tokensand Anthropiccache_creation_input_tokens(with the 1-hour part) are kept, the service tier is normalized with the shared table, and a stream without its usage leaves the output count unknown. Node.js 22 or newer. - Agent framework guides with recorded fixtures. New integration pages for the OpenAI Agents SDK, LangGraph, Pydantic AI and LlamaIndex, each backed by a recorded OTLP fixture of a small agent run (one tool round trip, two model calls) through the framework’s own OpenTelemetry instrumentation:
openinference-openai-agents2.5.3,openinference-langgraph(LangGraph 1.2.16),pydantic-ai2.57.0 andopeninference-llama-index4.6.0. The fixtures are on the OTLP compatibility page and in the weekly matrix. - Search what the model said. The Traces search box (and
qonGET /api/v1/projects/{id}/tracesand exports) now also matches span content: with three or more characters it searches the end of each span’s input and the start of its output (2,000 characters of each) for spans in the selected time range. Apg_trgmindex (migration 1007, built concurrently) answers it. A search that cannot finish within 5 seconds, usually a common word over a long range, answers422 SEARCH_TOO_BROADwith a hint to narrow the range or the text, instead of a503the dashboard retried. The index is only usable under row-level security when the migration runs as a Postgres superuser (the bundled Compose and Railway setups do); otherwise content matching covers the last hour of the selected range, and names and ids still cover all of it. - Trace compare. Select two traces and choose Compare to see them step by step: spans are paired by name and order, with the change in duration, tokens and cost, whether the input and output changed, and steps that exist or failed on one side only. Also
GET /api/v1/projects/{project_id}/traces/compare?a=&b=. agentspan kind. OpenTelemetry GenAIinvoke_agentandcreate_agent, OpenInferenceAGENTand Traceloopagentspans are nowagentinstead ofchain, with their own colour and icon in the waterfall. Native ingestion accepts"kind": "agent". Spans stored earlier keepchain. Migration1006adds the enum value; Postgres cannot remove it, so the downgrade setsagentspans back tochaininstead.
Changed
Section titled “Changed”- Two unusable indexes dropped (migration
1009).traces_tags_idxandinsights_evidence_trace_ids_idxserve only@>and&&, which row-level security never lets Postgres turn into index conditions for the app’s role, so they were never read while every insert paid to maintain them. Tag filters keep running on the time window’s traces as before. Dropped concurrently, so ingestion keeps writing during the migration; downgrade rebuilds both. - Inputs and outputs are kept up to 1 MB each (was 32 KB). Long RAG contexts and agent histories are stored and shown whole; only a payload over 1 MiB of JSON (after redaction) is still cut to its first 1 MiB and marked
truncated. The Python SDK (0.2.1) keeps input and output strings up to 1,000,000 characters (was 32,000); attribute strings stay at 32,000, and a cut attribute now marks the spantruncatedtoo. Request limits are unchanged (5 MB per body, 1,000 spans per batch). Migration1008addsspans.payload_bytes, the stored size of a span’s payloads. - Trace detail returns at most 8 MB of payloads. Spans past that point in a very large trace come back with
payload_omitted: trueand no input or output; the newGET /api/v1/projects/{project_id}/traces/{trace_id}/spans/{span_id}returns one span whole, and the dashboard loads it when the span is opened. Long texts open at their first 20,000 characters and large JSON at its first 200 entries, with Show all. - Memory stays bounded with larger payloads. The gateway’s write backlog is also bounded by payload bytes (
GATEWAY_RECORD_BACKLOG_MB, default 128), and JSONL exports cut a batch at 64 MiB of payloads as well as at 2,000 spans. - Dashboard metrics read rollups for every window. The overview, time series and per-model views read whole, rolled-up hours from the hourly rollups and only the partial edges and the most recent hour or so from the spans, for windows of any length. A 24-hour dashboard on a project with 2 M spans a day loads about ten times faster. Counts, tokens and cost are exact (windows over 24 hours no longer count their first and last hour in full); p50/p95 are histogram estimates (
approximate: true) whenever a rollup hour is read, so they stay exact only for the last hour. The rollup job now rewrites the last three hours every five minutes and the rest of the last 48 hours about once an hour, so a span that arrives more than two hours late can take up to an hour to show in charts. - An overloaded api process answers at once instead of queueing. Past
API_MAX_IN_FLIGHTrequests in flight (default 64) on/apiand/v1, a request is answered503 SERVICE_UNAVAILABLEwithRetry-After: 1without touching the database; the SDKs back off on it./gwand/healthare not counted.API_POOL_TIMEOUT_SECONDSnow defaults to 1 second (was 5). - API keys are cached per api process for 10 seconds (
API_KEY_CACHE_TTL_SECONDS). The secret, revocation and expiry are still checked on every request against the cached row; a key revoked through another api process keeps working on this one for up to that long. A revocation on the same process takes effect at once, and0turns the cache off. - A lost database connection answers
503instead of500. The api no longer tests pooled connections before use, so after a Postgres restart the first request on each stale connection answers503 SERVICE_UNAVAILABLEwithRetry-After: 5and logsdb_connection_lost, once per restart. - Pages follow your role. A role that can read a page but not change it sees one view-only note and no write controls; a page it cannot read shows a no-access page on a direct link. A viewer opening an annotation queue no longer claims items it cannot score.
- Secrets are redacted in span and trace names as well as in payloads. Ids, tags and the release stay as sent.
- Implausible token counts are not priced. A span with more than 100,000,000 input or output tokens is stored with a
NULLcost, so one bad value cannot inflate spend, budgets and projections. - LLM judges answer on thinking models. Claude models that think by default get
output_config.effort: "low", the default judgemax_tokensis 1024 (was 256), and an answer cut off atmax_tokensfails asjudge_truncatedinstead ofjudge_no_answer.
- Python SDK: tracing no longer raises into your code or loses whole traces on bad values (Python SDK 0.2.1).
with spanlight.span(...)re-raises an exception whose__str__raises unchanged, instead of aRuntimeErrorfrom the SDK.set_usageignores counts that are not whole numbers from 0 to the server’s limit,record_erroraccepts any value, andset_attribute(s)andspan(attributes=)ignore what they cannot use, all instead of raising. Trace fields the server would reject, which made it drop every span of the trace, are made to fit:""is unset, non-strings become text,name,user_idandsession_idare cut to 256 characters,environmentto 64 andreleaseto 128,tags="beta"is one tag (it was four), and a trace keeps 20 tags of up to 64 characters, with one warning per kind of fix. A span that cannot be encoded as JSON is dropped alone and counted as failed, instead of losing its whole batch.wrap_anthropicandwrap_openaialso trace the clients thatwith_options(...)andcopy(...)return, andwrap_anthropictracesmessages.parseandclient.beta.messages(create,parse,streamand batch results), so fast mode, compaction and the tool runner record spans; they recorded none. A forked child that only callsflush()no longer waits on the parent’s in-flight batch, and an@observeasync generator left byasync for ... breakis no longer recorded as failed withCancelledErrorwhen the event loop shuts down. - LlamaIndex prompt-building spans no longer mark a trace as unpriced. LlamaIndex labels
OpenAI._prepare_chat_with_toolsan OpenInferenceLLMspan although it makes no model call. An OpenInferenceLLMspan with onlyinput.value(no messages and no token counts) is now stored as achainwith no model. - A session’s cost is marked as a lower bound when one of its traces has an unpriced call (
has_unpricedon the session list and a user’s recent sessions), as trace costs already were. - The release comparison no longer lists repeated provider errors as new. Long letter-and-digit ids, such as Anthropic request ids, are normalised like UUIDs before messages are compared.
- API edge cases. A pagination cursor holding a lone surrogate or NUL is a
422(was a500); OTLP JSONintValuethat is not an integer string is400 INVALID_OTLP(it was stored as 0); gateway paths with a trailing slash answer the provider-shaped404instead of a bare307. - Gateway cache. The cache key ignores
useronly on OpenAI surfaces, so a cached answer no longer serves an Anthropic request the provider would reject, and a non-200 answer reportsbypass, notmiss. - Dashboard. User-supplied names and ids are isolated from right-to-left overrides; sessions named
.or..open; money over $1,000 has thousands separators; calls with no provider (cache hits, refused calls) are labelled “No provider”; empty thinking blocks are hidden; a Doctor finding resolved by hand no longer claims “No recurrence for 24 hours”.
Upgrading
Section titled “Upgrading”Run spanlight migrate (or restart the stack) after upgrading, as a Postgres superuser (the bundled postgres user is one). Migration 1004 replaces the windowed span index with one that also carries usage_rollup, built CONCURRENTLY before the old one is dropped. 1005 sets fillfactor = 70 and tighter autovacuum on traces; it rewrites nothing and fails cleanly after 5 seconds if an autovacuum holds the table (run migrate again). 1006 adds the agent span kind (catalog only; earlier spans keep chain). 1007 adds the pg_trgm extension and the content search index, built CONCURRENTLY (about 24 minutes and 1.35 GB on a 4.2 M-span table); as a non-superuser it skips the LEAKPROOF marks and content search covers the last hour of a range only. 1008 adds spans.payload_bytes (nullable, catalog only). 1009 drops traces_tags_idx and insights_evidence_trace_ids_idx CONCURRENTLY; ingestion keeps writing, and the downgrade rebuilds them. Set SPANLIGHT_IMAGE and SPANLIGHT_WEB_IMAGE to the 0.7.0 images.
[0.6.0] - 2026-10-19
Section titled “[0.6.0] - 2026-10-19”The first tagged server release: images on GHCR, and every change recorded under 0.2.0 to 0.5.0 below ships in it.
Prompts, datasets and evals: manage prompts as versions, score traces by hand or automatically, and compare a change on your own test cases before it ships.
- Prompt registry. Named prompts,
textorchat, with immutable numbered versions and movable labels such asproduction. Templates use{{variable}}and are checked identically by the server, the dashboard and the SDK. A prompt can get 100 versions a day, saving an identical version is refused, and a prompt that an evaluator or experiment uses cannot be deleted. Each version shows its use over the last 7 days (traces, LLM calls, cost, p95 duration). - Prompts for your application.
GET /v1/prompts/{name}?label=or?version=with an API key that has the newprompts:readscope, answered with anETagso an unchanged prompt is a304. Spans can carry the id of the prompt version they used (prompt_version_id), Traces can be filtered by it, and trace detail links to it. - Playground. Run a prompt version or typed messages on up to three credential and model configurations side by side. Every run is a real provider call, recorded as a trace in the
playgroundenvironment; the playground has no spend cap of its own beyond the project’s blocking budgets, and is limited to 30 runs a minute per person. - Scores. Numeric, boolean and categorical scores on a trace or a span, from the API (
POST /v1/scores, scopescores:write, up to 1000 per request with per-score rejection), by hand on the trace page, or from evaluators. Score configs (type, range, categories) are optional: the first score with a name creates an implicit one. Traces can be filtered by score (score_namewithscore_min,score_maxorscore_value, andhas_scores), and the trace list shows each trace’s latest scores. - Annotation queues. Queue traces for review under Review. Reviewers claim the next item (an abandoned claim is freed after 30 minutes), give one score per config of the queue, skip or release it, and can work entirely from the keyboard.
- Datasets. Build a dataset from traces or upload
.jsonland.csvfiles (up to 5 MB and 10 000 items per dataset), with bad lines reported by line number instead of failing the file. Items can be edited, and the recorded output of a trace is offered only as a starting point for the expected output. - Evaluators. Score traces automatically: heuristic rules (
regex,json_valid,length,contains,exact_match,levenshtein), an LLM judge with four built-in templates (relevance,faithfulness,toxicity,format_compliance; every score is 0 to 1, higher is better) or your own chat prompt, and signed webhook evaluators. Online sampling runs every minute over traces that ended 30 seconds to 5 minutes ago, never samples Spanlight’s own traces (evals,playground,experimentsanddoctorenvironments, and judge calls), and queues each trace at most once. - Judge budgets. A judge needs a monthly budget and reserves the most a call could cost before it makes the call, under a lock, so concurrent runs cannot both pass the limit; a run that does not fit is marked
budget_exceededand sends no request. The budget covers sampled runs, tests and experiment use, and a meter shows spent, reserved and remaining. - Experiments. Replay a dataset through a prompt version or typed messages, score every answer, and compare two runs item by item with deltas, an improved and regressed count, cost and p95 duration. A run can override the prompt version, so one experiment compares versions of a prompt. A cost limit reserves the estimate of every call in flight, so a run never starts an item that could pass it.
- Eval gate. The
eval-gateGitHub Action andpython -m spanlight._experimentsrun a prompt over a dataset in CI and fail the build when a mean score falls below a threshold. - Demo.
spanlight demo seed-evalscreates a prompt with two versions, a 50-item dataset from the demo’s recorded traffic, a budgeted judge and a two-run experiment. - Screens and navigation. Prompts, Playground, Datasets, Review, Evaluators and Experiments under a new Improve group in the sidebar (
G P,G L,G N,G Q,G E,G X), scores on trace and span detail, and a Scores column on Traces. - Roles and audit. Members can create prompts, score configs, datasets, annotation queues and experiments, add scores and start experiment runs; evaluators are admin and owner only, because a judge spends money unattended. The main changes to them are recorded in the audit log (
prompt.*,score_config.*,dataset.*,annotation_queue.*,evaluator.*,experiment.*). - Operations. New Prometheus metrics (
spanlight_scores_written_total,spanlight_prompt_reads_total,spanlight_evaluator_runs_total,spanlight_evaluator_cost_usd_total,spanlight_experiment_items_total) feed an Evaluations row on the Grafana dashboard. Prices includeclaude-haiku-5-5, the model the demo uses. - Prompt-cache writes and prompt-size tiers are priced. Anthropic bills a cache write at 1.25 times the input rate for a 5-minute cache and 2 times for 1 hour, and bills Claude Haiku 5.5 prompts over 100,000 tokens at a higher row of rates; Spanlight priced both as plain input, up to 80 % under the invoice on real calls. A span’s usage now carries
cache_write_tokensandcache_write_1h_tokens(the part written for 1 hour), both optional, withinput_tokensstill the whole prompt. The Python SDK (Span.set_usage(..., cache_write_tokens=, cache_write_1h_tokens=), filled bywrap_anthropic; the attributeusage.cache_creation_input_tokensis no longer set), the gateway (Anthropic answers and streams, andspanlight.cache.original_usage) and OTLP (gen_ai.usage.cache_creation.input_tokens, or the oldergen_ai.usage.cache_creation_input_tokens; OpenTelemetry has no lifetime split, so OTLP writes are priced as 5-minute) all report them, and OTLP also reads cache reads fromgen_ai.usage.cache_read.input_tokens, the name current instrumentations and LiteLLM use, before the oldergen_ai.usage.cache_read_input_tokens. Price table2026-10-15adds the write rates to every Anthropic row and a secondclaude-haiku-5-5row for prompts over 100,000 tokens (min_prompt_tokens100001); the2026-10-01table is kept beside it so older spans price as before.GET /api/v1/pricesand price overrides carrycache_write_5m_per_mtok,cache_write_1h_per_mtokandmin_prompt_tokens; an override is one tier, so overriding the base row of a tiered model leaves the built-in higher tier in force. Invalid combinations reject the span (cached_tokens + cache_write_tokensoverinput_tokens,cache_write_1h_tokensovercache_write_tokensor sent alone). The span Usage card shows a Cache writes row with the 1-hour part. Migration0508; its downgrade deletes the tier rows and drops the write rates, and an upgrade after it leaves the surviving built-in rows without write rates, so read the upgrade guide before rolling back below it. - Service tiers are priced. A span records the tier the provider billed (
standard,batch,priorityorflex) inservice_tier, and its token cost is multiplied by the tier’s factor from the price snapshot: batch at half price for Anthropic, OpenAI and Google, OpenAI flex at half, Anthropic priority at the standard rate and marked commitment billed. OpenAI priority has no single factor (OpenAI publishes a rate per model), so it is priced at the standard rate and marked unmodelled. Native JSON takesusage.service_tierand refuses any other value; the Python SDK, the gateway and OTLP (openai.response.service_tier,gen_ai.openai.response.service_tier,spanlight.usage.service_tier) map the provider’s own spelling and keep one they do not know inusage.service_tier_raw, priced as standard. Data residency (inference_geo) and fast mode are not priced yet: the SDK and the gateway price such a call at the standard rate and setusage.pricing_modifier_unknown. - Web search fees are priced. Anthropic and OpenAI bill $10.00 per 1,000 web searches. A span counts them in
web_search_requests(Anthropicserver_tool_use.web_search_requests, the completedweb_search_callitems of an OpenAI Responses answer,usage.server_tool_use.web_search_requestsin native JSON,spanlight.usage.web_search_requestsover OTLP), and the fee is added tocost_usdand reported again in the newfee_usd. A fee is kept even when the token cost is unknown, without becoming the span’s cost. - Streams cut before their usage are unknown, not cheap. A stream that ends without the provider’s final usage event, from the SDK or the gateway, keeps its input counts, stores
output_tokensand the cost as unknown and says why inusage.unreported_reason(client_disconnected,stream_error,stream_cut_by_fault,include_usage_not_setorno_final_usage). The trace counts the span as unpriced. - Cost explanation. Every model call’s span has a Cost breakdown on the trace page, and
GET /api/v1/projects/{project_id}/traces/{trace_id}/spans/{span_id}/cost-explanationreturns it: one line per token class and fee with its quantity, rate, tier factor and amount, the price row that was used, and a status (ok,mismatch,no_usage,no_price,price_version_missing,override_deleted,cache_hit) that compares the rebuilt figure with the stored one. It never prices the span again with today’s table. Readable by members and by API keys withtraces:read. spanlight prices diffcompares the bundled price snapshot with LiteLLM’s public price file and lists rates that differ, models priced only here, and chat models Spanlight does not price yet, with a proposed row for each. It changes nothing; every figure is checked against the provider’s page before it goes into a snapshot.- Price table
2026-10-19: OpenAI coverage. Adds the GPT-6 and GPT-5.x families, the o-series,chat-latest,gpt-4.1-nanoand the legacy OpenAI models, 56 OpenAI rows in all, with a second row for prompts over 272,000 tokens where OpenAI bills one. GPT-5.6 and later bill cache writes at 1.25 times the input rate: the Python SDK (wrap_openai) and the gateway now record OpenAI’scache_write_tokens, so those writes are priced instead of counted as plain input.gpt-4o-2024-05-13is priced by its own row; it was priced at thegpt-4orate, half what OpenAI bills. The cost breakdown labels the default cache-write line “Cache write”. - Price table
2026-10-16. Adds Gemini (google) prices, with a second row above 200,000 prompt tokens for the Pro models, Claude Fable 5.1 and Mythos 5.1, Claude Fable 5 and Mythos 5, Claude Opus 4 and 4.1, Claude Sonnet 4 and Claude 3.5 Haiku, and carries the service-tier factors and the web search fees. It corrects the Claude Sonnet 5.5 cache-read rate to $0.10 (it was $0.20); spans priced under2026-10-15keep their stored cost. The Prices page lists the tier factors and fees. - Provider aliases for pricing. A span’s provider is folded onto the name the price table uses before the price is looked up:
gemini,vertex_aiand the other Google spellings find thegoogleprices, andclaudetheanthropicones. A price override saved under any spelling (gemini,vertex_ai,google) matches spans sent under every spelling of that provider.azureandazure_openaiare not folded ontoopenai, because Azure bills its own rates: an Azure span stays unpriced unless an override for its provider name prices it. The stored provider does not change. - Batch results in the Python SDK.
wrap_anthropicrecords the results you read withmessages.batches.results, and the newspanlight.record_openai_batch_output(lines, batch_id=...)records an OpenAI Batch output or error file. Each request becomes anllmspan at the batch rate in a trace namedbatch <batch_id>; the ids come from the batch and request ids, so reading the results again updates the same spans. - Billing sources. Owners and admins can connect a provider account to an organization under
/api/v1/orgs/{org_id}/billing-sources: an Anthropic Admin API key, an OpenAI admin key, or a CSV source, mapped to the projects it pays for (one account per provider per project). The admin key is encrypted withCREDENTIALS_KEYS, checked once with the provider when it is added or replaced, and never returned, logged or audited;spanlight reseal-credentialsre-seals it with the provider credentials. A new permission,billing_sources:manage, covers the changes; any member can read the sources. - Reconciliation with the provider’s bill. A source with an admin key is fetched every day from 03:30 UTC (the last 7 days, or 31 on its first fetch) and can be refreshed on demand once every 5 minutes (
POST …/billing-sources/{id}/refresh). A CSV source takes uploads in a documented ten-column format (POST …/upload, at most 5 MB and 100,000 rows; a refused file lists up to 20 reasons by row and column and stores nothing; each upload replaces the provider’s figures for every day from its first to its last).GET …/billing-sources/{id}/reconciliation, also as.csv, compares the provider’s amount with Spanlight’s figures for the mapped projects per UTC day and model group, over up to 31 days: the signed residual, the explained lines (batch spend, web search fees, untraced traffic, Anthropic priority commitment, unpriced calls, unreported usage, unmodelled pricing modifiers, spans past retention), credits and charges Spanlight cannot see listed apart, and days marked provisional until 48 hours after they end and a later fetch. Spanlight’s side comes from new daily cost rollups, recomputed every 15 minutes for the last three days and nightly for the last eight, never from a scan of the spans.GET …/runslists the fetches and uploads. The guide “Reconcile cost with your provider bill” covers keys, scopes, project mapping, reading the report and the CSV template. - Provider health. A new project page, after the Doctor, tells a provider’s outage or limit from your own bug or the gateway’s limiter. Per provider: LLM calls, error rate, the share of failed calls the provider refused with a 429, and the most common kind of refusal; failed calls per hour or day by error class; the rate-limit kinds with how many carried a
retry-after; what retries cost; the 20 newest failed calls with the provider’s error type, code and request id; and the incidents on the providers’ own status pages. The API isGET /api/v1/projects/{project_id}/providers/health,…/providers/errors/timeseriesand…/providers/errors/samples, readable by every role. - Provider error fields and rate-limit kinds. Every failed span records the provider’s error type and code (
provider_error_type,provider_error_code) and, for a capacity refusal, which limit refused it (rate_limit_kind):requests,tokens,spend_cap,acceleration,overloaded,gateway(the gateway key’s own limit, never the provider’s) orunknown. The kind comes from the provider’s documented error types, codes and messages, with its rate-limit headers as a last resort; a successful span never gets one. The carriers are span attributes (provider.error.type,provider.error.code,provider.request_id,provider.retry_after_s,provider.ratelimit) that the gateway and the Python SDK set and any client may send; the server filtersprovider.ratelimitthrough an allowlist of 29 rate-limit headers, each value at most 64 printable characters, so no other header is ever stored. OTLP spans get a kind from the status message. Spans stored earlier keep these fields empty. - Gateway attempt log and rate-limit headers. A gateway span records the status the gateway answered with (
spanlight.gateway.status), its own error code when it made the error (spanlight.gateway.error_code), the provider’s request id and rate-limit headers (all of them on a failure, the limit and remaining ones on a success), andspanlight.gateway.attempt_log: one entry per attempt with the target, credential name, status, error, duration andRetry-After, at most 10 (the first 9 and the last). Nothing new is read from the database on the gateway’s path. - Retry cost. Traces carry
retry_callsandretry_cost_usd: within a trace, every call of the same request but the last is a retry, and its cost is retry cost. The cost is unknown, never zero, when a retried call has no price, and both are unknown for traces stored before this release until a span of theirs is sent again. Traces can be filtered withhas_retriesand sorted withsort=retry_cost_usd; the trace list has a Retries column and the trace header a retries line. - Provider status page incidents. The worker polls the status pages of Anthropic, OpenAI and Google (Vertex AI and Gemini) every 5 minutes, from fixed addresses, and keeps their incidents for two years.
GET /api/v1/projects/{project_id}/provider-incidentslists those that overlap a window, with each feed’s poll state; the Overview’s calls and errors chart can shade the incidents of the providers you used, and the Doctor’sprovider_incidentfinding names the status page incident that overlaps it. An incident only explains: it never opens, closes or silences a finding or an alert. Air-gapped servers switch polling off withPROVIDER_STATUS_POLLING_ENABLED=false. - Telemetry completeness. Spanlight counts the spans lost before they were stored: gateway spans its recorder could not keep (
overflow,error,rejected), spans ingestion rejected, by reason, and spans the Python SDK dropped or could not deliver, which it now reports.GET /api/v1/projects/{project_id}/telemetry-completenessand a new Overview card give the share of spans that were stored, as a lower bound. - “Was this right?” on Doctor insights. Members, admins and owners can mark an insight right or wrong, with an optional note (
PUTandDELETE /api/v1/projects/{project_id}/insights/{insight_id}/feedback, new permissioninsights:feedback). The Doctor’s new Precision tab andGET …/insights/precisionshow, per detector, the votes, the precision and its Wilson 95 % interval, and whether it meets the target of 0.8 with a lower bound of 0.6 on 20 votes;spanlight doctor precisionprints the same table across every project for operators. - Alert rules on error classes and kinds. A new metric,
error_calls, counts failed LLM calls, anderror_rateanderror_callsaccept the filterserror_classandrate_limit_kind, so a rule can watch, for example, anyspend_caprefusal. - Fall back on a rejected key, if you choose. A gateway route can list
auth_errorinfallback.onto move to the next target when the provider answers 401 or 403. It is off by default, because another credential answering for a rejected key hides a revoked key, and it needs two targets on different credentials. - The Doctor’s accuracy harness is in the repository.
backend/accuracy/holds the scenarios and scoring that measure the provider-path detectors against planted problems; its offline tests run in CI, and a live run is manual because it makes paid provider calls. - Open core. Code under directories named
eeis under the new Spanlight Enterprise License and runs only with an offline-verified license key inEE_LICENSE_KEY; everything else stays MIT, and nothing that was MIT has moved. The directories hold no features yet. Without a key the application runs as the community edition; a key that does not verify stops startup, an expired one turns its features off (403 EE_LICENSE_REQUIRED), andspanlight license-statusshows what a key grants. See ADR 16. - Trace metadata. A trace can carry up to 32 key-value pairs, such as a customer, a feature or a team:
trace.metadatain native JSON (strict: an invalid entry rejects the span with a reason naming the key),spanlight.metadata.<key>span or resource attributes over OTLP, thex-spanlight-metadataheader on gateway calls, andupdate_trace(metadata=)in the Python SDK (the last three drop an invalid entry and keep the span). Keys are 1 to 64 characters ofa-z,0-9,_,.and-; values 1 to 256 characters, numbers and booleans stored as text. Values merge per key, the last one winning, a key the trace has can always be updated, new keys are added in code-point order while there is room, and nothing is ever deleted, so a replay changes nothing. Values are redacted like payloads. The trace list shows a chip per promoted key, filters bymetadata_keyandmetadata_value(the key alone finds the traces without it) and by exactname, and the trace header lists every pair;GET .../filterslists themetadata_keysandtrace_namesof the last 7 days. - Attribution. A new project page (
G C) andGET /api/v1/projects/{project_id}/attributiongroup spend, LLM calls, errors, unpriced calls, tokens, retries and retry cost by a metadata key, trace name, end user or release, with the traces that lack the dimension (none) and the window’s totals on every page; the rows always add up to the totals. A window of 24 hours or less is read live from the traces for any key; a longer one comes from daily rollups, refreshed every 15 minutes, which keep up to 1,000 values per UTC day, environment and key and fold the rest intoother. Admins promote up to five keys in project settings (promoted_metadata_keys); a new key is backfilled over the last 7 days, and an unpromoted key over more than 24 hours is422 KEY_NOT_PROMOTED. Each row links to its traces.spanlight attribution backfillrebuilds older days. - Attribution export.
POST .../exportswith"kind": "attribution"writes the attribution table of a window as CSV, through the same queued export flow as traces. Trace exports gain the metadata filter, and trace files ametadatacolumn (CSV) or object (JSONL). - Budgets on a metadata pair or a trace name. Budgets take the scopes
metadata(scope_idiskey=value, such ascustomer_id=acme) andtrace_name, andscope_idmay be 400 characters. A blocking budget matches a gateway call by itsx-spanlight-metadataand newx-spanlight-trace-nameheaders, and for a call that joins a trace, by the stored trace when a header is missing (best effort until the trace’s first span is stored). The key does not need to be promoted. - OTLP reads OpenInference, the Vercel AI SDK and Traceloop. Besides the OpenTelemetry GenAI attributes (
gen_ai.*, also written by LiteLLM),POST /v1/otlp/tracesnow reads OpenInference (llm.*,input.value), the Vercel AI SDK’s own attributes (ai.*, fromai5 and 6 and the legacy telemetry of 7) and Traceloop’s workflow spans (traceloop.*): provider, model, token counts, prompts and completions (flattenedllm.input_messages.*andgen_ai.prompt.*messages are rebuilt into lists of at most 256 messages), kind, finish reason, time to first token, trace name and trace metadata. The convention is detected per span and stored as the attributespanlight.convention, the convention’s own kind asspanlight.operation; a span that mixes conventions has its gaps filled from itsgen_ai.*attributes. Every content attribute is removed from the stored attributes, read or not, so prompt text follows the payload capture setting. An embeddings call that reports only input tokens is priced with 0 output tokens and markedusage.output_tokens_implied; a model call whose exporter sent no token count is markedusage.unreported_reasonnot_exported. The Prometheus counterspanlight_otlp_convention_spans_total{convention}counts accepted spans per convention. - Usage is counted once. When a span with token counts has a descendant in the same trace with token counts too, as an agent step over its model call or the Vercel AI SDK’s
ai.generateTextoverai.generateText.doGeneratedoes, the outer span is a usage rollup (usage_rollupon the span): it keeps everything it was sent, and every total of tokens and cost leaves it out, from trace totals to the Overview, alerts, budgets, the Doctor, Users, prompt usage, attribution and reconciliation. Counts of spans, calls and errors still include it, a rollup is never a retry, and its cost explanation has the statususage_rollup. The rule reads stored spans only, so it holds for the Python SDK, the gateway and OTLP alike and whatever order spans arrive in. The trace page marks such a span “Counted on child spans”, and JSONL trace exports carryusage_rollupon each span. - OTLP compatibility page and exporter guides. The docs gain a page generated from the fixtures that lists, per library, version and scenario, which fields Spanlight captures and which the exporter does not send, and guides with the tested setup for the Vercel AI SDK, LiteLLM, OpenLLMetry (Traceloop) and OpenInference.
spanlight recompute-usage-rollups --since 30dflags the usage rollups of traces stored before the upgrade, and retotals them. Without it, an older trace is corrected only when a new span of it arrives.- Usage state. Spans in the trace API carry
usage_state:priced,rollup,usage_not_reported,partial_usage,model_unknown,no_priceornot_applicable. The span usage card uses it to say why a cost is unknown, for example “The exporter sent no token counts”. - Recorded exporter fixtures. What each supported exporter library and version sends is recorded as a fixture under
sdks/fixtures/otlp/and replayed through the mapper by the test suite, against a local fake provider with no provider call or key. A weekly workflow records the latest releases, diffs them with the fixtures and keeps one issue labelledotlp-matrixcurrent when they differ. Recorded so far: the OpenTelemetry OpenAI instrumentation 2.4b0, LiteLLM 1.105.1, OpenInference for OpenAI 0.1.65 and LangChain 0.1.79, Traceloop 0.62.4, and the Vercel AI SDK 5.0.0, 6.0.0 and 7.0.142. - Daily review. A review policy (one per project, under Review) fills an annotation queue every UTC night from 01:00 with the traces of the previous day worth reading, each with its reason: an open Doctor finding, an error, a cost outlier (above the p99 cost of its trace name over 7 days, from 30 priced traces) or a random share of up to 5 %. A daily cap (1 to 500) keeps a quarter of its places for random picks, picks are deterministic and never duplicated, and a completed day can be selected by hand from yesterday back to 7 days. Queue items carry
reasonandreason_detail(numbers and ids, never payload text), queues show progress per reason and filter by it, and the annotate screen says why each trace is there, withRto replay its failing span andDto save it as a dataset item that records why it was kept. Routes:GET,PUT,DELETE /review-policy,POST /review-policy/runs,GET /review-policy/selections. New problem codes:QUEUE_LACKS_FAILURE_CATEGORY,NOTHING_SELECTED,DAY_NOT_COMPLETE,DAY_TOO_OLD,ALREADY_SELECTED, andQUEUE_IN_USEwhen a policy’s queue is deleted. - Failure categories. A failure category is a categorical score named
failure_category, created withPOST /score-configs/failure-category(2 to 30 categories,nonerequired) from an editable starting list. A review policy’s queue must ask for it.GET /releases/failure-categoriesand a card on Releases show each category’s rate per release over the reviewed traces, with Wilson 95 % intervals; a release nobody reviewed has no rate rather than 0 %. Readable with a project API key that hastraces:read. - Exact replay. Send a span’s stored request to the provider again from the span panel, the annotate screen or the playground, and compare the recorded and the new answer. SDK and gateway spans replay exactly; OTLP spans, which store only messages, replay as “not exact”. A replay is never streamed, sends
n = 1and at most 4096 output tokens, refuses another provider’s credential instead of converting the request, and refuses redacted, truncated or missing payloads with a reason (REPLAY_*codes); the request can be edited first. “Identical request” is shown only when the request hashes match. Replays run under a per-project monthly budget, reserved before each call, and are recorded as traces namedreplayin theplaygroundenvironment. Routes:GET …/spans/{span_id}/replay-source,POST /playground/replays,GET /playground/replays,GET /playground/replays/budget. New problem codes:REPLAY_BUDGET_EXCEEDEDand the eight replay refusals. - Honest uncertainty. Yes/no scores of an experiment run carry the number of items that passed and a Wilson 95 % interval on the pass rate (“78% (95% CI 65–88%, n = 50)”). Comparing two runs adds an exact McNemar test for yes/no scores and a paired sign test for numeric scores, with a plain sentence saying whether the difference is significant at 0.05 and what “not significant” means. The eval gate’s report gains a
95% CIcolumn; the gate itself still compares the mean. - Review loop metrics.
spanlight_review_selected_total{reason},spanlight_replay_runs_total{outcome},spanlight_replay_cost_usd_totaland six newspanlight_feature_used_totalvalues (review_selection,review_item_completed,failure_category,replay,dataset_item_from_review,experiment_interval) feed a Review loop row on the Grafana dashboard.
Changed
Section titled “Changed”- A new error class,
gateway. A call Spanlight’s gateway refused or failed by itself (a blocking budget, a blocked upstream address, a busy database, an internal error) is classgatewayinstead of being classed by its status, so Spanlight’s own 500 and 503 are no longer counted as the provider’s 5xx by the Overview, the Doctor or alerts. An error the gateway made takes its class from its code: a malformed request isclient, a gateway key’s rate limitrate_limit. Anthropic’s 400 for a spend limit you set yourself israte_limit, notclient. Spans stored earlier keep their class. - Calls refused by a gateway key’s rate limit are recorded as spans. They have no model or input, because the gateway never read the body, and the kind
gateway. They are dropped first when the gateway’s span recorder is busy (and counted as lost), they do not count in the key’s per-minute usage, and the Doctor’serror_spikeandrate_limit_pressureleave them out. - The gateway passes Anthropic’s
anthropic-priority-*response headers to the client, like the other rate-limit headers. - The
prompts:readandscores:writekey scopes now do something. They were accepted but reserved. Existing keys keep their scopes, so a key made for sending traces cannot read prompts or send scores: create a new key with the scope. - Native JSON ingestion accepts
prompt_version_idon a span. Older servers ignore it. OTLP has no field for it. - Trace summaries carry
has_scoresand a few latestscores, andGET .../traces/filterslistsscore_names. - Dataset uploads may be 5 MB. Every other
/api/v1body is still limited to 1 MiB; the limit for the upload route is checked while the body streams. - The landing page covers the whole product. A new “Cost you can audit” section presents the cost breakdown and usage states, reconciliation with the provider’s bill, and attribution with budgets; “Diagnose and improve” presents the Doctor and provider health, the gateway, prompts and replay, evals with confidence intervals, and the daily review. The hero shows a drawn trace beside its cost breakdown, computed from the real claude-sonnet-5-5 rates of the 2026-10-16 price table and captioned as an illustration, instead of an empty frame waiting for a screenshot; the screenshot pipeline (
frontend/scripts/capture-landing.ts) is removed. The “works with” row lists the OTLP conventions Spanlight reads (OpenInference, Vercel AI SDK, OpenLLMetry, LiteLLM), the nav and footer link the new sections and the cost and attribution guides, and a test checks that every documentation link points at a page that exists. - Possibly breaking: model names are priced case-sensitively, and surrounding whitespace is stripped at ingest. A span’s
modelis stored without the whitespace around it (" gpt-4o "becomesgpt-4o, a whitespace-only name becomesnull), and its case is kept. Pricing now matches the stored name exactly, as alert rules, rollup filters and budgets already did, so a model sent in another case than the provider’s id (GPT-4o,Claude-Sonnet-4-5) costsnull(“—”) and is listed among the project’s unpriced models instead of being priced while budgets and alerts ongpt-4omissed it. Send the provider’s own model id to keep costs. - OTLP keeps a span whose resource or token values do not fit. A
deployment.environmentover 64 characters or aservice.versionover 128 is dropped instead of rejecting every span of the service; a token count sent as a whole double (12.0) is stored as12, and one with a fraction, NaN or an infinity is dropped (the span is kept with the count unknown and its costnull). Oneotlp_values_droppedwarning per batch logs how many values were dropped, never the values. Native/v1/tracesstill rejects such spans. - SDK:
usage.missing_reasonis renamedusage.unreported_reason. A streamed OpenAI Chat Completions call withoutstream_options={"include_usage": True}now carriesusage.unreported_reasonset toinclude_usage_not_set, instead ofusage.missing_reasonset tostream_options.include_usage not set. Update any filter or dashboard that reads the old attribute. - Possibly breaking: the Python SDK records Azure clients as
azure_openai.wrap_openaiused to record anAzureOpenAIorAsyncAzureOpenAIclient asopenai, so its spans were priced at OpenAI’s list rates. It now recordsazure_openaiunlessprovider=is passed, and such spans have no cost until an organization adds a price override forazure_openai, because Azure bills its own rates. - A blank provider is stored as unknown. The provider of a span is stored without surrounding whitespace, and an empty or whitespace-only provider as
null, on every ingestion path; native JSON answered an empty one with422. - Spans carry
service_tier,web_search_requestsandfee_usdinGET .../traces/{trace_id}; the Usage card shows the tier, and the playground’s run usage may have an unknown output count. - Blocking budgets say they are a soft limit. The gateway’s
402 BUDGET_EXCEEDEDmessage now ends with(soft limit, measured every 60 s), the budgets page labels a blocking budget Blocking (soft), and the dialog explains why: spend is measured every 60 seconds, so calls keep going through until the next measurement. On 2026-10-14 a $0.002 budget started blocking after 85.5 s, with spend at $0.0122, six times its amount. Nothing about how blocking works has changed; theactionis stillblock. - Gateway traces are named after the model you asked for. A gateway call that starts a new trace names it
"<surface> <model>"with the model in your request, before route aliases and before the provider’s answer, sochat_completions gpt-4.1-ministays that even when the provider answers with a snapshot. The span keeps the answered model. Traces stored earlier keep their names. - A repeated
x-spanlight-*header counts by its last value, documented now for every header, the two new ones included. - OTLP names dropped metadata in
partialSuccess. A batch that droppedspanlight.metadata.*entries answers withrejectedSpansat0and a message counting them. Other dropped values are still only logged. 422 UNKNOWN_SCOPEhas a new detail for every budget scope,The budget's scope_id does not name anything its scope can measure., and a field message that says what the scope expects.- The
project.updateaudit event records the promoted keys it replaced underprevious, next tochanges. - Possibly breaking: OTLP
gen_aispans for tools and agents are no longerllm. A span whosegen_ai.operation.nameisexecute_toolis now kindtool, and one withinvoke_agent,create_agentorinvoke_workflowischain, so they no longer count as LLM calls;retrievalisretrieval, and any other operation that is not a model call isother. Agen_aispan without the attribute is stillllm. Spans stored earlier keep their kind. - Trace totals leave usage rollups out. A trace’s tokens and cost are the sum of its billable spans, so a trace from an agent framework that reported a call twice now costs what the call cost, and may be lower than the sum of its spans’ costs.
- OTLP error classes read
http.response.status_codeanderror.type. The stable status attribute is read beforehttp.status_code; anerror.typeof one to three digits is a status, and an exception class name in it (RateLimitError,PermissionDeniedError,ContextWindowExceededError,BadRequestError) is matched before the status message. ABadRequestErrorstill yields to a message that names a more specific cause. - More finish reasons are recognized. Vercel AI SDK
content-filter,tool-calls,errorandunknown, Geminisafety,recitation,blocklistandmalformed_function_call, and the OpenTelemetry GenAI singulartool_callthat Traceloop sends, fromai.response.finishReason,llm.response.finish_reason,gen_ai.response.finish_reason,gen_ai.completion.0.finish_reasonandllm.finish_reason.response.finish_reasonsandgen_ai.response.finish_reasonssent as text holding a JSON array, as LiteLLM does (["stop"]), are read as the list.
- A busy database answers 503, not 500, inside a gateway call. A pool or statement timeout while the gateway served a call (during the budget check, for example) answered
500 INTERNAL_ERRORand logged a traceback. It now answers503 SERVICE_UNAVAILABLEwithRetry-After, which the official SDKs retry, as the same failure while checking the key already did, and logs a warning. - A gateway call with a body that cannot be forwarded as JSON (
NaN) has a classified span. It is answered400 INVALID_REQUEST, and its span, which had no status and the classunknown, now records the status and the classclient. - A gateway span whose write fails is retried instead of lost. A span the database refused was dropped after one try, so a paid call could leave no span and recorded spend (and the budgets evaluated on it) came out low. A write that fails for a passing reason (a lost connection, a timeout, a deadlock) is now retried after 0.1, 0.5 and 2 seconds, and the write backlog (
GATEWAY_RECORD_BACKLOG) grows from 1000 to 5000; a waiting span holds its input and output already redacted and cut to 32 KB, so a waiting span’s payloads are at most 64 KB of JSON as stored (the memory it takes in the process is larger, so size the backlog by what the api can spare). A span still not stored is counted inspanlight_gateway_record_failures_totalas before and in the newgateway_spans_lostfield of/health/ready, which never makes the probe fail. A span whose input or output cannot be prepared for the queue is counted the same way instead of only being logged. - A gateway call whose body holds NUL, a lone surrogate, NaN or Infinity keeps its span. Plain
json.loadsaccepts all four and Postgres stores none of them, so the call was answered but its span was refused (and, for a lone surrogate, the call itself failed with a 500). The span now stores the body with NUL removed, each lone surrogate replaced by?and each non-finite number as its name; a lone surrogate is forwarded as the\uXXXXescape the client sent; and amodelholding NUL or a lone surrogate is400 INVALID_REQUESTinstead of a 500. A span write the database refuses outright is no longer retried for 2.6 seconds, and its log line names the error type and SQLSTATE, never the message. - A Lab
truncated_streamcall is recorded as failed. The client still gets the first frames and a clean end of stream without the final event, but the span, which saidok, now has statuserrorand the message200 FAULT_TRUNCATED_STREAM: …, like amalformed_jsoncall. A stream too short to cut passes whole and staysok. - A gateway call cancelled while the provider was answering now shows the attempt it made. Its span reported
spanlight.gateway.attempts0 and no provider, so a request the provider had received, and may bill, left no trace of the attempt. The span now lists it, andspanlight_gateway_attempts_totalcounts it ascancelled. - A card number next to other digits is redacted. A card followed or preceded by a CVV, an expiry or a quantity (
4242 4242 4242 4242 123,qty 2 4242424242424242) was read as one longer number that fails the Luhn check, and was stored whole. Card-shaped runs (groups of four or more digits, the last of two or more, separated by single spaces or dashes) are now searched for 13-19 digit cards made of whole groups; the neighbouring digits are kept, and lists of small numbers, dates and phone numbers are left alone. A card typed in groups of one to three digits is not detected. - An ingestion client that disconnects mid-body gets
400 BAD_REQUEST(“The request body was not fully sent.”) on/v1/traces,/v1/otlp/tracesand/v1/scores, and the gateway answers400 INVALID_REQUEST, instead of being logged as an unhandled server error. - A gzip or deflate body that is cut short is refused with
400 BAD_REQUEST(400 INVALID_REQUESTon the gateway) instead of being accepted when the part that arrived happened to parse. An empty body sent as gzip or deflate gets the same answer; on ingestion it was400 INVALID_JSON. - Evaluator run and test scores send
valueas a JSON number, as trace scores and the documented contract already did; it was a decimal string ("0.8"). - A dataset upload full of unparseable lines reports at most 100 rejected lines, like a file of invalid items already did; every bad JSON line used to be listed, so a 5 MB file could produce a response many times its size.
- Rolling back below migration 0505 with evaluator scores no longer blocks the next upgrade. The downgrade now clears
scores.evaluator_run_idbefore it drops the evaluator runs. - Migrations validate every row of a table under row-level security. Run as the table owner, a migration validated a foreign key with a query that row-level security filtered, so migration 0505 could mark
scores.evaluator_run_idvalid while a score pointed at a missing run. EveryVALIDATE CONSTRAINTnow runs with the worker bypass set for its own transaction, and such a dangling reference fails the upgrade. - SDK:
Prompt.compileaccepts a variable value holding a lone surrogate (asos.fsdecodeproduces), and a fallback template holding one is anInvalidTemplateError(PromptUnavailableErrorfromprompts.get) instead of aUnicodeEncodeError. - SDK: a
Retry-After: nanorinfheader is ignored and the exporter waits its normal backoff instead of retrying at once. - Starting a review no longer ends the session while items are pending. Opening work mode sent two claims at once (the page mounts twice while the app switches to its focus layout), the second found the first one’s item locked and the page said “All done”. The app now picks its layout from the route it is about to show, so the page mounts once, and a remounted page waits for the claim already on its way.
- The new-experiment form says when a list fails to load. A failed dataset list left the field loading forever and a failed credential list showed an empty select; datasets, credentials, prompts and evaluators now each show “Couldn’t load the …” with Try again.
- Release pickers stop loading when the release list fails: both selects are disabled and say “Couldn’t load releases”.
- No more Content-Security-Policy error in the console on every page. zod built schemas before its no-
evalsetting applied; the setting now loads with zod itself. - The dataset upload preview counts only what the server will store. A bare carriage return outside quotes, or a cell over 65,536 characters, stops the server’s CSV reader; the preview now marks that line “unreadable record; the rest is skipped” and counts nothing after it.
- Email channels accept internationalized addresses such as
ops@例え.jp, which the API already accepted. - A free-text categorical score of up to 200 characters is accepted when it holds emoji or other characters outside the Basic Multilingual Plane; the form counted UTF-16 units instead of characters.
- Experiment comparisons never show “+0.0000” or round a pass rate up to “100%”. A score change below 0.0001 reads “+<0.0001”, and a yes/no pass rate rounds down, so “100%” means every item passed. An evaluator’s budget meter rounds down the same way, so it reads “100%” only once the budget is used up.
- Accessibility: the audit log’s filter pills, the Lab probability slider and the dataset file picker have accessible names.
- The landing page no longer captions a missing screenshot as “A real screen from the live demo”.
- Layout fixes at 375 px and on short desktop windows. On a phone the account pages’ “Back to app” becomes an icon button instead of sliding under the theme toggle, the span detail tabs wrap into two rows instead of overlapping, a trace’s start time wraps instead of being cut off, and gateway KPIs shrink together to fit instead of showing “100. …”. In the sidebar only the page list scrolls, the current page is scrolled into view, and fades show when more pages are above or below.
- Users figures count spans that arrive late. The per-user figures were recomputed for yesterday and today only, so a span that arrived later for an older trace never reached them. Every 15 minutes the job now recomputes the last three UTC days, and a nightly pass (from 02:00 UTC) the last eight. The Users pages show when the figures were last updated (“Updated 4 min ago”), and the API returns it as
computed_at.
Security
Section titled “Security”- The web image’s Caddy is rebuilt from Caddy 2.11.7 with Go 1.27.2 and
golang.org/x/net0.60.0: the officialcaddy:2.11-alpinebinary (Go 1.26.8, x/net 0.59.0) carries CVE-2026-78667, CVE-2026-78669 and CVE-2026-97031, which failed the release scan. - A judge’s input is passed as marked data and the model is told to treat it as data, which reduces and does not remove prompt injection. Scores are advice, not proof; this is stated in the documentation.
- Judge spend is bounded by a reservation per evaluator rather than a check after the fact, and an unpriced model never runs.
- Webhook evaluators use the same vetted client as alert webhooks (https only, no private addresses unless
WEBHOOK_ALLOW_PRIVATE_TARGETS, no redirects), are signed with the channel’s secret, make one attempt with a 30 second limit, and never put the URL or the secret in a run’s error or a log line. - A
regexrule runs with a 1 second time limit on at most 10 000 characters, so a pattern that backtracks without end fails its run instead of holding a worker. - Dataset files are parsed with fixed limits (5 MB, 64 KB per value, 10 000 items), in a thread pool, and an error never repeats the content of a line.
- A malformed backup database URL (for example one without a host, where the password reads as the port) is refused with a fixed message; the error used to quote the bad port, which could be the password.
- “Explain with Claude” no longer turns off row-level security inside the request to check the organization’s budget. The sum is a narrow database function, and the reservation is written under the request’s project like any other row.
- The new tables are protected by row-level security like traces: child tables (prompt versions, labels, dataset items, queue items, evaluator runs, experiment runs and items) are filtered through their parent. Scores are keyed by project and id, so a client-chosen id cannot collide with, or hide behind, another project’s.
spanlight.prompts.get(name, label="production", *, version=None, fallback=None)fetches a prompt with a revalidating cache. After the cache time it serves the stale copy if the server fails, then thefallback, and raisesPromptUnavailableError, the SDK’s one deliberate exception, only with neither.Prompt.compile(**variables)follows the server’s template rules and raisesMissingVariablesErrororInvalidTemplateError.- Span linking.
spanlight_prompt=compiledonwrap_openaiandwrap_anthropiccalls,with compiled:for everything inside a block, andspanlight.no_prompt()to keep a task unlinked. spanlight.score(trace_id, name, value, *, span_id=None, comment=None)sends a score in the background with the spans, with an id per call so a retry cannot store it twice. It never raises.- Experiment runner
python -m spanlight._experiments, behind the eval-gate Action. - Python SDK 0.2.0, the next release.
Span.set_usageacceptsservice_tierandweb_search_requests, andSpan.clear_output_tokens()makes an output count unknown;wrap_anthropicandwrap_openairecord the tier, web searches and the reason a stream had no usage;wrap_anthropicrecords batch results;record_openai_batch_outputis new;usage.missing_reasonis renamedusage.unreported_reason. A failed provider call records the provider’s error type and code, theretry-afterit asked for and its rate-limit headers, filtered through the same 29-name allowlist as the server, so no other response header leaves your process; a call the Spanlight gateway refused itself records the gateway’s code instead. Every batch of spans carries aclientobject with the exporter’s running totals of dropped and failed spans, which older servers ignore. - Python SDK 0.2.0 reports its own cuts. A span whose input or output the SDK shortened (a string over 32,000 characters, a list or mapping over 1,000 entries, nesting deeper than 12, or a generator over 1,000 yielded items) is sent with
truncated: true, and the server stores the span as truncated. Before, such a span arrived under 32 KB and was stored as complete. - Python SDK 0.2.0 compresses by default.
gzipnow defaults toTrue, so span and score batches are sent withContent-Encoding: gzip; every Spanlight server accepts it. Passgzip=Falseonly for a proxy that cannot forward a compressed body. - Python SDK 0.2.0 also adds
update_trace(metadata=...)(andSpanlight.update_trace). It applies the server’s rules on the client: an invalid entry is dropped with aDEBUGlog naming the key, never the value, and a trace keeps at most 32 keys, so the server never rejects a span for its metadata. Older servers ignore the field. - Python SDK 0.1.2.
Span.set_usageacceptscache_write_tokensandcache_write_1h_tokens, andwrap_anthropicfills them fromcache_creation_input_tokensandcache_creation.ephemeral_1h_input_tokens; the attributeusage.cache_creation_input_tokensis no longer set. - Eval gate report. The score table gains a
95% CIcolumn for yes/no scores (64–88%, the low bound rounded down and the high one up;—for other scores), and a gated score whose threshold lies inside its interval gets the line “Interval includes the threshold; the result is not conclusive at this sample size.” Exit codes are unchanged. - Python SDK 0.1.1. The eval-gate report prints p50 and p95 latency in whole milliseconds, and its “Open the run” link and the Action’s
run-urloutput open the run page instead of the experiment.
Settings
Section titled “Settings”| Variable | Default | Effect |
|---|---|---|
EVALS_ENABLED |
true |
Schedule online evaluation. Off, evaluators run only in experiments and tests. |
EVAL_SAMPLER_MAX_RUNS_PER_CYCLE |
50 |
Most runs one evaluator gets per one-minute sampling cycle. |
EXPERIMENT_MAX_ITEMS |
1000 |
Ceiling for an experiment’s max_items. |
GATEWAY_RECORD_BACKLOG |
5000 (was 1000) |
Most gateway spans waiting to be written, retries included. |
PROVIDER_STATUS_POLLING_ENABLED |
true |
Poll the Anthropic, OpenAI and Google status pages every 5 minutes. Off, nothing is fetched and the incident list says polling is disabled; for air-gapped servers. Allow status.claude.com, status.openai.com and status.cloud.google.com on port 443 otherwise. |
BILLING_SOURCES_ENABLED |
true |
Allow calls to the providers’ cost APIs. Off, adding or re-keying a billing source skips the key check, nothing is fetched and a refresh answers 409 NOT_CONFIGURED; CSV uploads and reports still work. |
REVIEW_SELECTION_ENABLED |
true |
Schedule the nightly selection of traces into each project’s review queue. Off, a review policy can still be run by hand. |
REPLAY_MONTHLY_BUDGET_USD |
5.00 |
Most a project may spend on replays per UTC month, in US dollars. 0 turns replay off (409 NOT_CONFIGURED). Replays also need CREDENTIALS_KEYS. |
ATTRIBUTION_ENABLED |
true |
Schedule the attribution refresh, every 15 minutes. Off, attribution windows longer than 24 hours stop updating; shorter windows stay live. |
Migrations
Section titled “Migrations”Run spanlight migrate and then spanlight ensure-app-role (or restart the stack) after upgrading. Migrations 0500 to 0506 add prompts, versions and labels, the span-to-version link, scores and score configs, datasets, annotation queues, evaluators and their runs, and experiments, runs and items. Migration 0507 adds explain_month_to_date_cost, the function the explain budget sums with; ensure-app-role grants the app role permission to call it. Migrations 0501 and 0502 build their indexes on spans and traces CONCURRENTLY: writes continue, and on a large table the build takes a few minutes and extra disk. 0502 also adds traces.has_scores; a retry of a failed CONCURRENTLY build drops its half-built index first. Migration 0600 adds spans.service_tier, spans.web_search_requests and spans.fee_usd (empty on older spans, with no backfill) and the server_tool_fees table; 0601 adds billing sources, their project mappings, fetch runs and provider cost lines; 0602 adds the daily cost rollups and user_stats_daily.computed_at. Run spanlight sync-prices (the api and worker do it when they start) to load the 2026-10-16 prices and fees. Migrations 0700 to 0705 add the provider error columns to spans and the retry columns to traces (empty on older rows; the new checks are validated without blocking writes), the hourly error rollup, the provider incident tables, the telemetry loss tables, the insight feedback table and the error_calls alert metric. The error rollup is backfilled over each project’s retention by the rollup job. Rolling back below 0700 deletes the spans of class gateway, which the older check does not allow. Migrations 0800 to 0804 add traces.metadata (empty on older traces, with no backfill) and the merge_trace_metadata function (callable by every role: no ensure-app-role step is needed for it), projects.promoted_metadata_keys, the attribution_rollups_daily table, the attribution export kind and the metadata and trace_name budget scopes with a 400-character scope_id. They take short locks only. Rolling back below 0804 deletes the budgets of the two new scopes and any budget whose scope_id is longer than 200 characters; below 0803, the attribution exports (their files stay in the bucket); below 0800, all trace metadata. Migration 0900 adds spans.usage_rollup (false on older spans, with no backfill: run spanlight recompute-usage-rollups --since 30d to flag older traces); it changes the catalog only and takes a short lock. Rolling back below it drops the column. Migration 1000 adds reason (manual on every existing item) and reason_detail to annotation_queue_items, with an index built CONCURRENTLY; 1001 adds review_policies and review_selections and the review and replay audit actions; 1002 adds replay_runs; 1003 allows the job outcome already_selected. The three new tables use forced row-level security. Rolling back below 1000 drops the reasons; below 1003, the jobs that ended already_selected lose their outcome.
[0.5.0] - Not tagged; included in 0.6.0
Section titled “[0.5.0] - Not tagged; included in 0.6.0”The Doctor: Spanlight reads your traces, opens findings about how your application calls LLMs, and shows what changed between releases and who drives cost.
- Error class, request hash and finish reason on LLM spans. Every failed span gets one class (
auth,rate_limit,timeout,context_length,content_filter,provider_5xx,network,clientorunknown) from its HTTP status or message, and Traces can be filtered by it. Spans carry arequest_hash(the same model and request give the same hash, computed before payload capture so it exists even when inputs are not stored) and a canonicalfinish_reason(stop,length,tool_calls,content_filterorother) for OpenAI, Anthropic and OTLP spans. Gateway spans for faults that answer before the provider call now record the simulated status andRetry-After. Spans stored before this release have none of these and are not backfilled. - Fifteen detectors. Deterministic rules over a project’s recent spans:
error_spike,latency_regression,cost_spike,retry_storm,retry_after_ignored,rate_limit_pressure,truncated_outputs,context_growth,cache_opportunity,tool_loop,truncated_stream_accepted,unsupported_parameter_retried,client_timeout_misconfigured,unpriced_spendandprovider_incident. They run every 15 minutes for each project with traffic in the last 24 hours, skip calls the Integration Lab faulted when they judge traffic or provider health, and return nothing below a minimum sample size. Each finding carries its evidence (up to 20 traces), the layer where it fails, whether it is measured or inferred, a suggested fix and a way to verify it. The catalogue, thresholds and fixes are on the new Doctor page of the documentation. - Insights lifecycle. A problem is one insight however often it recurs: it counts occurrences, resolves itself after 24 hours without a sighting, and reopens if it returns. Admins and owners can acknowledge, resolve, mute (up to 90 days, with a reason) and unmute. A project’s insight notifications go to a list of its organization’s alert channels; only a critical insight that opens notifies, once, as an
insight.openedwebhook event (signed like alert events), a Slack message, an email or a PagerDuty trigger. The weekly digest gains an Insights section. - Health score. Overview shows a 0 to 100 score: 100 minus penalties for open critical and warning insights, error rate, a slower p95 and higher spend than the previous window. It is “—”, never 0, when the window has no LLM calls.
- Explain with Claude. An opt-in button on an insight asks Claude to explain the finding from its evidence. Admins and owners only, capped per organization at
EXPLAIN_MONTHLY_BUDGET_USDa month with the cost reserved up front, shown under an “Advisory” label as plain text. Only redacted excerpts of at most five example traces are sent, through the organization’s own Anthropic credential and traced into the project. - Releases. Set a release with the SDK, the OTLP
service.versionattribute or the gateway headerx-spanlight-release. The Releases page lists releases in a range and compares two: deltas, model mix, error classes and new error messages, with exact percentiles over a range of up to 30 days. - Users. The Users page ranks end users (the SDK
user_id, OTLPuser.idorx-spanlight-user) by cost, errors or traces over whole UTC days, with a daily chart and recent sessions for each. Statistics are refreshed every 15 minutes. Releases and Users can also be read with a project API key that has thetraces:readscope. - Lab example clients.
examples/holds a deliberately naive client and a corrected one that run the Integration Lab scenarios against the gateway. The naive client opens the matching finding for each scenario and the corrected one is written to open none. - Doctor screens. Doctor (list and detail with evidence, fix, verification and actions), Releases, Users, a health tile on Overview, insight badges on traces, an error-class filter on Traces and an Insight notifications setting in Settings, Project.
- Operations.
GET /api/v1/projects/{id}/detector-runsshows what each detector did on its last passes. New Prometheus metrics (spanlight_detector_runs_total,spanlight_detector_duration_seconds,spanlight_insights_open,spanlight_detector_projects_skipped_total) feed a Doctor row on the Grafana dashboard. A new runbook covers detector runs, a project that times out, muting and the explanation budget.
Changed
Section titled “Changed”insight.openedjoins the notification payloads. Webhook, Slack, email and PagerDuty channels render it next to alert events; PagerDuty uses a dedup key that is stable for the insight and never sends a resolve for one.- The gateway reads
x-spanlight-release(1 to 128 characters) into the trace, so release comparison covers traffic that has no SDK. - Gateway
Retry-Afterhandling. The provider’sretry-after-msheader is now passed back to the client next toretry-after, and the gateway honours and records the millisecond value first. - A Lab
malformed_jsoncall is recorded as failed. It still answers200with the cut body, but its span has statuserrorand the message200 FAULT_MALFORMED_JSON: …, so a client’s reaction to it is judged like any other failure.
- Request hash and finish reason on LLM spans. The OpenAI and Anthropic wrappers now send a
request_hash(a stable hash of the model and request parameters, computed before payload capture so it exists even when inputs are not stored) and the provider’s rawfinish_reason(the first choice’sfinish_reasonfor OpenAI,stop_reasonfor Anthropic, streams included). Manual spans can callspan.set_request_hash(...)andspan.set_finish_reason(...).
Settings
Section titled “Settings”| Variable | Default | Effect |
|---|---|---|
DETECTORS_ENABLED |
true |
Schedule the detector job. Off, no new insights appear. |
EXPLAIN_MODEL |
claude-sonnet-5-5 |
The model behind “Explain with Claude”. It must have a price. |
EXPLAIN_MONTHLY_BUDGET_USD |
1.00 |
Most an organization may spend on explanations per UTC month. 0 turns them off. |
USER_STATS_ENABLED |
true |
Schedule the refresh of per-user daily statistics behind the Users page. |
Migrations
Section titled “Migrations”Run spanlight migrate (or restart the stack) after upgrading. Migrations 0400 to 0405 add the error class, request hash and finish reason columns on spans, the insights, detector runs and insight explanations tables, the project’s insight channel list, the per-user daily statistics table and a release index on traces. Migrations 0400, 0401 and 0405 build their indexes CONCURRENTLY: writes continue, and on a large table the build takes a few minutes and extra disk.
[0.4.0] - Not tagged; included in 0.6.0
Section titled “[0.4.0] - Not tagged; included in 0.6.0”Alerts and budgets: get told when error rate, latency, volume or cost crosses a line, and stop runaway spend at the gateway.
- Alert channels. Organization-wide channels for email, Slack incoming webhooks, signed webhooks and PagerDuty (Events API v2). Email goes only to verified members of the organization. A channel can be tested, and its delivery log shows every send with its status, attempts and last error; a failed delivery can be retried by hand. Slack URLs, PagerDuty routing keys and webhook signing secrets are encrypted with
CREDENTIALS_KEYS, never returned, and a webhook’s secret is shown once, when it is created or rotated. - Signed webhooks. Each request carries
X-Spanlight-Event,X-Spanlight-Delivery,X-Spanlight-Timestampand an HMAC-SHA256X-Spanlight-Signature, with a documented verification procedure and test vector. Failures are retried with backoff for up to 8 attempts. - Alert rules. Threshold rules and anomaly rules (a band of a chosen number of standard deviations around the average of the previous windows) on error rate, p95 latency, p95 time to first token, LLM calls, tokens and cost, filtered by environment, provider or model, with a 7-day preview before you save. Rules are evaluated every 60 seconds with a cooldown after each resolve, can be muted for up to 30 days, and their events can be acknowledged. A metric with no data never fires or resolves a rule.
- Budgets. Daily or monthly (UTC) spend limits per project, gateway key, end user or model, each set to notify or to block. A budget notifies its channels when its spend passes the amount and again when it recovers.
- Blocking budgets. The gateway answers
402 BUDGET_EXCEEDED, in the OpenAI or Anthropic error shape, to calls in the scope of a blocking budget that is exceeded. Ingestion is never blocked. Blocking lags spend by up to one evaluation, about a minute. - Weekly digest. Every Monday at 08:00 UTC, members with a verified email get one summary per project of spend, LLM calls, error rate, p95 latency, top models by cost and alerts fired. It can be turned off per project in project settings and needs an email provider.
- Dashboard screens. Alerts (Rules and Channels) and Budgets, with the rule editor and preview, mute and acknowledge, the event timeline, and the delivery log.
- Grafana alert panels. Rules evaluated by outcome, transitions by kind, evaluation duration, outbox backlog, deliveries by kind and outcome, and budget blocks.
/health/readyreportsalert_evaluation_lag_s. spanlight alerts evaluate. Runs one evaluation pass by hand, with--dry-runto roll it back and--jsonfor scripts, which is also how the evaluation load test is timed.- Documentation. Guides for alerts and channels, budgets and the weekly digest, plus a runbook for stuck and failed deliveries, a rotated webhook secret and late evaluation.
Changed
Section titled “Changed”- Outbox leases and fencing. A worker now claims an outbox row with a 60 second lease and a fencing token and sends outside any database transaction. A worker whose lease expired can no longer settle the row, and a crashed worker’s rows are claimed again by the next run. Delivery is at least once.
- Failed alert payloads are kept. Payload reduction after a delivery settles now keeps the payload of a failed alert delivery so it can be retried; sent rows and failed transactional mail (verification, reset, invite) are reduced as before.
- Egress protection is a shared module. The check that refuses private, loopback, link-local and reserved addresses on every connection moved from the gateway into
app/core/egress.pyand now also guards webhook, Slack and PagerDuty deliveries. - Outbound URLs stay out of logs.
httpxandhttpcorelog at WARNING and Sentry’shttpxintegration is off, so a Slack or webhook URL can never reach a log line or an error report. - Model filters match snapshot names. An alert or budget filter on
gpt-4oalso matchesgpt-4o-2024-08-06andgpt-4o-latest, but notgpt-4o-mini, the same rule the price table uses. - SMTP on port 465. With
SMTP_PORT=465the email sender connects with implicit TLS (SMTPS) and checks the certificate, for networks that block port 587. - The gateway budget guard is real. The hook added in 0.3.0 now reads the evaluated state of blocking budgets instead of allowing every call.
Security
Section titled “Security”- Alert email cannot be used to write to strangers: recipients must be verified members, checked when the channel is saved and again when the message is queued (
ALERT_EMAIL_ANY_RECIPIENTlifts it for a closed deployment). - Webhook URLs must be
https://and resolve only to public addresses, checked when the channel is saved and on every connection, so a DNS change after the check does not get around it. Redirects are never followed, and response bodies are capped before they reachlast_error.WEBHOOK_ALLOW_PRIVATE_TARGETSlifts both rules for a receiver inside a private network. - Channel secrets are never written to a log, the audit log or
last_error.
Settings
Section titled “Settings”| Variable | Default | Effect |
|---|---|---|
ALERTS_EVALUATION_ENABLED |
true |
Schedule the once-a-minute alert and budget evaluation. |
WEEKLY_DIGEST_ENABLED |
true |
Schedule the Monday digest email. |
WEBHOOK_ALLOW_PRIVATE_TARGETS |
false |
Allow http:// and private addresses for webhook channels. |
ALERT_EMAIL_ANY_RECIPIENT |
false |
Allow email channels to send to addresses that are not verified members. |
PAGERDUTY_EVENTS_URL |
https://events.pagerduty.com/v2/enqueue |
Where PagerDuty channels send events. |
Migrations
Section titled “Migrations”Run spanlight migrate (or restart the stack) after upgrading. Migrations 0300 to 0307 add the outbox lease columns, the alert channel, rule, state, event and budget tables, the weekly digest switch on projects, and three indexes. Migrations 0302 (end-user traces), 0306 (a channel’s deliveries) and 0307 (weekly digest lookups) build their indexes CONCURRENTLY: writes continue, and on a large table the build takes a few minutes and extra disk.
[0.3.0] - Not tagged; included in 0.6.0
Section titled “[0.3.0] - Not tagged; included in 0.6.0”The LLM gateway and the Integration Lab: point the official OpenAI or Anthropic client at Spanlight with one base URL and every call is traced, priced, limited and, if you configure it, routed, retried, cached and fault-injected.
- LLM gateway.
POST /gw/v1/chat/completions,POST /gw/v1/responses,POST /gw/v1/messagesandGET /gw/v1/modelsaccept OpenAI and Anthropic requests, streaming and tool calls included, and forward the provider’s answer unchanged. Errors come back in the provider’s own shape with a stablespanlight_code. Each call is recorded as onellmspan with the gateway’s route, target, attempts and overhead, written after the answer is sent (a span that cannot be written is counted inspanlight_gateway_record_failures_total), and can join a trace you already started with thex-spanlight-*headers. - Provider credentials. Organization owners store OpenAI, Anthropic and OpenAI-compatible API keys. Keys are encrypted with
CREDENTIALS_KEYS, never returned, and can be checked, rotated and re-sealed under a new master key withspanlight reseal-credentials. The gateway refuses to connect to private, loopback, link-local and other reserved addresses, on every connection, unlessGATEWAY_ALLOW_INSECURE_BASE_URLSis set for a local model server. - Routes. Weighted targets, per-target model aliases, retries with backoff and
Retry-After, fallbacks to the next target, and a time budget for the whole call. Every save is a numbered version with revert, and a stale edit is refused with409 ROUTE_VERSION_CONFLICT. - Gateway keys and limits. Project-scoped
spl_gw_…keys, accepted as a bearer token orx-api-key, with a route, an environment, requests and tokens per minute, an allowed-model list and default tags.GATEWAY_ORG_RPM_CEILINGcaps an organization across its keys. - Response cache. Opt-in per key with a time to live. Exact match on the surface, route, route version and request body, for non-streaming
200answers up to 1 MB.X-Spanlight-Cachereportshit,miss,offorbypass, and a project’s cache can be purged. - Budget guard hook. The gateway asks a budget guard before every provider call and answers
402 BUDGET_EXCEEDEDwhen it says no. The guard that ships allows every call; budget rules arrive in a later release. - Price overrides. An organization can set its own per-model prices (
/api/v1/orgs/{org_id}/price-overrides). They win over the built-in table for spans ingested afterwards, and such spans recordpricing_versionoverride:<id>. - Integration Lab. Fault profiles inject nine provider failures into non-production keys: expired or denied keys, rate limits, an unsupported parameter, server errors, a cut-off JSON body, a stream that ends early, a slow first byte and a timeout. Faulted calls are marked by
X-Spanlight-Fault, aFAULT_<SCENARIO>code and alab:<scenario>trace tag. - Standalone gateway.
GATEWAY_MODE=standaloneruns the gateway as its own process (python -m app.gateway, same image) behind thestandalone-gatewayCompose profile, so LLM traffic can scale and fail apart from the dashboard API.disabledturns it off. There is a new runbook for it. - Demo through the gateway. The demo project’s LLM calls now go through the gateway with the demo project’s own key, so the demo exercises the same path as a real application.
- Gateway load test. The k6 load tests gain a scenario that measures the time a call spends in Spanlight against a fake provider, with a target of under 20 ms at p95, and
docs/performance.mdexplains the method. - Dashboard screens. A Gateway section with Overview (traffic, errors, cache hit rate, fallbacks and retries per target), Keys, Routes with a versioned editor, Credentials and Lab. New Prometheus metrics cover gateway requests, overhead, upstream time and attempts; Grafana panels show requests, overhead, attempts, cache hits and spans not recorded.
- Documentation. Gateway guides for the quickstart, routing, the cache, the Lab, credentials and error responses.
Changed
Section titled “Changed”CREDENTIALS_KEYSnow also protects provider credentials. Rotate it in two phases and runspanlight reseal-credentialsbefore dropping an old key (see the rotate-secrets runbook).- Audit log. Gateway configuration changes (credentials, routes, keys, fault profiles, cache purges and price overrides) are recorded as audit events.
Security
Section titled “Security”- Provider API keys are write-only: they are never returned by the API, written to a log or kept in an
Idempotency-Keyrecord, and responses that describe a credential carryCache-Control: no-store. - Gateway keys are stored as hashes, compared in constant time, and redacted from logs and spans.
- The gateway never forwards the client’s
Authorization,x-api-key, cookies orx-spanlight-*headers to a provider, does not follow redirects, and sends no CORS headers. - Fault injection cannot run on a key whose environment is
production, checked when a profile is attached and again on every call.
[0.2.0] - Not tagged; included in 0.6.0
Section titled “[0.2.0] - Not tagged; included in 0.6.0”Production hardening: accounts and access control, a versioned API, durable background work, backups and exports, observability of Spanlight itself, and documentation.
- Accounts. Email verification and password reset by email (links carry the token in the URL fragment; verification links expire after 24 hours, reset links after 1 hour). Sign in with GitHub and Google. Two-factor authentication with an authenticator app and recovery codes, which an organization owner can require for all members.
- Access tokens. Personal access tokens (
spl_pat_…) for scripts and CI, with areadorwritescope and an optional expiry, revocable at any time. - API keys. Project API keys carry scopes (
ingest:write,traces:read) and an optional expiry. Existing keys keepingest:writeand never expire. - Idempotency keys. State-changing API requests accept an
Idempotency-Keyheader, so a retried request runs once. - Dashboard at scale. Hourly rollups with bucketed latency histograms back every time window longer than 24 hours; those responses are marked
"approximate": true. Shorter windows still read the spans directly. - Shared rate limits. Limits for ingestion, the demo and bearer reads are enforced in Postgres, so they hold across replicas and across restarts. A limited request gets
429withRetry-After. - Object storage and backups. Any S3-compatible store (AWS S3, Cloudflare R2, MinIO) can hold nightly
pg_dumpbackups, kept for 14 daily and 8 weekly copies, andspanlight restoreloads one back. - Exports. Export traces as CSV or JSONL, and the audit log as CSV. Download links last one hour and files are deleted after seven days.
- Price endpoints.
GET /api/v1/priceslists the price table, andGET /api/v1/projects/{project_id}/unpriced-modelsshows which models in your traffic have no known price. - Organization and project administration. Rename or delete an organization or a project through the API, with a typed confirmation for deletes.
- Observability of Spanlight itself. Optional self-tracing with OpenTelemetry, a readiness check (
/health/ready) that reports the database, the migrations and the worker, and Prometheus metrics for the worker (jobs, outbox, notifications, rollups) onWORKER_METRICS_PORT. - Operations. Runbooks for deploying, rolling back, restoring, rotating secrets, revoking keys, scaling, ingestion spikes and service level objectives, plus a threat model and security documentation.
- Documentation site. Quickstart, Python SDK, OTLP, self-hosting, configuration, API reference, security, runbooks and this changelog, served at
/docs/by the web image. - Load tests. k6 scripts and a seeder in
backend/load/measure ingestion (500 spans a second), the dashboard overview and the trace list (10 million spans) against the Compose stack, and a manually started workflow runs them on a GitHub-hosted runner. Method and targets are indocs/performance.md.
Changed
Section titled “Changed”- Versioned API. Dashboard routes moved from
/api/<resource>to/api/v1/<resource>. The old paths return404. Ingestion (/v1/traces,/v1/otlp/traces) keeps its own version and is unchanged. A future breaking change will ship as/api/v2, with/api/v1kept for at least 12 months. - Postgres shared memory. The Compose
postgresservice gets 256 MB of/dev/shminstead of Docker’s 64 MB default, which parallel queries on large tables could exhaust (“could not resize shared memory segment”). - Dashboard queries at 10 million spans. Migration 0016 swaps the
spanstime index for a covering one, so a 24 hour overview is answered from the index and not from the table. It is builtCONCURRENTLY: writes continue, and on a large table it takes minutes and extra disk. - Row-level security policies read their settings once per statement. Migration 0017 rewrites the tenant-isolation and worker-bypass policies of
traces,spans, the rollup tables andexportssocurrent_setting()is evaluated once per query instead of once per row; which rows are visible does not change. It takes a brief exclusive lock on each table and gives up after 5 seconds if it cannot get it, so run it again at a quieter moment. - A busy database answers
503, not500. A request that waits more than 5 seconds for a connection (API_POOL_TIMEOUT_SECONDS) or has a statement that runs longer than 10 seconds (API_STATEMENT_TIMEOUT_SECONDS) now gets503 SERVICE_UNAVAILABLEwithRetry-After: 5. - Postgres JIT off in Compose. The
postgresservice starts withjit=off, which costs more than it saves on the dashboard and rollup queries. - More than one replica. Rate limits and idempotency state live in Postgres, so the API can run as several replicas.
- Optional features report themselves. A request that needs an unconfigured integration (email, object storage, OAuth,
CREDENTIALS_KEYS) answers409 NOT_CONFIGUREDand names the setting; a background job that needs one ends asskipped_not_configured.
Security
Section titled “Security”- Two-factor seeds are encrypted at rest with the keys in
CREDENTIALS_KEYS. - Sign-up is throttled per client IP.
- Password hashing and checking (argon2) run off the event loop, on a small dedicated executor that caps how many hashes run at once, so a burst of sign-ins cannot starve other requests or exhaust memory.
- Requests to
/api/v1are limited to 1 MiB (413above that). Deeply nested JSON sent to ingestion answers400 INVALID_JSONinstead of500. BACKUPS_ENABLEDandBACKUP_DATABASE_URLare set only on the worker, the only service that runs backups. The object storage keys stay on both services, because the API signs export download links.- Responses that reveal a secret (a new API key or access token, recovery codes, a two-factor setup secret) carry
Cache-Control: no-store. - The
/metricstoken is compared in constant time. - The web image runs Caddy as a non-root user.
- Export files are written under a separate key for each attempt, so a retry or a lease takeover can never delete a file another attempt finished. Download links force the browser to save the file instead of showing it.
- A database that is too busy to answer answers
503withRetry-After, not500. - Row-level security policies read their settings once per query. Which rows are visible does not change.
[0.1.0]
Section titled “[0.1.0]”Initial version.
- Tracing. Python SDK (
spanlight) with@observe, spans and wrappers for the OpenAI and Anthropic clients, native JSON ingestion (POST /v1/traces) and OTLP ingestion (POST /v1/otlp/traces, JSON and protobuf, OpenTelemetry GenAI attributes). - Dashboard. Overview with latency, token, cost and error metrics, a trace explorer with a span waterfall, and session views.
- Cost. Versioned per-model prices; a model without a known price shows as unknown, never as $0.
- Teams. Organizations, projects, roles, invites, API keys and an audit log.
- Privacy. Postgres row-level security, secret redaction before storage, and per-project switch for payload capture.
- Self-hosting. One Docker image with
api,workerandwebroles, a Compose file and a Railway deployment guide.
This page is generated from CHANGELOG.md in the repository.