kuilt-otel
Offline-first OpenTelemetry for Kotlin Multiplatform.
Record traces, metrics, and logs on any platform — JVM, Android, iOS, macOS, or browser (wasm) — and have them automatically reconcile across all your users' devices when connectivity returns, with no duplicates and no data loss.
Why this is different
Standard OpenTelemetry exporters POST spans to a collector and fail if the network is down. kuilt-otel turns that around: export() succeeds the moment the data is durably written to local storage. Delivery to a backend happens whenever the network allows, possibly hours later. Reconnecting devices exchange only the missing deltas — not a full queue replay — so a brief reconnection is kind to a flaky link.
The trick that makes this correct (not just buffered): every signal is stored as a CRDT. Spans are an ORSet keyed by span id; sending the same span twice is a set union and therefore idempotent. Metrics are mergeable counters. Logs are an ordered append-only sequence. A resend cannot double-count — the delta-temporality retry bug is structurally impossible.
Quick start
@sample us.tractat.kuilt.otel.sampleWarpTelemetryWhen an app suddenly has a lot to say
Saving one log line used to cost a full round of bookkeeping and two writes to the device's storage — every single time. Hand over a handful of lines at once and that cost is paid once for the lot, so an app that gets chatty for a moment doesn't have to pay for each line separately.
Nothing is delayed to make that happen. A batch is only ever what the caller already had in hand, and it is on disk before the call returns — a lone line on a quiet app is written just as promptly as before.
WarpLogRecordExporter.export takes either a single record or a list of them. The list overload admits the whole run to the Rga, encodes the active segment once and writes it once, rather than repeating that per record; a run too large for one segment is split across turns, so a failure means "stop", not "none of it landed" (#2194). Dedup and the buffer cap remain per-record decisions. Its KDoc carries a worked example.
Where the bytes actually land
Every exporter here writes through a DurableStore — a small "keep these bytes safe" interface with a crash-safe implementation per platform. It lives in its own module, kuilt-store, because a durable key→bytes store has nothing to do with telemetry; see that module's documentation for the durability contract and the four backends.
What's here
| Type | What it does |
|---|---|
WarpSpanExporter | CRDT-backed span buffer (ORSet). Idempotent export + merge. |
WarpMetricExporter | CRDT-backed metric buffer: sums (GCounter), gauges (LWWRegister), cardinality (HyperLogLog). |
WarpLogRecordExporter | CRDT-backed log buffer (Rga). Ordered, idempotent export + merge. |
WarpTelemetry | Facade that composes all exporters under one surface. |
WarpTelemetry.clear | The supported reset: empties every signal's buffer and its persisted state on a live instance — no restart, no per-platform directory delete. Logs and spans suppress what they drop, so a peer cannot re-merge it back; metrics forget only locally (a monotonic join has no merge-safe forget). |
WarpOtlpBridge | Drains converged CRDTs to an OTLP edge, reconciling by digest. |
OtlpEdge | Interface your backend implements to receive spans. |
SpanDigest | Compact set of span ids the edge already holds; drives delta computation. |
DrainResult | Typed result of WarpOtlpBridge.drain: spans sent or failure. |
SpanRecord | OTLP-shaped span data model. |
LogRecord | OTLP-shaped log-record data model with optional trace correlation. |
MetricKey | Identity of one metric time series (name + kind + label set). |
MetricKind | SUM / GAUGE / CARDINALITY. |
ExportResult | Typed result of span/log export() / merge(). |
MetricExportResult | Typed result of metric mutations. |
BufferPolicy | Span/log bounded-buffer eviction strategy (spans log each drop; log records count them on ExporterHealth). |
MetricBufferPolicy | Metric bounded-buffer eviction strategy (always logs what it drops). |
Deferred (follow-up PRs)
Histogram metrics — DDSketch or t-digest for merge-able quantile estimates.
Honest limits
Clock skew. Timestamps are the producer's local clock. Long-offline devices may have skewed clocks; an HLC offset could be estimated on reconnect but is not yet implemented. Gauge values from a device with a slow clock may be silently overwritten by a peer with a faster clock even if the slow-clock value is "newer" in wall time.
Late traces. A trace straddling an offline and an online producer only assembles when the offline half syncs. Collectors accept late spans within a configurable assembly window.
Bounded buffer. The span and log buffers are capped (
DEFAULT_MAX_SPANS,DEFAULT_MAX_LOG_RECORDS); the metric buffer atDEFAULT_MAX_METRICSdistinct series. Eviction is never silent, but it is reported differently by rate: spans and metrics log each drop; log records are counted exactly onExporterHealth.dropped/ExporterHealth.refusedwith one rate-limited summary line, because atDEFAULT_MAX_LOG_RECORDSevery exported record evicts one and a per-record line would narrate the cap doing its job. Counters are O(1) regardless of offline duration (a counter compresses losslessly).For logs, the total settles on the export path and still grows on the gossip path.
WarpLogRecordExporterpersists its op-log in segments ofDEFAULT_LOG_SEGMENT_OPSoperations, so one export rewrites one segment rather than the whole log; it then periodically drops everything outside the retained window from memory and deletes any sealed segment the resulting suppression state fully covers. A device fed only by its ownWarpLogRecordExporter.exportcalls therefore holds O(DEFAULT_MAX_LOG_RECORDS) ops and a flat number of keys however long it runs, and startup reads that many keys rather than one per segment ever written (#2127). That is one arm of the claim, not the whole of it — and the other arm costs whole records, not a small note. A record that arrived from a peer throughWarpLogRecordExporter.mergecannot fold into the per-author floor — raising another author's floor would annihilate records it has not written yet — so each one windowed away is suppressed by an explicit compaction record instead. In memory that is one bodiless(id -> id)pair per element. On disk it is not bodiless at all: nothing prunes a compaction record, so a segment carrying one is pinned, and a pinned segment is retained entire — every record it holds, bodies included, permanently. Two shapes reach it. A sealed segment that happened to be active when a pass minted one keeps its fullDEFAULT_LOG_SEGMENT_OPSoperations (~123 KB at the defaults) forever. AndWarpLogRecordExporter.mergepersists the peer's op-log verbatim under a key of its own, so merging from a peer that has itself windowed a foreign author's records — which is any peer in a steady-state mesh — pins that peer's whole log: megabytes per merge at the defaultDEFAULT_MAX_LOG_RECORDS. So the gossip path is growth, not a bound, and a replica that gossips accumulates it for as long as the process lives. Bounding it needs the same causal-stability argument tombstone collection does; consolidation — rewriting a pinned segment's compaction record forward so the segment can go — was considered and declined, and nothing implements it. The span and metric exporters still rewrite their whole state per export.Cardinality estimation. HyperLogLog gives ~0.81% relative error at default precision (
p=14). Small cardinalities (< ~5 distinct elements) have higher relative error; the linear-counting correction reduces but does not eliminate this. Precision is fixed per metric; changing it after production data exists is wire-breaking.Trust. On-device telemetry and peer-relayed telemetry need auth/encryption to prevent forgery. Deferred to a later PR.