Skip to content

feat(uniffi): expose the telemetry core - #1396

Open
pblazej wants to merge 4 commits into
blaze/telemetry-stack/7-process-pipelinefrom
blaze/telemetry
Open

pblazej wants to merge 4 commits into
blaze/telemetry-stack/7-process-pipelinefrom
blaze/telemetry

Conversation

@pblazej

@pblazej pblazej commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

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 state
  • log_forward.rs: the core's own warnings and errors copied into telemetry
  • Dart tests
  • The telemetry_ping example and the README's local testing guide
  • One lifecycle task carries out configure, shutdown and the opt-out's purge one at a time, in the order they were sent; telemetry_configure waits until its pipeline is installed
  • A replacing exporter starts only once the one before it stopped (drained within its bound, or cancelled and joined), so two exporters never replay a shared cache at once
  • telemetry_shutdown returns once every exporter queued before it stopped, and a shutdown that resumes late never ends a newer lifecycle
  • The pull queue never hands out or keeps a request once finish returned, and a pulled configure refused after the opt-out finishes its queue
  • Platform log lines passed to telemetry_log or TelemetryScope.log get the same credential masking as the core's own copied lines
What a platform calls, and when
When Call
SDK init telemetry_configure(config, transport, instruments) — storage and tuning only; Dart: telemetry_configure_pulled(config, []) + queue.try_next()
Room created telemetry_scope()
Connect, and every token refresh scope.set_server(url, token) — cheap, idempotent
Room lifecycle scope.start(SpanName, parent) → step / set_track / end; subscribe_started / subscribed (tracks already in the room at join: subscribe_started at connect, so lk.subscribe measures from join; a lone subscribed still opens the span as a fallback) / subscribe_failed; track_ended(sid); set_room; disconnected(reason)
Stats every scope.stats_poll_interval_ms(): scope.record_peer_stats(report, {trackId → sid}, ts) per peer connection
OS signals telemetry_set_device_state (thermal Unknown / low power None without a source), telemetry_device_event, telemetry_log
App API (the only public surface) scope.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 each getStats or submit

No 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.

# 1. Local OTel backend (OTLP/HTTP on :4318, UI on :3000)
docker run -d --name lk-lgtm -p 3000:3000 -p 4318:4318 grafana/otel-lgtm

# 2. Record a small session (lk.connect with its checkpoints, lk.publish, lk.subscribe ended by
#    first media, a custom event) and export it to the local backend; it prints the trace id
LK_TELEMETRY_ENDPOINT=http://localhost:4318 cargo run -p telemetry_ping

# 3. Optional: the same through the Dart bindings (pull queue)
(cd livekit-ffi && cargo make dart-package) && cd livekit-ffi/packages/dart && dart pub get \
  && LK_TELEMETRY_ENDPOINT=http://localhost:4318 dart test test/live_collector_test.dart

# 4. Optional: the file cache — stop the backend, run step 2 with LK_TELEMETRY_DIR=/tmp/lk-telemetry,
#    start the backend again and run once more to watch the cached batches replay

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 set LK_TELEMETRY_ENDPOINT the same way (with livekit-server --dev for a real Room).

Verification

At a2558a40, from a clean checkout:

  • cargo fmt --check
  • cargo clippy -p livekit-telemetry --all-targets -- -D warnings, also with --all-features and as a cargo hack --feature-powerset --depth 2 run
  • cargo test -p livekit-telemetry: 275 unit, 2 doc
  • RUSTDOCFLAGS="-D warnings" cargo doc -p livekit-telemetry --no-deps
  • cargo test -p livekit-ffi --no-default-features --features core-modules: 34
  • cargo clippy -p livekit-ffi --no-default-features --features core-modules --all-targets: only the 7 warnings that main (6473ec17) has
  • cargo clippy -p telemetry_ping --all-targets -- -D warnings
  • Kotlin: cargo make --profile release android-package in livekit-ffi generates the bindings and assembleRelease succeeds, and a fresh ./gradlew compileReleaseKotlin --rerun-tasks passes. android-check-size fails: the arm64 library is 2,298,256 bytes against a limit of 1,769,472 bytes (main: 1,593,408 bytes)
  • Dart: cargo make dart-package, dart pub get, dart test: 7 pass, 1 skip. The skipped live collector test passes against a local collector

Stages 2–7 pass the same livekit-telemetry gates on their own tips (see each PR).

@pblazej
pblazej force-pushed the blaze/telemetry branch 2 times, most recently from a951a3b to 6f17b80 Compare September 4, 2026 12:35
@pblazej pblazej added the internal to tag changes that don't require changelog documentation label Oct 1, 2026
@pblazej pblazej changed the title Telemetry feat(uniffi): expose the telemetry core Oct 1, 2026
@pblazej
pblazej changed the base branch from main to blaze/telemetry-stack/7-process-pipeline October 1, 2026 13:53
@pblazej
pblazej added this pull request to stack #1486 October 1, 2026 14:23
@pblazej
pblazej marked this pull request as ready for review October 1, 2026 14:33
@pblazej
pblazej requested a review from ladvoc as a code owner October 1, 2026 14:33
@pblazej
pblazej requested review from 1egoman, davidliu, hiroshihorie, lukasIO and xianshijing-lk and removed request for ladvoc October 1, 2026 14:33
devin-ai-integration[bot]

This comment was marked as resolved.

`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`.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal to tag changes that don't require changelog documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant