-
Notifications
You must be signed in to change notification settings - Fork 260
feat(telemetry): add the crate with its event model and OTLP encoding #1479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+1,983
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| livekit-telemetry: minor | ||
| --- | ||
|
|
||
| Add `livekit-telemetry`, the shared client telemetry core: records (events, warn/error logs, spans, RTC stats windows) are buffered on the device, committed to a write-ahead cache (memory, or fsynced files with `storage_dir`) and exported as OTLP/HTTP logs and traces to the LiveKit Cloud project each Room belongs to. A Room hands over only its server URL and token (`Scope::set_server`, again on every refresh); the core validates the URL, reads the token's grant and expiry, and uploads each record with the token of the Room and project that captured it. Every collector answer is classified (partial success, 4xx, 413 split, 401/403 disabled vs credential, 404, 429/503 `Retry-After`/`RetryInfo`, 5xx, no answer) with jittered backoff per destination, soft and hard device holds, loss counters by reason (`lk.telemetry.report`, `Telemetry::stats`), Room-scoped custom events and correlation attributes, and an opt-out that purges everything unsent. Defaults export and window RTC stats once a minute; `LK_TELEMETRY_ENDPOINT` points uploads at a local collector for tests. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # Changelog |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| [package] | ||
| name = "livekit-telemetry" | ||
| description = "Client telemetry core for LiveKit: buffers events on-device and exports them as OTLP" | ||
| version = "0.1.0" | ||
| readme = "README.md" | ||
| license.workspace = true | ||
| edition.workspace = true | ||
| repository.workspace = true | ||
|
|
||
| [dependencies] | ||
| log = { workspace = true } | ||
| prost = { workspace = true } | ||
| rand = { workspace = true } | ||
| # OTLP message types only (`gen-tonic-messages` = prost structs, no tonic). Same prost as | ||
| # livekit-protocol so a single prost is linked. | ||
| opentelemetry-proto = { version = "0.32", default-features = false, features = ["logs", "trace", "gen-tonic-messages"] } | ||
| uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns"], optional = true } | ||
|
|
||
| [features] | ||
| uniffi = ["dep:uniffi"] | ||
|
|
||
| # How CI checks this crate's features, read by | ||
| # `.github/workflows/feature-combinations-curated.yml` via `cargo metadata`. | ||
| [package.metadata.feature-combinations] | ||
| mode = "powerset" | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| # Client telemetry spec | ||
|
|
||
| Source of truth for event names, attributes and cadences emitted by LiveKit client SDKs. | ||
| Additive-only by convention; LiveKit-defined names carry the `lk.` prefix, everything else | ||
| follows [OpenTelemetry semantic conventions](https://github.com/open-telemetry/semantic-conventions). | ||
|
|
||
| ## Resource attributes | ||
|
|
||
| Set once per pipeline (`TelemetryConfig.resource`): | ||
|
|
||
| | Key | Who sets it | Example | | ||
| |---|---|---| | ||
| | `service.name` | platform SDK | `livekit-client-swift` | | ||
| | `service.version` | platform SDK | `2.9.0` | | ||
| | `os.name`, `os.version` | platform SDK | `iOS`, `18.5` | | ||
| | `device.model.identifier` | platform SDK | `iPhone16,1` | | ||
| | `telemetry.sdk.name/language/version` | core | `livekit-telemetry`, `rust`, `0.1.0` | | ||
|
|
||
| ## Events | ||
|
|
||
| An event with no `body` is exported with its name as the body as well as in `event_name`: log | ||
| viewers key their line on the body, and not every backend surfaces `event_name` yet. | ||
|
|
||
| ```yaml | ||
| event: lk.ping | ||
| area: sdk | ||
| severity: info | ||
| attributes: | ||
| lk.ping.seq: int # optional, monotonically increasing per pipeline | ||
| cadence: on demand — pipeline smoke test, never emitted in production paths | ||
| platforms: all | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.telemetry.report | ||
| area: sdk (self-telemetry) | ||
| severity: info | ||
| attributes: # counts since the previous report | ||
| lk.telemetry.uploads.sent: int # batches accepted | ||
| lk.telemetry.uploads.bytes: int # compressed bytes accepted — what telemetry cost the uplink | ||
| lk.telemetry.uploads.failed: int # attempts that failed transiently (no answer, 429, 5xx) | ||
| lk.telemetry.cache.batches: int # batches waiting in the cache right now (a gauge) | ||
| # the rest only when non-zero: | ||
| lk.telemetry.uploads.timeouts: int # attempts that hit export_timeout_ms | ||
| lk.telemetry.uploads.unauthorized: int # 401/403 answers: tokens the collector refused | ||
| lk.telemetry.holds.capped: int # soft holds that reached the 60 s cap | ||
| lk.telemetry.cache.write_errors: int # batches the disk refused (full, gone), kept in memory | ||
| lk.telemetry.dropped.queue_full: int # records evicted from the in-memory queue | ||
| lk.telemetry.dropped.cache_error: int # records no cache, not even memory, could take | ||
| lk.telemetry.dropped.cache_full: int # records evicted by the cache's size / file-count bound | ||
| lk.telemetry.dropped.expired: int # records in batches past the 24 h age limit | ||
| lk.telemetry.dropped.corrupt: int # records in cached batches that failed their CRC | ||
| lk.telemetry.dropped.invalid: int # custom events / attributes over the limits (rejected) | ||
| lk.telemetry.dropped.rejected: int # records the collector rejected (final 4xx/5xx, partial success) | ||
| lk.telemetry.dropped.oversized: int # single records larger than the collector accepts (413) | ||
| lk.telemetry.dropped.throttled: int # records evicted from the cache during a server-directed pause | ||
| lk.telemetry.dropped.rate_limited: int # discrete events dropped by the flood guard | ||
| cadence: appended to the next upload whenever a loss, a failure, a refusal, a capped hold or a | ||
| disk error happened since the previous report — never its own request, never persisted | ||
| on its own, so a broken uploader never reports through itself — and once at shutdown as | ||
| the session summary, so fleet-wide success rates have denominators. Losses by policy | ||
| (a project that receives nothing, the opt-out) are local only (`Telemetry::stats`). | ||
| platforms: all | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.thermal.changed | ||
| area: device | ||
| attributes: | ||
| lk.device.thermal.state: enum(nominal | fair | serious | critical) | ||
| cadence: on change (+ initial value on the first `set_device_state`); `unknown` (no thermal | ||
| source on the platform, the default) is no reading: no event, no stretch | ||
| platforms: ios, macos, android — optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.low_power.changed | ||
| area: device | ||
| attributes: | ||
| lk.device.low_power.enabled: bool | ||
| cadence: on change (+ initial value); `None` (no source on the platform, the default) is no | ||
| reading: no event, no stretch | ||
| platforms: ios, macos, android — optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.app_state.changed | ||
| area: device | ||
| attributes: | ||
| lk.device.app_state: enum(foreground | background) | ||
| cadence: on change (+ initial value); entering background also forces a flush | ||
| platforms: all | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.memory.changed | ||
| area: device | ||
| attributes: | ||
| lk.device.memory.pressure: enum(normal | warning | critical) | ||
| # Apple: DispatchSource memory-pressure levels; Android onTrimMemory: RUNNING_LOW / | ||
| # BACKGROUND → warning, RUNNING_CRITICAL / COMPLETE → critical | ||
| cadence: on change (+ initial value) | ||
| platforms: ios, macos, android — optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.network.changed | ||
| area: device | ||
| attributes: | ||
| network.connection.type: enum(wifi | cell | wired | vpn | bluetooth | other | unavailable | unknown) # OTel semconv | ||
| lk.device.network.expensive: bool # cellular / hotspot (NWPath.isExpensive, metered) | ||
| lk.device.network.constrained: bool # Low Data Mode / Data Saver / navigator.connection.saveData | ||
| cadence: on change of any attribute (+ initial value) | ||
| platforms: ios, macos, android — web: Chromium only | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.battery.changed | ||
| area: device | ||
| attributes: | ||
| hw.battery.charge: double # 0.0–1.0 (OTel hardware semconv) | ||
| hw.battery.state: enum(charging | discharging) # OTel hardware semconv | ||
| cadence: on charging change and when the level crosses 20 % or 10 % unplugged — never per | ||
| percent; silent where the level is unknown (desktops, tvOS) | ||
| platforms: ios, android — optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.audio_route.changed | ||
| area: device | ||
| attributes: | ||
| lk.device.audio_route.reason: enum(new_device | old_device_unavailable | category_change | override | wake_from_sleep | no_suitable_route | route_configuration_change | unknown) # AVAudioSession names; `unknown` where the platform gives none | ||
| lk.device.audio_route.outputs: string # comma-separated enum(speaker | receiver | wired_headset | bluetooth | car_audio | air_play | hdmi | usb | other) | ||
| cadence: on change | ||
| platforms: ios — android: audio device callbacks; optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.audio.interruption | ||
| area: device | ||
| attributes: | ||
| lk.device.audio.interruption: enum(began | ended) | ||
| cadence: on change | ||
| platforms: ios — android: audio focus loss/gain; optional elsewhere | ||
| ``` | ||
|
|
||
| ```yaml | ||
| event: lk.device.capture.failed | ||
| area: device | ||
| severity: warn | ||
| attributes: | ||
| lk.device.capture.device: enum(camera | microphone | screen_share) | ||
| lk.device.capture.reason: enum(permission_denied | not_found | in_use | disconnected | other) # the getUserMedia failure taxonomy | ||
| cadence: on failure | ||
| platforms: all — ios: authorization status, capture interruptions; android: permission checks, camera callbacks; web: DOMException names | ||
| ``` | ||
|
|
||
| ## Log records | ||
|
|
||
| A `TelemetryEvent` with an empty `name` is a plain log record (OTLP log without `event_name`): | ||
| `severity` + `body` (the message) + `code.function.name`, `code.file.path`, `code.line.number` | ||
| (semconv), `lk.log.source` (`sdk` | `ffi` | `webrtc`) and `lk.log.logger` (type, module or file). The | ||
| platform hands the core a typed `LogRecord` via `log(record)`; the core applies the floor: WebRTC only | ||
| at `error`, the SDK and the core at the configured `log_severity`, the core's own telemetry module | ||
| never. Only `warn` and `error` records | ||
| leave the device; `trace`/`debug`/`info` are dropped in `emit`. | ||
|
|
||
| The core's own warnings and errors (Rust `log` records from `livekit*` targets, never | ||
| `livekit_telemetry*`) reach the pipeline by themselves through `livekit-uniffi`'s log forwarder: | ||
| it copies them as `ffi` records — every JWT-shaped substring (`eyJ…` `.` … `.` …) masked as | ||
| `<jwt>`, so a token quoted in an error never leaves the device — and forwards the console entry | ||
| unchanged. The copy is active wherever the platform calls `log_forward_bootstrap` (Swift's | ||
| default `OSLogger` with `ffi: true` does), whatever level it passes: that level filters only what | ||
| is forwarded to the platform's console (the global `log` level is kept at `warn` or looser) — Rust allows one logger per process, and without the | ||
| forwarder the core's records go nowhere. Platforms must not feed forwarded Rust log entries | ||
| (what `log_forward_receive` returns) to `telemetry_log` / `log(record)`: the core already copied | ||
| them, so they would be counted twice. `telemetry_log` is for the platform's own and WebRTC's | ||
| lines. | ||
|
|
||
| ## Spans | ||
|
|
||
| A span is **one attempt** at an operation. The scope (one Room connection lifetime, across | ||
| reconnects) is the trace; its id is generated by the core when the pipeline starts and rides on | ||
| every span and log record. Spans are exported when they end — never a long-lived scope span. | ||
|
|
||
| | Rule | Value | | ||
| |---|---| | ||
| | Names | `lk.connect`, `lk.reconnect`, `lk.publish`, `lk.subscribe` — verbs, never ids | | ||
| | Kind | `CLIENT` for connect/reconnect (a call to the SFU), `INTERNAL` otherwise | | ||
| | Status | OTel `Unset` on success **and** cancellation, `Error` (+ `error.type`, message) on failure | | ||
| | `lk.outcome` | always present: `ok` \| `error` \| `cancelled` — rollups read this, never the status | | ||
| | `error.type` | platform-defined, a type name (≤ 128 bytes), never a message: e.g. Swift sends `LiveKitError.<numeric code>`, `CancellationError` or the Swift error type; dashboards group by it per `service.name` | | ||
| | Checkpoints | span events in the span's envelope (`ws_open`, `join_recv`, `pc_connected`, `attempt 2 full`, …); real events stay log records pointing at the span via `span_id` | | ||
| | Limits | 128 events and 128 attributes per span (OTel defaults); 256 open spans per pipeline | | ||
|
|
||
| ```yaml | ||
| span: lk.connect | ||
| kind: client | ||
| attributes: | ||
| lk.connect.attempt: int # 1 for the user-initiated connect | ||
| checkpoints: | ||
| required: ws_open, signal, join_recv, pc_created # every platform, in this order | ||
| best-effort: engine, pc_connected, offer_sent, answer_sent, room_connected # where the SDK has the moment | ||
| outcome: ok | error (error.type) | cancelled | ||
| ``` | ||
|
|
||
| Dashboards compute connect phases only from the required checkpoints; best-effort ones refine | ||
| a platform's own view and may be missing or ordered differently (Android reports seven of the | ||
| nine today). | ||
|
|
||
| ```yaml | ||
| span: lk.reconnect | ||
| kind: client | ||
| attributes: | ||
| lk.reconnect.reason: enum(signal_disconnected | publisher_failed | subscriber_failed | transport_failed | switch_candidate | network_changed | debug | unknown) | ||
| lk.reconnect.mode: enum(quick | full) # mode of the last attempt | ||
| lk.reconnect.attempts: int | ||
| checkpoints: "attempt <n> <mode>" per attempt | ||
| outcome: ok | error | cancelled # cancelled when disconnect() or a newer reconnect wins | ||
| ``` | ||
|
|
||
| ```yaml | ||
| span: lk.publish | ||
| kind: internal | ||
| parent: the ambient span, when any; a pre-connect publish (a microphone published before the | ||
| connect completes) is its own span in the session's trace — `lk.connect` has ended by then | ||
| attributes: | ||
| lk.track.kind: enum(audio | video) | ||
| lk.track.source: enum(camera | microphone | screen_share | screen_share_audio | unknown) | ||
| lk.track.sid: string # on success | ||
| outcome: ok | error (error.type) | cancelled | ||
| ``` | ||
|
|
||
| ```yaml | ||
| span: lk.subscribe | ||
| kind: internal | ||
| starts: when the intent to subscribe exists — a remote publish under autoSubscribe, or the | ||
| manual subscribe call. Tracks already in the room at join: platforms call | ||
| `subscribe_started` for each at connect (the join response lists them), so `lk.subscribe` | ||
| measures from join on every platform; a `subscribed` with no intent before it still opens | ||
| the span (a fallback, measured from the confirmation) | ||
| ends: at first media (the first inbound stats reading with bytes; the core sees it) → ok; | ||
| unsubscribe / unpublish before media → cancelled; | ||
| subscription failure → error; no media within 30 s → error (error.type = timed_out), | ||
| enforced on the core's own clock (no reading needed) and kept through a later disconnect | ||
| owner: the core (`Scope::subscribe_started / subscribed / track_ended / subscribe_failed`); | ||
| an SDK only reports the remote track's lifecycle | ||
| attributes: | ||
| lk.track.sid: string | ||
| lk.track.kind: enum(audio | video) | ||
| lk.track.source: enum(camera | microphone | screen_share | screen_share_audio | unknown) | ||
| lk.participant.remote_identity: string | ||
| checkpoints: subscribed, first_media | ||
| ``` |
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Missing README blocks crate publishing
When packaging
livekit-telemetry, Cargo cannot find the declaredREADME.md. The crate cannot be published.Learn more
Cargo uses the package's
readmeentry to include its README in the published archive. The new crate declaresREADME.md, but its directory contains onlyCHANGELOG.md,SPEC.md, Cargo.toml,src, anduniffi.toml. Packaging therefore fails before a release can publish the crate.Example: A release job packages
livekit-telemetryat version 0.1.0. Cargo looks forlivekit-telemetry/README.md, fails to find it, and does not produce a package.Recommended fix: Add the declared README to the crate, or remove the explicit
readmesetting if the crate intentionally ships without one. Validate withcargo package -p livekit-telemetry.Was this helpful? React with 👍 or 👎 to provide feedback.