Telemetry - #1396
Draft
pblazej wants to merge 37 commits into
Draft
Conversation
Move livekit-protocol, livekit, livekit-api, livekit-data-stream and livekit-uniffi from a pinned prost 0.12 to the workspace prost 0.14, with pbjson/pbjson-types 0.6 -> 0.9. The committed prost-generated code compiles unchanged, so no regeneration is needed. Binaries combining these crates (livekit-ffi already used the workspace prost) now link a single prost, and crates that depend on prost 0.14 types (e.g. opentelemetry-proto) no longer drag in a second copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared client telemetry core, consumed by the Rust SDK and (via
livekit-uniffi) by the platform SDKs. Mirrors the OpenTelemetry Logs SDK
shape:
- Telemetry::emit synchronous, never-blocking entry point
- Store bounded in-memory queue, drop-oldest
- Exporter actor: 1 s tick, batches of 512, OTLP encode,
bounded retries honoring Retry-After, drop on
reject, go silent when the collector disables
telemetry
- TelemetryTransport the only injection point: the core composes
URL/headers/body, the transport moves bytes;
NetTransport over livekit-net behind `net`
- FileCache (storage_dir) one file per encoded batch, .tmp -> rename,
oldest-first replay, drop-oldest above
max_storage_bytes, 24 h max age; shutdown spills
the queue to disk before trying the network;
throttled/rejected/disabled data is never written
OTLP types come from opentelemetry-proto (gen-tonic-messages only); its SDK
dependencies are dead code and LTO removes them. SPEC.md seeds the event
catalog (lk.ping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thin uniffi::Object wrapper around livekit_telemetry::Telemetry that spawns the exporter on the global tokio runtime. Hosts pass a TelemetryConfig record and implement the async TelemetryTransport foreign trait (e.g. a URLSession or OkHttp POST); TelemetryEvent/Attribute/Severity/ExportError cross the boundary as records and enums from the livekit-telemetry component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sends one `lk.ping` to an OTLP/HTTP collector (default: a local grafana/otel-lgtm on :4318) through NetTransport. LK_OTLP_ENDPOINT overrides the URL; LK_TELEMETRY_DIR enables the on-disk cache so the offline -> online replay can be exercised by stopping and starting the collector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sport Persistence was write-behind: only batches that failed after retries were written, plus a spill on shutdown. That survives being offline or killed but not a crash, which loses up to a flush interval of events - the seconds a crash investigation wants. Every client SDK surveyed (Sentry envelopes, Datadog batch files, opentelemetry-android disk buffering) writes before sending, so the cache is now the queue between exporter and transport. - BatchCache trait: push(id, body) / pending / read / remove / clear. Ids are exporter-minted `<unix_ns>-<seq>-<events>`: sortable, age-bearing, countable. - MemoryCache (default): failed uploads wait for the next attempt instead of being dropped after three tries. FileCache: the former persist.rs, selected by `storage_dir`. Telemetry::with_cache accepts any other impl. - Exporter collapses to one path: enqueue (encode -> cache), then upload (oldest-first -> transport -> remove); shutdown only ignores the upload backoff. Retry-After keeps cached batches but drops new ones for its window; Disabled empties the cache. - max_storage_bytes -> max_cache_bytes (applies to both implementations). ios-arm64 UniFFI dylib: 1,193,368 B (+16.4 KB over write-behind), 13.7 KiB over SPM_SIZE_LIMIT_BYTES - the gate bump is left as a separate decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-telemetry (the Sentry "client report" shape, reason names from the OTel
SDK self-metrics conventions): Counters shared by store and exporter track
every way data is lost - queue_full, cache_error, rejected, throttled,
disabled - plus uploads sent and failed attempts. Telemetry::stats() exposes
them; whenever something went wrong since the previous report the exporter
appends one lk.telemetry.report event to the next batch. Never an extra
request, never persisted on its own, silent when nothing is wrong.
Device state: the host pushes DeviceState { thermal, low_power_mode,
app_state } through Telemetry::set_device_state. The core emits the
lk.device.{thermal,low_power,app_state}.changed events (initial value on the
first call) and multiplies the flush interval by cadence_factor(): serious
thermal x2, critical x4, low power x2, background x2, capped at 4x; entering
the background flushes once immediately. The OS APIs stay on the host - no
Rust crate can read them without a JVM/ObjC bridge.
UniFFI: Telemetry.stats(), Telemetry.setDeviceState(); TelemetryStats,
DeviceState, ThermalState, AppState records/enums. ios-arm64 dylib +744 B.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… attributes The remaining v0 surface from the design doc: - Log records: a TelemetryEvent with an empty name is a plain OTLP log record (severity + body, no event_name). Only Warn/Error leave the device; emit() drops Trace/Debug/Info. - Flood guard: discrete events are capped at max_events_per_10min (300); the excess is dropped and counted as rate_limited (stats + report). RTC windows and self-telemetry are exempt. - RTC stats: platforms push raw getStats() readings as RtcStatsSample every 1-2 s; StatsWindows folds them per track and direction into one lk.rtc.stats.sample per stats_window_ms (15 s, stretched with the cadence): cumulative counters as the last value (monotonic, W3C webrtc-stats model), gauges as min/max/avg. Windows close on the stats tick, on background and on shutdown; the window ticker starts a full period out so early readings are not shipped as a zero-length window. - Session attributes: Telemetry::set_attribute(key, value) attaches lk.room.sid / lk.participant.identity / app correlation ids to every record at encode time without overriding explicit ones. UniFFI: Telemetry.recordStats(sample:), Telemetry.setAttribute(key:value:), RtcStatsSample / TrackKind / StreamDirection. ios-arm64 dylib 1,211,720 B (+17.6 KB, mostly the 21-field RtcStatsSample record lowering). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
livekit-runtime was removed upstream (#1375): livekit-telemetry now uses tokio::time directly. livekit-net gained its UniFFI bridge (#1290) and livekit-signaling/livekit-region were extracted from livekit-api (#1345); the new crates are moved onto the workspace prost like the rest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two ways for a host to move telemetry bytes, chosen per binding: - Telemetry(config, transport: Option<TelemetryTransport>): None falls back to the HTTP client the host registered with livekit-net (set_http_client), so a platform that already brought its client for signaling needs nothing more. Fails with TelemetryError::NoTransport when neither exists. - Telemetry::new_pulled(config, TelemetryExportQueue): Rust never calls into the host. The exporter queues each ExportRequest; the host awaits next(), performs it on its own thread and reports back with complete(id, error). The second exists for uniffi-dart: its foreign-trait callbacks are isolate-bound (Pointer.fromFunction) and the Dart VM aborts with "Cannot invoke native callback outside an isolate" when the exporter invokes send() from a tokio worker - reproduced with a Dart TelemetryTransport. Swift and Kotlin callbacks are thread-agnostic and keep using the trait directly. support/dart/test/telemetry_test.dart covers the pull path end to end (emit + stats window -> two ExportRequests served from Dart) and the no-transport error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A span is one attempt at an operation; the session is the trace. The core mints the trace id when the pipeline starts (rand, non-zero) and attaches it to every log record; records emitted inside a span carry its span_id too. - Spans registry: begin_span(name, kind, parent) -> u64 handle, add_span_event(handle, name, attrs) for checkpoints, end_span(handle, outcome, error_type, attrs). Handles are opaque u64s; ambient context is the platform's job. OTel span limits (128 events/attrs), 256 open spans, finished spans bounded like the event queue (drop-oldest, counted). - Outcome: OTel status only knows Unset/Ok/Error and instrumentation should not set Ok, so ok and cancelled export as Unset and every span carries lk.outcome = ok|error|cancelled (+ error.type and the status message on error). Checkpoints are span events in the span's own envelope (OTEP 4430 keeps that legal); real events stay log records pointing at the span. - Wire: opentelemetry-proto `trace` types, ExportTraceServiceRequest; a second signal through the same BatchCache - batch ids are now <unix_ns>-<seq>-<count>-<l|t> (old ids read as logs) and upload picks traces_endpoint, derived from the logs endpoint by replacing the last "logs" path segment (covers /v1/logs and /observability/logs/otlp/v0) or set explicitly. - UniFFI: traceId(), beginSpan, addSpanEvent, endSpan; SpanKind, SpanOutcome; TelemetryEvent.spanId. - SPEC.md: span rules, lk.connect and lk.reconnect definitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Telemetry must never win over media, so uploads are shaped as well as batched: gzip bodies (level 1, compressed when cached), a `Priority: u=7` hint, at most `max_batches_per_upload` (4) cached batches per tick, and holds while `lk.connect`/`lk.reconnect` are open, while WebRTC reports an outbound track bandwidth-limited (derived from the stats we already get), or while the device asks for quiet (constrained network, battery <= 10 % unplugged). A hold lasts 60 s at most; shutdown drains without the budget. `DeviceState` grows memory pressure, network path (type, expensive, constrained) and battery; each feeds `cadence_factor` and three new `lk.device.*.changed` events (battery on bucket crossings only, OTel `hw.battery.*` names, `network.connection.type`). A CPU-limited encoder (`qualityLimitationDurations.cpu`) doubles the cadence too - CPU is never measured directly, per the design doc. The exporter schedules ticks as `next = last + period` so a cadence change applies to the pending tick in both directions, and reads the device state synchronously (a command-only copy raced with emit + flush). `session.id` joins the resource attributes. SPEC.md documents the events, the cadence table and the upload policy. `target-*/` is ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gnostics The queue tracks its approximate size: crossing `flush_threshold_bytes` (256 KiB) wakes the exporter instead of waiting for the tick, and a request never carries more than `max_batch_bytes` (1 MiB before compression) - the design doc's "flush every 15 s or at 256 KB; single POST <= 1 MB". Both go through the same budget and holds, so an early flush is never a burst. `emit_custom(name, attributes)` is the stringly-typed escape hatch next to the `lk.*` catalogue: events ship as `custom.<name>`, so they can neither collide with nor spoof SDK events and the backend can quota the namespace. Self-diagnostics audit: uploads now report bytes on the wire and split timeouts from other failures; cache eviction (max size / age) is counted as `dropped.cache_full` instead of being silent (`BatchCache::push` returns the evicted ids); holds that hit the 60 s cap are counted; and one `lk.telemetry.report` is always emitted at shutdown as the session summary, so healthy sessions contribute denominators too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d destination The design doc wants buffering from SDK init and flushing once a granted token is available; the Swift integration started the pipeline at connect(). The core now models what that needs: - `Telemetry` is the process pipeline (queue, cache, exporter, device state, self-telemetry). `Telemetry::begin_session()` returns a `Session` - one room, one call - with its own trace id and attributes; queued records, spans and RTC windows are filed under the session that produced them and carry `session.id` as a record attribute (no longer a resource attribute). Everything emitted outside a session belongs to the pipeline's process session. A log record emitted with a span id is filed under that span's session, so SDK loggers need no session plumbing. - `TelemetryConfig.endpoint` is optional. Without one the pipeline buffers and caches; uploads wait (uncapped) for `set_destination(endpoint, headers)`, which the first connect supplies from the server URL and token. Calling it again redirects later batches. UniFFI gains `TelemetrySession`, `begin_session` and `set_destination`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
Changeset incompleteThis PR's changeset is missing version bumps for packages that are affected by the change. The following packages still require a bump:
Already covered:
A package must be bumped when its own files change, and whenever a package it depends on is bumped (so downstream consumers get a matching release). Click here to create a changeset for the missing packages The link pre-populates a changeset file with If this change doesn't require a version bump, add the |
`Telemetry::set_attribute` now attaches an attribute to every record of every session (an `enduser.id`, a tenant), merged at export after the session's own attributes and never overriding a record's explicit key. Session identity stays on `Session::set_attribute`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Log viewers key their line on the body and not every backend surfaces event_name yet, so attribute-only events (RTC windows, device changes) rendered as empty lines in Loki. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…severity Events left the device with attributes only; OTel calls an event's body its display message, log viewers key their line on it, and Loki has no EventName mapping yet, so RTC windows and device changes rendered as blank info lines. Every event now carries a one-line summary - `video outbound: 1204 kbps, loss 0.4%, rtt 48 ms, 30 fps`, `battery: 95%, charging`, `telemetry: 7 batches sent (4223 B), 0 failed, 0 dropped, 0 cached` - and `otel.event.name` (semconv 1.39) so backends without EventName can still tell events from logs. The name remains the last-resort body. `TelemetryConfig.log_severity` (default warn) is the threshold a plain log record needs to leave the device; events are not subject to it. Severity is now ordered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A record naming a span is filed under that span's session, but the lookup only knew open spans. SDK log paths hop threads, so a warning logged right before its span ends (a failing publish) arrived after the span had moved on and fell into the process session. Spans now remember their session for the last 1024 spans, open, ended or exported. Found by the e2e test that checks span-to-log attribution. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Holds, the hold cap, pauses after failures, the missing destination and every sent batch now log at debug, so a host's console shows why nothing is leaving the device. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WebRTC reports qualityLimitationReason=bandwidth for minutes during a normal ramp-up and for as long as an encoder stalls. On a real iPhone a camera track at 0 kbps kept the hold alive for 8 minutes; only the 60 s cap let one batch per minute out while the cache grew by 11 per minute. Yielding to media is the transport's job (Priority: u=7, background service class). The cpu counter still stretches the cadence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pblazej
force-pushed
the
blaze/telemetry
branch
from
September 4, 2026 11:29
ed02c1c to
f486eb8
Compare
…port Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hold begins (with reason) and ends (with duration), pause after a failure or a collector's Retry-After and its end, cadence changes with what stretched them, destination arrival: one debug line each. The per-tick repeats drop to trace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The upload policy's state (ok, held, paused, throttled, waiting, off) is part of TelemetryStats and leads every console line, followed by one backlog number and one loss number. Log lines drop the 'telemetry:' prefix; the target already names the module. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pblazej
force-pushed
the
blaze/telemetry
branch
from
September 4, 2026 12:35
a951a3b to
6f17b80
Compare
An upload outage logs its first failure at warn with the transport's reason and its recovery at info; a collector throttle, the 60 s hold cap, a full queue and a tripped flood guard log at warn once per episode; a rejected batch is data lost and logs at error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two SDK-independent rules move out of the platforms: the Cloud observability endpoint and bearer header derive from the room's server URL and token in set_server; captured log lines arrive as a typed LogRecord and the core builds the record (semconv code.*, lk.log.source, lk.log.logger) and applies the per-source floor (WebRTC only at error, its own module never). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce events, simulcast fold The span moves into the core: SpanName (kind implied), SpanStep checkpoints, SpanTrack, TraceContext, and a Span object that stamps the clock inside every synchronous call, exports when bound to a session and describes itself the same way on every platform. TelemetryResource and Sdk lower to semconv resource keys; RoomIdentity sets the session's lk.room.* / lk.participant.*; DeviceEvent builds audio route, interruption and permission-denied records; RtcStatsSample.layer lets the core fold simulcast layers into one series per track. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A scope is a trace id and the room's identity attributes on one shared pipeline; the name now says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Over the FFI a platform gets Scope::start and, on the span, step, set_attribute, set_track, end, fail, cancel, is_ended, context, total_secs and describe. The string span API is test-only, SpanKind is implied by SpanName and no longer exported, process-level and detached spans stay Rust-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Telemetry
Draft:
livekit-telemetry, a shared client-telemetry core exposed over UniFFI, implementing the "Client Telemetry" design doc. The wire spec — events, attributes, cadence and upload policy — lives inlivekit-telemetry/SPEC.md.Design
Session(trace id) per room: SDKs push warn/error logs, spans,getStats()readings and device state; the core owns everything downstream.TelemetryTransport.lk.telemetry.report), a flood guard, disk-full and collector-throttling handling are built in.Integration
livekit-uniffiexposesTelemetryandTelemetrySession; the Swift SDK is the reference integration: livekit/client-sdk-swift#1108. The first commit unifies the workspace on prost 0.14 and is cherry-pickable on its own.Size
iOS arm64
liblivekit_uniffi.dylib: +180 KiB overmain, within the doc's ~300 KB budget (the iOS size gate is already stale formain).