You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(retention): accumulate late-arriving spans instead of overwriting a rolled-up day
daily_usage was written with INSERT OR REPLACE, which is correct only while a
roll-up recomputes a day from complete raw data. A span dated to a day that had
already been rolled up and purged — a backfill, or an import of telemetry older
than COTEL_RETENTION_RAW_DAYS — made the next cycle see exactly one span for
that day, recompute the aggregate from it alone, and REPLACE the correct row.
The day's earlier usage was gone, and so were the raw spans that could rebuild
it, so historical cost was silently understated.
A day's aggregate now accumulates: ON CONFLICT DO UPDATE adds each cycle's sum
to the existing row instead of replacing it.
This reverses the reasoning recorded in the previous commit, which kept
INSERT OR REPLACE because an accumulating upsert would double a day's aggregate
on a retry after a crash between the INSERT and the DELETE. That objection was
correct and is answered rather than ignored: the accumulate, the raw-span purge
and the aggregate purge now run in a single transaction, so the spans a cycle
folds in vanish atomically with the addition and a crash rolls both back. The
whole-day truncation from that commit still stands — it is what keeps a day
from being rolled up in slices in the first place.
Accumulation is safe against the trigger it exists for: spans.span_id is a
PRIMARY KEY and both ingest and import insert with OR IGNORE, so re-importing a
span is a no-op and cannot inflate a total. The COALESCE on the update side
covers total_cache_read_tokens and total_cache_write_tokens only, because those
are the columns the v7->v8 migration added nullable; the other totals are
non-pointer types on every write path and are never NULL.
Tests roll up a day, ingest a span dated into that same purged day, and assert
the aggregate is the sum of both spans, that the day keeps exactly one row, and
that a further cycle does not double-count.
Co-Authored-By: Daedalus <daedalus@agents.flopbut.local>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
- No more silent telemetry loss on restart/deploy: the ingest (`:4318`) and dashboard (`:8080`) ports now bind and accept connections **before** the DuckDB open (WAL replay + schema migration), which can take minutes on a large production database. During that window both ports answer a retryable `503` with `Retry-After` instead of resetting the connection, so OTLP exporters retry and spans are delivered once cotel is ready. Previously the ports bound only after the open finished, so every deploy had a multi-minute window where ingest refused connections and dropped spans
15
15
- Retention roll-up no longer loses part of a day's usage. The cutoff was a wall-clock instant, so with the worker ticking every 6h (`COTEL_RETENTION_INTERVAL`) a day was rolled up in slices; because `daily_usage` is keyed by day and written with `INSERT OR REPLACE`, each slice overwrote the previous one while its raw spans were already purged. Every day's `span_count`, token totals and `total_cost_usd` were therefore systematically understated — only the last slice survived. The cutoff is now snapped back to UTC midnight — the same boundary the `day` key is bucketed on — so only whole days are ever rolled up and purged. Trade-off: raw spans now live up to one day longer than `COTEL_RETENTION_RAW_DAYS` (default 30). Aggregates already flattened by the old behaviour cannot be recovered — the raw spans are gone
16
16
- Retention is now correct on servers that do not run in UTC. A span's `day` is its UTC calendar day, but both retention cutoffs were computed in the server's local zone, so on any host at a non-zero UTC offset — including UTC+1/+2 — the boundary fell inside a day bucket and re-introduced the overwrite above for spans near midnight UTC. Both cutoffs are now computed in UTC, and the retention tests run under a matrix of server timezones so the alignment cannot silently regress
17
+
- Retention roll-up no longer overwrites an already-rolled-up day's aggregate when a span dated to that day arrives late — a backfill, or an import of telemetry older than `COTEL_RETENTION_RAW_DAYS`. The day had already been aggregated and its raw spans purged, so the next cycle recomputed the day from the single late span alone and `INSERT OR REPLACE`d the correct total away, silently corrupting historical cost. Late spans now **accumulate** into the existing `daily_usage` row (`ON CONFLICT DO UPDATE`) instead of replacing it; the accumulate and the raw-span purge run in one transaction so a crash between them cannot double-count
17
18
18
19
### Added
19
20
- Startup logging of each phase — `opening db`, `db ready: schema/migrations applied in <duration>`, and `ready: serving live traffic …` — so a slow open is visible instead of a silent container. Plus a Docker `HEALTHCHECK` (`cotel -healthcheck`, which probes the dashboard `/healthz`) so the container reports `health: starting` until the database is open rather than a misleading `Up`; no curl/wget is added to the runtime image
0 commit comments