WarpTelemetry
A CRDT-backed, offline-first telemetry surface for Kotlin Multiplatform.
WarpTelemetry owns the three signal exporters: spans, metrics, and logs. Each exporter writes to a DurableStore and holds its data as a CRDT — so offline buffering, eventual delivery, and idempotent merge are structural properties, not retry logic layered on top.
Binding surface (option a — direct OTLP)
kuilt-otel targets the OTLP wire format directly rather than wrapping the JVM OTel SDK. This gives full Kotlin Multiplatform reach: the same exporter runs on JVM, Android, iOS, macOS, and wasmJs. The JVM OTel SDK path (option b) would strand Native and wasm — the platforms where a KMP exporter is most valuable.
Usage
val telemetry = WarpTelemetry(
replica = ReplicaId("device-uuid-here"),
store = InMemoryDurableStore(), // or a platform WAL in production
)
telemetry.recover() // load persisted state from the store
telemetry.spans.export(span) // export() returns on durable write
telemetry.logs.export(logRecord) // same guarantee for log recordsA WarpOtlpBridge drains the converged CRDTs to a real OTLP endpoint whenever the network is available. Wire it with an OtlpEdge implementation and call WarpOtlpBridge.drain on each reconnect — it reconciles by digest and sends only the spans the edge does not yet have.
Honest limits
Platform WALs for iOS/macOS (#724) and wasmJs/IndexedDB (#725) are deferred; pass InMemoryDurableStore until those land.
Parameters
Stable, unique identity for this device/process (use a UUID).
Durable persistence backend. InMemoryDurableStore in tests; a platform-specific WAL in production.
Maximum number of spans buffered in memory.
Maximum number of log records buffered in memory.
Maximum number of distinct metric series buffered in memory.
Eviction strategy when maxSpans or maxLogRecords is exceeded.
Eviction strategy when maxMetrics is exceeded.
Samples
val telemetry = WarpTelemetry(
replica = ReplicaId("device-uuid-abc123"),
store = InMemoryDurableStore(),
)
// Load any spans and log records buffered during a previous session.
telemetry.recover()
// Span ids are raw bytes (OTLP wire format): 16 bytes for trace id, 8 for span id.
val span = SpanRecord(
traceId = ByteString(ByteArray(16) { it.toByte() }),
spanId = ByteString(ByteArray(8) { it.toByte() }),
parentSpanId = null,
name = "purchase",
kind = SpanKind.SERVER,
startEpochNanos = 1_000_000_000L,
endEpochNanos = 1_500_000_000L,
attributes = mapOf("item.id" to "widget-42"),
status = SpanStatus.Ok,
)
// export() returns the moment the data is durably written locally —
// not when it reaches a backend. Delivery is the fabric's job.
val spanResult = telemetry.spans.export(span)
check(spanResult == ExportResult.Success) { "export failed: $spanResult" }
val logRecord = LogRecord(
recordId = ByteString(ByteArray(8) { it.toByte() }),
body = "purchase completed",
severityNumber = 9, // INFO
traceId = ByteString(ByteArray(16) { it.toByte() }),
spanId = ByteString(ByteArray(8) { it.toByte() }),
)
val logResult = telemetry.logs.export(logRecord)
check(logResult == ExportResult.Success) { "log export failed: $logResult" }Constructors
Properties
The log-record exporter (A4). Export log records here; they are CRDT-merged on reconnect.
The metric exporter (A3). Export cumulative sums, gauges, and cardinality estimates here; they are CRDT-merged on reconnect with no double-counting.
The span exporter (A2). Export spans here; they are CRDT-merged on reconnect and auto-stamped with causal context so cross-device happens-before links form automatically (#846).
Functions
Empty every signal's buffer and its persisted state — the supported reset (#2208).
Load persisted CRDT state from the DurableStore for all exporters.