Repository navigation
Conversation
a951a3b to
6f17b80
Compare
6aba1b6 to
a9ec8d5
Compare
a9ec8d5 to
3d50ec6
Compare
`telemetry_configure` installs the process pipeline with a host-implemented `TelemetryTransport` or the `livekit-net` HTTP client, and `telemetry_configure_pulled` hands bindings with thread-bound callbacks (Dart) a bounded pull queue instead. `telemetry_scope` gives each Room its `TelemetryScope`: `set_server` at connect and on every token refresh, spans, the subscribe lifecycle, `record_peer_stats` paced by `stats_poll_interval_ms`, `track_ended`, `disconnected`, and the app API (`emit_custom`, `set_attribute`). `telemetry_disable` is the synchronous opt-out and `telemetry_is_disabled` reads it process-wide; device state, device events and log records come from the host. Dart tests cover the pull queue, and macOS links reserve header padding for Flutter's install name rewrite. The module is a core module of `livekit-ffi` (feature `core-modules`), the crate `livekit-uniffi` merged into.
Once a platform installs the log forwarder, warnings and errors from `livekit*` targets also reach the telemetry pipeline as log records, with any compact JWT and bearer or token credential values masked and the target as the logger (`log` has no function name to fill in); the telemetry crate's own records are never copied. The platform's level still filters only what is forwarded to it, so the console output is unchanged.
`telemetry_ping` records a small session (an `lk.connect` span with its checkpoints, an `lk.publish`, an `lk.subscribe` ended by first media, a custom event) and exports it to the collector `LK_TELEMETRY_ENDPOINT` names; the README shows how to run it against a local OpenTelemetry backend, and a Dart test does the same through the pull queue.
One lifecycle task now owns the whole process pipeline lifecycle. Configure, shutdown and the opt-out's purge are commands it carries out one at a time, in the order they were sent: it installs, uninstalls and purges itself, so a configure and a shutdown can no longer interleave. `telemetry_configure` waits until its pipeline is installed, which never includes a drain, and the instruments start on the lifecycle's thread. The lifecycle hands each installed pipeline's exporter to its exporter runner, which runs them one at a time, in install order. A replacing exporter starts only once the one before it stopped: drained within its pipeline's own `shutdown_bound`, or else cancelled and joined at that bound, never left running behind a dropped task handle. Two exporters therefore never replay a shared cache at the same time, and a batch in flight is not sent twice, however many replaces come within one drain. `telemetry_shutdown` returns once every exporter queued before it has stopped; when the slot still holds the lifecycle its command went to, and that command was the last one, it then ends the lifecycle and joins it with its runner and exporter, and the next configure starts a fresh one. Each lifecycle has a generation of its own, so a shutdown that resumes late never ends a newer lifecycle whose last command has the same number. `telemetry_flush` and `telemetry_shutdown` wait for the opt-out's purge through the same command order. `global::uninstall` is public, the synchronous half of `global::shutdown`, for the lifecycle to uninstall in turn. The pull queue's `serve` and `finish` read the finished flag under the in-flight lock, so nothing is handed out or kept once `finish` returned. A pulled configure that is refused after the opt-out finishes its queue, so `next` no longer waits forever. Platform log lines passed to `telemetry_log` or `TelemetryScope.log` get the same credential masking as the core's own copied lines. Its test reads the body uploaded through `TelemetryScope.log`, so it fails if that call drops the mask (`flate2` is a new dev-dependency for it). The pull queue's docs sit on the queue, it has no foreign constructor, and its fields and the span methods document their edge cases. `Pending` is `QueuedExport` and `mask_jwts` is `mask_credentials`. The forwarder maps levels with a new `From<log::Level>` for `Severity`. The module has its license header, the SPEC states the real masking rules, the example uses the workspace edition, the stray `.gitignore` entry is gone, and the `livekit-ffi` changeset names the `log_forward_bootstrap` change. A `livekit-telemetry` changeset records the new conversion and `global::uninstall`.
3d50ec6 to
a2558a4
Compare
| } | ||
|
|
||
| /// Hand `queued` out, unless the queue is finished or its exporter gave up on it. | ||
| fn serve(&self, queued: QueuedExport) -> Option<PendingExport> { |
There was a problem hiding this comment.
there can be a race condition here, like serve() hands a request to the host after finish() has returned:
finish() (751) sets finished inside the inflight lock. A serve already holding that lock has read finished == false, so it inserts and returns Some(export) while finish blocks behind it; finish then clears the map. next() has no re-check on the Some path. The doc on inflight ("nothing is handed out or kept once finish returned") is only half true — the existing test nothing_is_served_after_finish_returns asserts the map is empty but never checks what serve returned.
Impact: the host performs one more upload — carrying Authorization: Bearer — after the queue was finished, and since its entry is gone, complete() is ignored and the exporter retries → duplicate batch. Not an opt-out leak: serve's separate process && global::is_disabled() check covers that path. This is the shutdown/queue-replacement path.
I reproduced it deterministically at both levels, and this 7-line fix makes both go green with all 18 of the PR's telemetry tests still passing:
pub fn finish(&self) {
- // Before the lock: a
servealready holding it then sees the flag on its way out. - self.finished.store(true, Ordering::SeqCst);
{
let mut inflight = self.inflight.lock()...;
-
}
self.finished.store(true, Ordering::SeqCst); inflight.clear();
// in serve(), just before the insert:
+if self.finished.load(Ordering::SeqCst) {
- return None;
+}
inflight.retain(|_, done| !done.is_closed());
| fn log(&self, record: &Record) { | ||
| // Telemetry gets its copy first, built from the borrowed record; the console entry below | ||
| // is exactly what it always was. | ||
| if let Some(copy) = telemetry_copy(record) { |
There was a problem hiding this comment.
what will happen if telemetry is off ? does telemetry_copy cost anything or trigger some warning logs ?
| /// starts, and a configure that gets as far as installing deletes whatever `config.storage_dir` | ||
| /// still holds. | ||
| #[uniffi::export] | ||
| pub fn telemetry_configure( |
There was a problem hiding this comment.
| /// bound for each pipeline still in line. A shutdown that is the last call ends the lifecycle | ||
| /// and joins it; the next configure starts a fresh one. | ||
| #[uniffi::export(async_runtime = "tokio")] | ||
| pub async fn telemetry_shutdown() { |
There was a problem hiding this comment.
I am concerned on this approach, how long will telemetry_shutdown() will delay the shutdown ?
I wonder if we should just stop networking and save the data on device, and the data will be uploaded after the app get opened again
Summary
livekit-telemetry— the shared client-telemetry core — and its UniFFI surface for the Swift, Kotlin and Dart SDKs. Wire spec (events, attributes, cadence, upload policy):livekit-telemetry/SPEC.md.#1566 stacks on this PR: it takes every telemetry name from the protocol registry.
Changes
livekit-ffi/src/core_modules/telemetry.rs: configure and configure-pulled, scopes, spans, stats, the app API, the opt-out, device statelog_forward.rs: the core's own warnings and errors copied into telemetrytelemetry_pingexample and the README's local testing guidetelemetry_configurewaits until its pipeline is installedtelemetry_shutdownreturns once every exporter queued before it stopped, and a shutdown that resumes late never ends a newer lifecyclefinishreturned, and a pulled configure refused after the opt-out finishes its queuetelemetry_logorTelemetryScope.logget the same credential masking as the core's own copied linesWhat a platform calls, and when
telemetry_configure(config, transport, instruments)— storage and tuning only; Dart:telemetry_configure_pulled(config, [])+queue.try_next()telemetry_scope()scope.set_server(url, token)— cheap, idempotentscope.start(SpanName, parent)→step/set_track/end;subscribe_started/subscribed(tracks already in the room at join:subscribe_startedat connect, solk.subscribemeasures from join; a lonesubscribedstill opens the span as a fallback) /subscribe_failed;track_ended(sid);set_room;disconnected(reason)scope.stats_poll_interval_ms():scope.record_peer_stats(report, {trackId → sid}, ts)per peer connectiontelemetry_set_device_state(thermalUnknown/ low powerNonewithout a source),telemetry_device_event,telemetry_logscope.emit_custom(name, {k: v}),scope.set_attribute(key, value?),telemetry_disable()— synchronous, in effect when it returns (TODO on platforms: token discussion);telemetry_is_disabled()reads that opt-out process-wide (every thread and Dart isolate), for platforms to check right before eachgetStatsor submitNo endpoint, header or sink exists in any client-language API. Local end-to-end tests set
LK_TELEMETRY_ENDPOINT(read by the core; see the README).Local testing against a local OpenTelemetry backend (LGTM)
Everything can be tried against a local OpenTelemetry backend (LGTM) in a couple of minutes; no LiveKit server is needed for the Rust example.
Then open http://localhost:3000 → Explore: Loki
{service_name="telemetry_ping"}for the log records (otel.event.name:custom.ping,lk.rtc.stats.sample,lk.telemetry.report), Tempo with the printed trace id for the spans (lk.connect,lk.publish,lk.subscribe). Platform e2e tests setLK_TELEMETRY_ENDPOINTthe same way (withlivekit-server --devfor a real Room).Verification
At
a2558a40, from a clean checkout:cargo fmt --checkcargo clippy -p livekit-telemetry --all-targets -- -D warnings, also with--all-featuresand as acargo hack --feature-powerset --depth 2runcargo test -p livekit-telemetry: 275 unit, 2 docRUSTDOCFLAGS="-D warnings" cargo doc -p livekit-telemetry --no-depscargo test -p livekit-ffi --no-default-features --features core-modules: 34cargo clippy -p livekit-ffi --no-default-features --features core-modules --all-targets: only the 7 warnings thatmain(6473ec17) hascargo clippy -p telemetry_ping --all-targets -- -D warningscargo make --profile release android-packageinlivekit-ffigenerates the bindings andassembleReleasesucceeds, and a fresh./gradlew compileReleaseKotlin --rerun-taskspasses.android-check-sizefails: the arm64 library is 2,298,256 bytes against a limit of 1,769,472 bytes (main: 1,593,408 bytes)cargo make dart-package,dart pub get,dart test: 7 pass, 1 skip. The skipped live collector test passes against a local collectorStages 2–7 pass the same
livekit-telemetrygates on their own tips (see each PR).