Repository navigation
Conversation
a951a3b to
6f17b80
Compare
6aba1b6 to
a9ec8d5
Compare
a9ec8d5 to
3d50ec6
Compare
`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());
There was a problem hiding this comment.
| 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.
There was a problem hiding this comment.
I need to dig into the telemetry PRs in depth, sorry I still haven't. That being said, is NetTransport::from_registry actually blocking for a signifigant amount of time? From a quick glance at the code it looks like it's all fairly fast synchronous code and no blocking IO or anything like that.
In case it matters, In the AIC plugin, there have been some recent changes to move synchronous blocking work into a background thread, though that is fairly straightforward thing to do in that case because the return value isn't needed, it's "fire and forget". Here it looks like the return value is important so it might be more involved.
If you want to chat about this more feel free to reach out on slack and I'll share some of the non public AIC plugin PR links.
There was a problem hiding this comment.
The measured registry lookup was cheap, but instrument startup can block configure or deadlock if it waits on the caller. 81393b49 documents synchronous configure and its callback restrictions; the early-answer prototype remains excluded because it changes readiness semantics.
There was a problem hiding this comment.
The measured mobile registry path performs a lookup and an Arc clone, rather than a network request. We retained synchronous configure and documented its blocking work in 81393b49; fire-and-forget would weaken installation-before-return semantics.
| /// 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
There was a problem hiding this comment.
A stalled upload used roughly the default ten-second deadline in the desktop test, and draining generations can accumulate waits. 81393b49 documents the cooperative bounds and cache recovery; the existing drain is retained because persist-only shutdown or a shorter cap changes delivery behavior.
…finish Replaces nothing_is_served_after_finish_returns, which checked only the in-flight map. The new tests check the returned value, the answer path, a busy poller, a send waiting for room and a send holding a permit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…ort queue's finish finish() cleared the in-flight map, so the answer to a request the host already had was ignored and the exporter retried a batch that was uploaded. The in-flight lock stays the single commit point for a hand-out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
finish() drained the receiver only when no poller held it, and a sender kept from before finish could enqueue afterwards. The poller that holds the receiver now closes and drains it on release, under the lock finish raises its flag under, and a send that enqueued through an earlier permit discards its own request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…is off Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…y runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…eue's finish A poller that read finished == false keeps the in-flight lock until it let the receiver go. The test runs finish inside that release whenever the code lets it, and fails once the lock is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
… finish stops it Covers a permit reserved before finish, an unserved request after its exporter stops waiting, with and without a busy poller, and states the busy-poller limit of finish. The waiting-for-room test now polls by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…h raises its flag under A send that reserved room before finish could enqueue after it, and its request then waited until the same send cleaned it up. The enqueue now checks the flag under the in-flight lock, so it never happens after finish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…stops waiting A queued request kept its body and authorization header until the next send, poll or finish. The exporter's wait guard now closes its answer channel under the in-flight lock and sweeps the receiver; a poller that holds the receiver sweeps when it lets go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…t a hand-out may be seen late Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…t log without a hang Each case runs in its own child process, killed after 20 s, so a hang never holds the process-wide lock for the other tests. Also pins where a record built by log_with goes when the builder replaces or opts out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…peline lock uninstall() dropped the instrument list while the write guard on the installed pipeline was still alive, so a destructor that logged waited on that lock forever. disable() and the test reset now free what they took outside every lock but the lifecycle one too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The candidate read the installed pipeline for every record before it checked the level and target. The forwarder checks them first again, as the PR head did, so other records never touch that lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
… uncontended one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…r review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…n keeps Describes the current behavior only: no readiness, API or shutdown change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…crate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…tirement Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…l retirement Retirement snapshotted the live scopes, released the registry, then pruned destinations with that snapshot. A Room registering and setting its token in between lost it while alive, so its batches waited with no credential. The cache backlog is now listed first, outside any lock, and the registry stays locked from the snapshot through the prune (scopes before destinations; no path takes them in reverse). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The three hand-over tests polled for the first request with no deadline, so a flush that built no request (the Room had no route or token) hung the suite. They now wait at most 10 s and fail saying so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…ng first upload A first-upload wait that timed out panicked with the static slot still holding the lifecycle and exporter on the process runtime. The wait now shuts the pipeline down through the existing shutdown path before it reports that no request arrived, for all three hand-over tests. A new test proves the slot is empty, the exporter is gone with its transport and the waiting flush is answered when the first request never comes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
… first upload The integrated review (B1) asked that a timed-out first-upload wait also keep and join the test's flush task, not only shut the pipeline down. The three callers keep their flush handles; the wait joins it after the bounded shutdown (cancelling and joining it past END_BOUND), and the normal paths join it after their shutdown too. The timeout message now states only what was observed (N1). The failure-path test also proves the pipeline itself is freed, through a Weak on its caller-provided cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
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).