diff --git a/.changeset/protocol-semconv.md b/.changeset/protocol-semconv.md new file mode 100644 index 000000000..dc9299926 --- /dev/null +++ b/.changeset/protocol-semconv.md @@ -0,0 +1,5 @@ +--- +livekit-protocol: patch +--- + +Add `semconv`, the telemetry names and enum values generated from the protocol's Weaver registry, and move the protocol submodule to `003613b7`. diff --git a/.changeset/telemetry-semconv.md b/.changeset/telemetry-semconv.md new file mode 100644 index 000000000..437312f21 --- /dev/null +++ b/.changeset/telemetry-semconv.md @@ -0,0 +1,5 @@ +--- +livekit-telemetry: minor +--- + +Take every telemetry name from the protocol registry (`livekit_protocol::semconv`) and send its spelling: `lk.room_sid`, `lk.participant_id`, `lk.track_sid`, `lk.pii.room_name`, `lk.pii.participant_identity`, `lk.pii.remote_participant_identity`, `lk.track_source` with the RTC FFI `TrackSource` names, and `lk.disconnect_reason` with the protobuf `DisconnectReason` names. diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 916a2fe85..7a3d29d8c 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -41,5 +41,6 @@ jobs: -ignore "livekit-protocol/src/livekit.agent.serde.rs" \ -ignore "livekit-protocol/src/logger.rs" \ -ignore "livekit-protocol/src/logger.serde.rs" \ + -ignore "livekit-protocol/src/semconv.rs" \ -ignore "livekit-api/src/public_api/generated.rs" \ **/*.{rs,cpp,c} diff --git a/Cargo.lock b/Cargo.lock index 91650ba92..b09f9df16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4022,6 +4022,7 @@ dependencies = [ "base64 0.22.1", "flate2", "livekit-net", + "livekit-protocol", "log", "opentelemetry-proto", "prost 0.14.4", diff --git a/livekit-protocol/generate_proto.sh b/livekit-protocol/generate_proto.sh index 901adedd7..e359ee5cb 100755 --- a/livekit-protocol/generate_proto.sh +++ b/livekit-protocol/generate_proto.sh @@ -17,8 +17,12 @@ PROTOCOL=${PROTOCOL:-protocol/protobufs} +SEMCONV=${SEMCONV:-protocol/otel/gen/rust/semconv.rs} OUT_RUST=src +# The telemetry names: generated in the protocol repository by Weaver (`otel/generate.sh`). +cp "$SEMCONV" $OUT_RUST/semconv.rs + protoc \ -I=$PROTOCOL \ --prost_out=$OUT_RUST \ diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index 3fbe50985..003613b76 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit 3fbe509850cb24aa90a0bc20134936698333dceb +Subproject commit 003613b76adfcd57f5142be6395b7978ffca917e diff --git a/livekit-protocol/src/lib.rs b/livekit-protocol/src/lib.rs index 0f6566c46..c4023dfe6 100644 --- a/livekit-protocol/src/lib.rs +++ b/livekit-protocol/src/lib.rs @@ -49,3 +49,7 @@ pub mod agent { pub mod logger { include!("logger.rs"); } + +// Generated by `generate_proto.sh` from `protocol/otel/gen/rust/semconv.rs`. +#[rustfmt::skip] +pub mod semconv; diff --git a/livekit-protocol/src/semconv.rs b/livekit-protocol/src/semconv.rs new file mode 100644 index 000000000..2d7f3e16e --- /dev/null +++ b/livekit-protocol/src/semconv.rs @@ -0,0 +1,1615 @@ +// Code generated by weaver from livekit/protocol otel/model. DO NOT EDIT. +//! LiveKit telemetry semantic conventions. +//! +//! In a `log` key-value list, write a constant in parentheses, `(LK_PII_ERROR) = value`: `log` +//! stringifies a bare identifier, so `LK_PII_ERROR = value` sends the key `LK_PII_ERROR`. + +/// The agent's name. +pub const AGENT_NAME: &str = "agent_name"; + +/// Browser name. +pub const BROWSER_NAME: &str = "browser.name"; + +/// Browser version. +pub const BROWSER_VERSION: &str = "browser.version"; + +/// One chat history item, as a map. Carries conversation content. PII: redacted by the collector. +pub const CHAT_ITEM: &str = "chat.item"; + +/// Whether a pooled connection was reused, as the string `true`/`false`. +pub const CONNECTION_REUSED: &str = "connection_reused"; + +/// One evaluation result, as a map of string fields. +pub const EVALUATION: &str = "evaluation"; + +/// Token type (`input` or `output`); deprecated upstream, a dimension of `gen_ai.client.token.usage`. +pub const GEN_AI_TOKEN_TYPE: &str = "gen_ai.token.type"; + +/// LangFuse spelling of audio input tokens. +pub const GEN_AI_USAGE_INPUT_AUDIO_TOKENS: &str = "gen_ai.usage.input_audio_tokens"; + +/// LangFuse spelling of cached input tokens. +pub const GEN_AI_USAGE_INPUT_CACHED_TOKENS: &str = "gen_ai.usage.input_cached_tokens"; + +/// LangFuse spelling of text input tokens. +pub const GEN_AI_USAGE_INPUT_TEXT_TOKENS: &str = "gen_ai.usage.input_text_tokens"; + +/// LangFuse spelling of audio output tokens. +pub const GEN_AI_USAGE_OUTPUT_AUDIO_TOKENS: &str = "gen_ai.usage.output_audio_tokens"; + +/// LangFuse spelling of text output tokens. +pub const GEN_AI_USAGE_OUTPUT_TEXT_TOKENS: &str = "gen_ai.usage.output_text_tokens"; + +/// LangFuse spelling of reasoning tokens. +pub const GEN_AI_USAGE_REASONING_TOKENS: &str = "gen_ai.usage.reasoning_tokens"; + +/// LangFuse's completion start time (ISO 8601), set next to `gen_ai.response.time_to_first_chunk`. +pub const LANGFUSE_OBSERVATION_COMPLETION_START_TIME: &str = "langfuse.observation.completion_start_time"; + +/// Label of the agent running the activity. +pub const LK_AGENT_LABEL: &str = "lk.agent_label"; + +/// Name of the agent a job was dispatched to. +pub const LK_AGENT_NAME: &str = "lk.agent_name"; + +/// AMD classification. +pub const LK_AMD_CATEGORY: &str = "lk.amd.category"; + +/// Seconds from the call start to the AMD decision. Unit: `s`. +pub const LK_AMD_DELAY: &str = "lk.amd.delay"; + +/// Why the AMD classifier decided. +pub const LK_AMD_REASON: &str = "lk.amd.reason"; + +/// Seconds of speech the AMD classifier heard. Unit: `s`. +pub const LK_AMD_SPEECH_DURATION: &str = "lk.amd.speech_duration"; + +/// Blocks in the session. +pub const LK_BLOCKING_COUNT: &str = "lk.blocking.count"; + +/// CPU seconds the loop thread consumed during the block. Unit: `s`. +pub const LK_BLOCKING_CPU_TIME: &str = "lk.blocking.cpu_time"; + +/// Seconds the loop was blocked. Unit: `s`. +pub const LK_BLOCKING_DURATION: &str = "lk.blocking.duration"; + +/// Seconds of garbage collection on the loop thread during the block. Unit: `s`. +pub const LK_BLOCKING_GC_TIME: &str = "lk.blocking.gc_time"; + +/// Module a lazy import was loading when sampled. +pub const LK_BLOCKING_IMPORT: &str = "lk.blocking.import"; + +/// Seconds of the longest block in the session. Unit: `s`. +pub const LK_BLOCKING_MAX_DURATION: &str = "lk.blocking.max_duration"; + +/// Severity bucket of the block. +pub const LK_BLOCKING_SEVERITY: &str = "lk.blocking.severity"; + +/// Loop-thread stack sampled while blocked (source locations only). +pub const LK_BLOCKING_STACK: &str = "lk.blocking.stack"; + +/// Reports dropped by rate limiting since the previous emitted span. +pub const LK_BLOCKING_SUPPRESSED: &str = "lk.blocking.suppressed"; + +/// Name of the task that was running. +pub const LK_BLOCKING_TASK: &str = "lk.blocking.task"; + +/// Seconds above which a block is reported. Unit: `s`. +pub const LK_BLOCKING_THRESHOLD: &str = "lk.blocking.threshold"; + +/// Seconds blocked in total over the session. Unit: `s`. +pub const LK_BLOCKING_TOTAL_DURATION: &str = "lk.blocking.total_duration"; + +/// Name of the shutdown callback. +pub const LK_CALLBACK_NAME: &str = "lk.callback.name"; + +/// The close drained pending speech first. +pub const LK_CLOSE_DRAIN: &str = "lk.close.drain"; + +/// Why the session closed. +pub const LK_CLOSE_REASON: &str = "lk.close_reason"; + +/// LiveKit Cloud agent id. +pub const LK_CLOUD_AGENT_ID: &str = "lk.cloud_agent_id"; + +/// 1-based connect attempt. +pub const LK_CONNECT_ATTEMPT: &str = "lk.connect.attempt"; + +/// Room connection state. +pub const LK_CONNECTION_STATE: &str = "lk.connection_state"; + +/// LiveKit Cloud deployment id. +pub const LK_DEPLOYMENT_ID: &str = "lk.deployment_id"; + +/// Whether the app is in front of the user. +pub const LK_DEVICE_APP_STATE: &str = "lk.device.app_state"; + +/// Values of [`LK_DEVICE_APP_STATE`]. +pub mod lk_device_app_state { + /// In front of the user. + pub const FOREGROUND: &str = "foreground"; + /// In the background. + pub const BACKGROUND: &str = "background"; +} + +/// Phase of an audio session interruption. +pub const LK_DEVICE_AUDIO_INTERRUPTION: &str = "lk.device.audio.interruption"; + +/// Values of [`LK_DEVICE_AUDIO_INTERRUPTION`]. +pub mod lk_device_audio_interruption { + /// Interruption began (another app or a call took the audio session). + pub const BEGAN: &str = "began"; + /// Interruption ended. + pub const ENDED: &str = "ended"; +} + +/// The active outputs, comma-separated. +pub const LK_DEVICE_AUDIO_ROUTE_OUTPUTS: &str = "lk.device.audio_route.outputs"; + +/// Why the audio route changed. +pub const LK_DEVICE_AUDIO_ROUTE_REASON: &str = "lk.device.audio_route.reason"; + +/// Values of [`LK_DEVICE_AUDIO_ROUTE_REASON`]. +pub mod lk_device_audio_route_reason { + /// A new output became available. + pub const NEW_DEVICE: &str = "new_device"; + /// The previous output went away. + pub const OLD_DEVICE_UNAVAILABLE: &str = "old_device_unavailable"; + /// The audio session category changed. + pub const CATEGORY_CHANGE: &str = "category_change"; + /// The route was overridden. + pub const OVERRIDE: &str = "override"; + /// The device woke from sleep. + pub const WAKE_FROM_SLEEP: &str = "wake_from_sleep"; + /// No route fits the category. + pub const NO_SUITABLE_ROUTE: &str = "no_suitable_route"; + /// The route's configuration changed. + pub const ROUTE_CONFIGURATION_CHANGE: &str = "route_configuration_change"; + /// The platform gave no reason. + pub const UNKNOWN: &str = "unknown"; +} + +/// The device is charging. +pub const LK_DEVICE_BATTERY_CHARGING: &str = "lk.device.battery.charging"; + +/// Battery charge in percent, 0-100. Unit: `%`. +pub const LK_DEVICE_BATTERY_LEVEL: &str = "lk.device.battery.level"; + +/// Capture device that failed. +pub const LK_DEVICE_CAPTURE_DEVICE: &str = "lk.device.capture.device"; + +/// Values of [`LK_DEVICE_CAPTURE_DEVICE`]. +pub mod lk_device_capture_device { + /// Camera. + pub const CAMERA: &str = "camera"; + /// Microphone. + pub const MICROPHONE: &str = "microphone"; + /// Screen capture. + pub const SCREEN_SHARE: &str = "screen_share"; +} + +/// Why capture failed, in the getUserMedia failure taxonomy. +pub const LK_DEVICE_CAPTURE_REASON: &str = "lk.device.capture.reason"; + +/// Values of [`LK_DEVICE_CAPTURE_REASON`]. +pub mod lk_device_capture_reason { + /// The user or OS denied access. + pub const PERMISSION_DENIED: &str = "permission_denied"; + /// No such device. + pub const NOT_FOUND: &str = "not_found"; + /// Another app holds the device. + pub const IN_USE: &str = "in_use"; + /// The device went away. + pub const DISCONNECTED: &str = "disconnected"; + /// Anything else. + pub const OTHER: &str = "other"; +} + +/// Low Power Mode / Battery Saver is on. +pub const LK_DEVICE_LOW_POWER_ENABLED: &str = "lk.device.low_power.enabled"; + +/// Memory pressure as the OS reports it. +pub const LK_DEVICE_MEMORY_PRESSURE: &str = "lk.device.memory.pressure"; + +/// Values of [`LK_DEVICE_MEMORY_PRESSURE`]. +pub mod lk_device_memory_pressure { + /// No pressure. + pub const NORMAL: &str = "normal"; + /// Low memory. + pub const WARNING: &str = "warning"; + /// Critically low memory. + pub const CRITICAL: &str = "critical"; +} + +/// The user asked for less traffic (Low Data Mode, Data Saver). +pub const LK_DEVICE_NETWORK_CONSTRAINED: &str = "lk.device.network.constrained"; + +/// The network is metered (cellular, personal hotspot). +pub const LK_DEVICE_NETWORK_EXPENSIVE: &str = "lk.device.network.expensive"; + +/// Thermal pressure as the OS reports it. +pub const LK_DEVICE_THERMAL_STATE: &str = "lk.device.thermal.state"; + +/// Values of [`LK_DEVICE_THERMAL_STATE`]. +pub mod lk_device_thermal_state { + /// Within normal limits. + pub const NOMINAL: &str = "nominal"; + /// Slightly elevated. + pub const FAIR: &str = "fair"; + /// High; the system reduces performance. + pub const SERIOUS: &str = "serious"; + /// Critical; the device needs to cool down. + pub const CRITICAL: &str = "critical"; +} + +/// Why a participant left the room, as the protobuf `DisconnectReason` enum name. +pub const LK_DISCONNECT_REASON: &str = "lk.disconnect_reason"; + +/// Values of [`LK_DISCONNECT_REASON`]. +pub mod lk_disconnect_reason { + /// No reason given, or a number the producer does not know. + pub const UNKNOWN_REASON: &str = "UNKNOWN_REASON"; + /// The participant hung up. + pub const CLIENT_INITIATED: &str = "CLIENT_INITIATED"; + /// Another participant joined with the same identity. + pub const DUPLICATE_IDENTITY: &str = "DUPLICATE_IDENTITY"; + /// The server instance shut down. + pub const SERVER_SHUTDOWN: &str = "SERVER_SHUTDOWN"; + /// RoomService.RemoveParticipant was called. + pub const PARTICIPANT_REMOVED: &str = "PARTICIPANT_REMOVED"; + /// RoomService.DeleteRoom was called. + pub const ROOM_DELETED: &str = "ROOM_DELETED"; + /// The server did not know the session the client tried to resume. + pub const STATE_MISMATCH: &str = "STATE_MISMATCH"; + /// The client could not connect fully. + pub const JOIN_FAILURE: &str = "JOIN_FAILURE"; + /// The server asked the participant to migrate (Cloud only). + pub const MIGRATION: &str = "MIGRATION"; + /// The signal WebSocket closed unexpectedly. + pub const SIGNAL_CLOSE: &str = "SIGNAL_CLOSE"; + /// The room closed after its participants left. + pub const ROOM_CLOSED: &str = "ROOM_CLOSED"; + /// The SIP callee did not answer in time. + pub const USER_UNAVAILABLE: &str = "USER_UNAVAILABLE"; + /// The SIP callee rejected the call. + pub const USER_REJECTED: &str = "USER_REJECTED"; + /// SIP protocol failure. + pub const SIP_TRUNK_FAILURE: &str = "SIP_TRUNK_FAILURE"; + /// The server timed out the session. + pub const CONNECTION_TIMEOUT: &str = "CONNECTION_TIMEOUT"; + /// Media failed or timed out. + pub const MEDIA_FAILURE: &str = "MEDIA_FAILURE"; + /// The agent failed. + pub const AGENT_ERROR: &str = "AGENT_ERROR"; + /// The client ran out of reconnect attempts (client only, not a protobuf value). + pub const RECONNECT_FAILED: &str = "RECONNECT_FAILED"; +} + +/// Agent dispatch id. +pub const LK_DISPATCH_ID: &str = "lk.dispatch_id"; + +/// Seconds from the end of the user's speech to the agent's first audio. Unit: `s`. +pub const LK_E2E_LATENCY: &str = "lk.e2e_latency"; + +/// Seconds from the end of speech to the turn decision. Unit: `s`. +pub const LK_END_OF_TURN_DELAY: &str = "lk.end_of_turn_delay"; + +/// Seconds the detector inference took. Unit: `s`. +pub const LK_EOU_DETECTION_DELAY: &str = "lk.eou.detection_delay"; + +/// Seconds of endpointing delay applied. Unit: `s`. +pub const LK_EOU_ENDPOINTING_DELAY: &str = "lk.eou.endpointing_delay"; + +/// The detector answer came from the cache. +pub const LK_EOU_FROM_CACHE: &str = "lk.eou.from_cache"; + +/// Language the detector ran for. +pub const LK_EOU_LANGUAGE: &str = "lk.eou.language"; + +/// Turn decisions the wait rejected before it ended. +pub const LK_EOU_NOT_COMMITTED_COUNT: &str = "lk.eou.not_committed_count"; + +/// How the endpointing wait ended. +pub const LK_EOU_OUTCOME: &str = "lk.eou.outcome"; + +/// Values of [`LK_EOU_OUTCOME`]. +pub mod lk_eou_outcome { + /// The turn was committed. + pub const COMMITTED: &str = "committed"; + /// The user spoke again. + pub const USER_RESUMED: &str = "user_resumed"; + /// The turn was dropped. + pub const DROPPED: &str = "dropped"; +} + +/// End-of-utterance probability. +pub const LK_EOU_PROBABILITY: &str = "lk.eou.probability"; + +/// Times the endpointing wait restarted on a later trigger. +pub const LK_EOU_REARM_COUNT: &str = "lk.eou.rearm_count"; + +/// Endpointing waits the user cut short by speaking again. +pub const LK_EOU_RESUME_COUNT: &str = "lk.eou.resume_count"; + +/// What decided the end of the turn. +pub const LK_EOU_SOURCE: &str = "lk.eou.source"; + +/// Threshold under which the turn is unlikely to be over. +pub const LK_EOU_UNLIKELY_THRESHOLD: &str = "lk.eou.unlikely_threshold"; + +/// Seconds from the end of the user's speech to the turn decision. Unit: `s`. +pub const LK_EOU_WAIT_DURATION: &str = "lk.eou.wait_duration"; + +/// The provider error is retryable (Node binding). +pub const LK_ERROR_RETRYABLE: &str = "lk.error.retryable"; + +/// The session report is tagged failed. +pub const LK_FAIL: &str = "lk.fail"; + +/// Index of the fallback instance that served the request. +pub const LK_FALLBACK_INDEX: &str = "lk.fallback.index"; + +/// Label of the fallback instance that served the request. +pub const LK_FALLBACK_LABEL: &str = "lk.fallback.label"; + +/// The request was a recovery probe of a failed instance. +pub const LK_FALLBACK_RECOVERY: &str = "lk.fallback.recovery"; + +/// Seconds from waiting for a track to its first frame. Unit: `s`. +pub const LK_FIRST_FRAME_DELAY: &str = "lk.first_frame_delay"; + +/// How the inbound frame of a plugin-task metric was resolved (Node binding). +pub const LK_FRAME_RESOLUTION: &str = "lk.frame.resolution"; + +/// Values of [`LK_FRAME_RESOLUTION`]. +pub mod lk_frame_resolution { + /// The root frame. + pub const ROOT: &str = "root"; + /// A live speech frame. + pub const LIVE: &str = "live"; + /// A frame whose speech already ended. + pub const STALE: &str = "stale"; + /// Not a frame the slab knows. + pub const UNKNOWN: &str = "unknown"; +} + +/// Tool call id. +pub const LK_FUNCTION_TOOL_ID: &str = "lk.function_tool.id"; + +/// The tool returned an error. +pub const LK_FUNCTION_TOOL_IS_ERROR: &str = "lk.function_tool.is_error"; + +/// Tool name. +pub const LK_FUNCTION_TOOL_NAME: &str = "lk.function_tool.name"; + +/// Function tool names offered. +pub const LK_FUNCTION_TOOLS: &str = "lk.function_tools"; + +/// Generations in the turn. +pub const LK_GENERATION_COUNT: &str = "lk.generation_count"; + +/// Id of the agent turn (generation). +pub const LK_GENERATION_ID: &str = "lk.generation_id"; + +/// Span whose input record this one continues. +pub const LK_INPUT_BASE_SPAN_ID: &str = "lk.input.base_span_id"; + +/// The span's input record is a delta over its base span. +pub const LK_INPUT_DELTA: &str = "lk.input.delta"; + +/// Items of the base record not carried into this one. +pub const LK_INPUT_DROPPED_FROM_BASE: &str = "lk.input.dropped_from_base"; + +/// The speech was interrupted. +pub const LK_INTERRUPTED: &str = "lk.interrupted"; + +/// Seconds from the user's speech to the interruption decision. Unit: `s`. +pub const LK_INTERRUPTION_DETECTION_DELAY: &str = "lk.interruption.detection_delay"; + +/// Seconds the interruption prediction took. Unit: `s`. +pub const LK_INTERRUPTION_PREDICTION_DURATION: &str = "lk.interruption.prediction_duration"; + +/// Interruption probability. +pub const LK_INTERRUPTION_PROBABILITY: &str = "lk.interruption.probability"; + +/// What triggered the interruption. +pub const LK_INTERRUPTION_SOURCE: &str = "lk.interruption.source"; + +/// Seconds of the interruption from detection to resolution. Unit: `s`. +pub const LK_INTERRUPTION_TOTAL_DURATION: &str = "lk.interruption.total_duration"; + +/// The user turn was classified as an interruption. +pub const LK_IS_INTERRUPTION: &str = "lk.is_interruption"; + +/// Seconds from the job request to its acceptance. Unit: `s`. +pub const LK_JOB_ACCEPT_LATENCY: &str = "lk.job.accept_latency"; + +/// Agent id of the job. +pub const LK_JOB_AGENT_ID: &str = "lk.job.agent_id"; + +/// Seconds from acceptance to the job assignment. Unit: `s`. +pub const LK_JOB_ASSIGNMENT_LATENCY: &str = "lk.job.assignment_latency"; + +/// Seconds from the dispatch request to the entrypoint running. Unit: `s`. +pub const LK_JOB_DISPATCH_LATENCY: &str = "lk.job.dispatch_latency"; + +/// Seconds from launch to the entrypoint running. Unit: `s`. +pub const LK_JOB_ENTRYPOINT_LATENCY: &str = "lk.job.entrypoint_latency"; + +/// Seconds from assignment to the job process launch. Unit: `s`. +pub const LK_JOB_LAUNCH_LATENCY: &str = "lk.job.launch_latency"; + +/// Worker id the job runs on. +pub const LK_JOB_WORKER_ID: &str = "lk.job.worker_id"; + +/// Agent job ID (`AJ_...`). +pub const LK_JOB_ID: &str = "lk.job_id"; + +/// Keyterms added by the update. +pub const LK_KEYTERMS_ADDED: &str = "lk.keyterms.added"; + +/// Number of keyterms configured. +pub const LK_KEYTERMS_COUNT: &str = "lk.keyterms.count"; + +/// Keyterms removed by the update. +pub const LK_KEYTERMS_REMOVED: &str = "lk.keyterms.removed"; + +/// LLM metrics of the turn, serialized. +pub const LK_LLM_METRICS: &str = "lk.llm_metrics"; + +/// Logger, type, module or file that wrote the line. +pub const LK_LOG_LOGGER: &str = "lk.log.logger"; + +/// Which layer wrote the log line. +pub const LK_LOG_SOURCE: &str = "lk.log.source"; + +/// Values of [`LK_LOG_SOURCE`]. +pub mod lk_log_source { + /// The platform SDK. + pub const SDK: &str = "sdk"; + /// The Rust core, copied by the log forwarder with JWT-shaped substrings masked as ``. + pub const FFI: &str = "ffi"; + /// WebRTC. + pub const WEBRTC: &str = "webrtc"; +} + +/// Metric type noted from a plugin task (Node binding `metrics` span). +pub const LK_METRIC: &str = "lk.metric"; + +/// State after a transition. +pub const LK_NEW_STATE: &str = "lk.new_state"; + +/// State before a transition. +pub const LK_OLD_STATE: &str = "lk.old_state"; + +/// Seconds the `on_user_turn_completed` hook took. Unit: `s`. +pub const LK_ON_USER_TURN_COMPLETED_DELAY: &str = "lk.on_user_turn_completed_delay"; + +/// How the attempt ended. Rollups read this, never the span status. +pub const LK_OUTCOME: &str = "lk.outcome"; + +/// Values of [`LK_OUTCOME`]. +pub mod lk_outcome { + /// Succeeded; span status Unset. + pub const OK: &str = "ok"; + /// Failed; span status Error, with `error.type` when the failure has a type. + pub const ERROR: &str = "error"; + /// Cancelled by the caller or superseded; span status Unset. + pub const CANCELLED: &str = "cancelled"; +} + +/// Generation this one continues (a tool-call follow-up). +pub const LK_PARENT_GENERATION_ID: &str = "lk.parent_generation_id"; + +/// Server-assigned SID of the local participant (`PA_...`). +pub const LK_PARTICIPANT_ID: &str = "lk.participant_id"; + +/// Kind of the linked participant (protocol `ParticipantInfo.Kind`). +pub const LK_PARTICIPANT_KIND: &str = "lk.participant_kind"; + +/// Transcript the AMD classifier used. PII: redacted by the collector. +pub const LK_PII_AMD_TRANSCRIPT: &str = "lk.pii.amd.transcript"; + +/// Tool-call arguments on a log record. PII: redacted by the collector. +pub const LK_PII_ARGUMENTS: &str = "lk.pii.arguments"; + +/// Serialized chat context sent to the LLM. PII: redacted by the collector. +pub const LK_PII_CHAT_CTX: &str = "lk.pii.chat_ctx"; + +/// DTMF digits received. PII: redacted by the collector. +pub const LK_PII_DTMF_INPUTS: &str = "lk.pii.dtmf_inputs"; + +/// Error text that may quote user content. PII: redacted by the collector. +pub const LK_PII_ERROR: &str = "lk.pii.error"; + +/// Raw provider event on a debug log record. PII: redacted by the collector. +pub const LK_PII_EVENT: &str = "lk.pii.event"; + +/// Tool call arguments. PII: redacted by the collector. +pub const LK_PII_FUNCTION_TOOL_ARGUMENTS: &str = "lk.pii.function_tool.arguments"; + +/// Tool output. PII: redacted by the collector. +pub const LK_PII_FUNCTION_TOOL_OUTPUT: &str = "lk.pii.function_tool.output"; + +/// Text sent to the TTS. PII: redacted by the collector. +pub const LK_PII_INPUT_TEXT: &str = "lk.pii.input_text"; + +/// Agent instructions in force. PII: redacted by the collector. +pub const LK_PII_INSTRUCTIONS: &str = "lk.pii.instructions"; + +/// An interim (non-final) transcript, on a log record. PII: redacted by the collector. +pub const LK_PII_INTERIM_TRANSCRIPT: &str = "lk.pii.interim_transcript"; + +/// The keyterms themselves (customer vocabulary). PII: redacted by the collector. +pub const LK_PII_KEYTERMS: &str = "lk.pii.keyterms"; + +/// Tool-call output on a log record. PII: redacted by the collector. +pub const LK_PII_OUTPUT: &str = "lk.pii.output"; + +/// Customer-supplied identity of the local participant. PII: redacted by the collector. +pub const LK_PII_PARTICIPANT_IDENTITY: &str = "lk.pii.participant_identity"; + +/// MCP tool progress message. PII: redacted by the collector. +pub const LK_PII_PROGRESS_MESSAGE: &str = "lk.pii.progress_message"; + +/// Malformed tool-call arguments as received. PII: redacted by the collector. +pub const LK_PII_RAW_ARGUMENTS: &str = "lk.pii.raw_arguments"; + +/// Customer-supplied identity of the remote participant a record is about (the publisher, on subscribe). PII: redacted by the collector. +pub const LK_PII_REMOTE_PARTICIPANT_IDENTITY: &str = "lk.pii.remote_participant_identity"; + +/// Tool-call arguments after repair. PII: redacted by the collector. +pub const LK_PII_REPAIRED: &str = "lk.pii.repaired"; + +/// Function calls the LLM produced, serialized. PII: redacted by the collector. +pub const LK_PII_RESPONSE_FUNCTION_CALLS: &str = "lk.pii.response.function_calls"; + +/// Text the LLM produced. PII: redacted by the collector. +pub const LK_PII_RESPONSE_TEXT: &str = "lk.pii.response.text"; + +/// Customer-supplied room name. PII: redacted by the collector. +pub const LK_PII_ROOM_NAME: &str = "lk.pii.room_name"; + +/// RPC request payload. PII: redacted by the collector. +pub const LK_PII_RPC_PAYLOAD: &str = "lk.pii.rpc.payload"; + +/// RPC response payload. PII: redacted by the collector. +pub const LK_PII_RPC_RESPONSE: &str = "lk.pii.rpc.response"; + +/// Phone number of a SIP participant. PII: redacted by the collector. +pub const LK_PII_SIP_PHONE_NUMBER: &str = "lk.pii.sip.phoneNumber"; + +/// Message text on a log record. PII: redacted by the collector. +pub const LK_PII_TEXT: &str = "lk.pii.text"; + +/// Transcript text on a log record. PII: redacted by the collector. +pub const LK_PII_TRANSCRIPT: &str = "lk.pii.transcript"; + +/// The user's transcript for the turn. PII: redacted by the collector. +pub const LK_PII_USER_INPUT: &str = "lk.pii.user_input"; + +/// Transcript the detector scored. PII: redacted by the collector. +pub const LK_PII_USER_TRANSCRIPT: &str = "lk.pii.user_transcript"; + +/// Monotonically increasing sequence number per pipeline. +pub const LK_PING_SEQ: &str = "lk.ping.seq"; + +/// Seconds of the agent's speech played out when it was interrupted. Unit: `s`. +pub const LK_PLAYOUT_POSITION: &str = "lk.playout.position"; + +/// Seconds of pre-connect audio replayed. Unit: `s`. +pub const LK_PRE_CONNECT_AUDIO_DURATION: &str = "lk.pre_connect_audio.duration"; + +/// Label of the agent handed off from. +pub const LK_PREVIOUS_AGENT_LABEL: &str = "lk.previous_agent_label"; + +/// Provider request ids a span covers, for joining against provider logs. +pub const LK_PROVIDER_REQUEST_IDS: &str = "lk.provider_request_ids"; + +/// Provider-side tool names offered. +pub const LK_PROVIDER_TOOLS: &str = "lk.provider_tools"; + +/// Realtime model metrics of the turn, serialized. +pub const LK_REALTIME_MODEL_METRICS: &str = "lk.realtime_model_metrics"; + +/// Reconnect attempts made in the cycle. +pub const LK_RECONNECT_ATTEMPTS: &str = "lk.reconnect.attempts"; + +/// Mode of the last reconnect attempt. +pub const LK_RECONNECT_MODE: &str = "lk.reconnect.mode"; + +/// Values of [`LK_RECONNECT_MODE`]. +pub mod lk_reconnect_mode { + /// Resume the session (ICE restart). + pub const QUICK: &str = "quick"; + /// Reconnect from scratch. + pub const FULL: &str = "full"; +} + +/// What triggered the reconnect cycle. +pub const LK_RECONNECT_REASON: &str = "lk.reconnect.reason"; + +/// Values of [`LK_RECONNECT_REASON`]. +pub mod lk_reconnect_reason { + /// The signaling socket closed (RR_SIGNAL_DISCONNECTED). + pub const SIGNAL_DISCONNECTED: &str = "signal_disconnected"; + /// The publisher peer connection failed (RR_PUBLISHER_FAILED). + pub const PUBLISHER_FAILED: &str = "publisher_failed"; + /// The subscriber peer connection failed (RR_SUBSCRIBER_FAILED). + pub const SUBSCRIBER_FAILED: &str = "subscriber_failed"; + /// A peer connection failed and the platform did not say which (client only). + pub const TRANSPORT_FAILED: &str = "transport_failed"; + /// The server asked for a candidate switch (RR_SWITCH_CANDIDATE). + pub const SWITCH_CANDIDATE: &str = "switch_candidate"; + /// The device moved to another network (client only). + pub const NETWORK_CHANGED: &str = "network_changed"; + /// A test or a debug menu asked for it (client only). + pub const DEBUG: &str = "debug"; + /// RR_UNKNOWN or a number the client does not know. + pub const UNKNOWN: &str = "unknown"; +} + +/// PII redaction was requested for the session. +pub const LK_REDACTION_ENABLED: &str = "lk.redaction.enabled"; + +/// Seconds to the first TTS audio byte. Unit: `s`. +pub const LK_RESPONSE_TTFB: &str = "lk.response.ttfb"; + +/// Seconds to the first LLM token. Unit: `s`. +pub const LK_RESPONSE_TTFT: &str = "lk.response.ttft"; + +/// Retries before the operation succeeded or gave up. +pub const LK_RETRY_COUNT: &str = "lk.retry_count"; + +/// The room was joined with auto-subscribe. +pub const LK_ROOM_AUTO_SUBSCRIBE: &str = "lk.room.auto_subscribe"; + +/// End-to-end encryption is on. +pub const LK_ROOM_E2EE: &str = "lk.room.e2ee"; + +/// Remote participants when the agent connected. +pub const LK_ROOM_REMOTE_PARTICIPANT_COUNT: &str = "lk.room.remote_participant_count"; + +/// How room I/O picked its participant. +pub const LK_ROOM_IO_PARTICIPANT_FILTER: &str = "lk.room_io.participant_filter"; + +/// Server-assigned room session SID (`RM_...`). +pub const LK_ROOM_SID: &str = "lk.room_sid"; + +/// Identity of the RPC caller. +pub const LK_RPC_CALLER_IDENTITY: &str = "lk.rpc.caller_identity"; + +/// Identity the RPC is sent to. +pub const LK_RPC_DESTINATION_IDENTITY: &str = "lk.rpc.destination_identity"; + +/// RPC error code. +pub const LK_RPC_ERROR_CODE: &str = "lk.rpc.error_code"; + +/// A handler was registered for the method. +pub const LK_RPC_HANDLER_REGISTERED: &str = "lk.rpc.handler_registered"; + +/// RPC request payload size in bytes. Unit: `By`. +pub const LK_RPC_PAYLOAD_SIZE: &str = "lk.rpc.payload_size"; + +/// RPC request id. +pub const LK_RPC_REQUEST_ID: &str = "lk.rpc.request_id"; + +/// RPC response size in bytes. Unit: `By`. +pub const LK_RPC_RESPONSE_SIZE: &str = "lk.rpc.response_size"; + +/// RPC response timeout in seconds. Unit: `s`. +pub const LK_RPC_RESPONSE_TIMEOUT: &str = "lk.rpc.response_timeout"; + +/// Average audio level in the window, 0-1, inbound audio. Unit: `1`. +pub const LK_RTC_AUDIO_LEVEL_AVG: &str = "lk.rtc.audio_level.avg"; + +/// Maximum audio level in the window, 0-1, inbound audio. Unit: `1`. +pub const LK_RTC_AUDIO_LEVEL_MAX: &str = "lk.rtc.audio_level.max"; + +/// Minimum audio level in the window, 0-1, inbound audio. Unit: `1`. +pub const LK_RTC_AUDIO_LEVEL_MIN: &str = "lk.rtc.audio_level.min"; + +/// Bytes sent or received (cumulative). Unit: `By`. +pub const LK_RTC_BYTES: &str = "lk.rtc.bytes"; + +/// Codec mimeType, when known. +pub const LK_RTC_CODEC: &str = "lk.rtc.codec"; + +/// Concealed audio samples, inbound audio (cumulative). Unit: `{sample}`. +pub const LK_RTC_CONCEALED_SAMPLES: &str = "lk.rtc.concealed_samples"; + +/// Concealment events, inbound audio (cumulative). Unit: `{event}`. +pub const LK_RTC_CONCEALMENT_EVENTS: &str = "lk.rtc.concealment_events"; + +/// Average frame rate in the window, video. Unit: `{frame}/s`. +pub const LK_RTC_FPS_AVG: &str = "lk.rtc.fps.avg"; + +/// Maximum frame rate in the window, video. Unit: `{frame}/s`. +pub const LK_RTC_FPS_MAX: &str = "lk.rtc.fps.max"; + +/// Minimum frame rate in the window, video. Unit: `{frame}/s`. +pub const LK_RTC_FPS_MIN: &str = "lk.rtc.fps.min"; + +/// Video freezes, inbound video (cumulative). Unit: `{freeze}`. +pub const LK_RTC_FREEZE_COUNT: &str = "lk.rtc.freeze_count"; + +/// Total freeze time, inbound video (cumulative). Unit: `ms`. +pub const LK_RTC_FREEZES_DURATION_MS: &str = "lk.rtc.freezes_duration_ms"; + +/// Audio interruptions, inbound audio (cumulative). Unit: `{interruption}`. +pub const LK_RTC_INTERRUPTION_COUNT: &str = "lk.rtc.interruption_count"; + +/// Total interruption time, inbound audio (cumulative). Unit: `ms`. +pub const LK_RTC_INTERRUPTIONS_DURATION_MS: &str = "lk.rtc.interruptions_duration_ms"; + +/// Jitter buffer delay, inbound (cumulative). Unit: `ms`. +pub const LK_RTC_JITTER_BUFFER_DELAY_MS: &str = "lk.rtc.jitter_buffer_delay_ms"; + +/// Samples or frames emitted by the jitter buffer, inbound (cumulative). Unit: `{sample}`. +pub const LK_RTC_JITTER_BUFFER_EMITTED_COUNT: &str = "lk.rtc.jitter_buffer_emitted_count"; + +/// Average jitter in the window, inbound. Unit: `ms`. +pub const LK_RTC_JITTER_MS_AVG: &str = "lk.rtc.jitter_ms.avg"; + +/// Maximum jitter in the window, inbound. Unit: `ms`. +pub const LK_RTC_JITTER_MS_MAX: &str = "lk.rtc.jitter_ms.max"; + +/// Minimum jitter in the window, inbound. Unit: `ms`. +pub const LK_RTC_JITTER_MS_MIN: &str = "lk.rtc.jitter_ms.min"; + +/// Packets sent or received (cumulative). Unit: `{packet}`. +pub const LK_RTC_PACKETS: &str = "lk.rtc.packets"; + +/// Packets lost, inbound (cumulative). Unit: `{packet}`. +pub const LK_RTC_PACKETS_LOST: &str = "lk.rtc.packets_lost"; + +/// Video pauses, inbound video (cumulative). Unit: `{pause}`. +pub const LK_RTC_PAUSE_COUNT: &str = "lk.rtc.pause_count"; + +/// Total pause time, inbound video (cumulative). Unit: `ms`. +pub const LK_RTC_PAUSES_DURATION_MS: &str = "lk.rtc.pauses_duration_ms"; + +/// Time the encoder was limited by bandwidth, outbound video (cumulative). Unit: `ms`. +pub const LK_RTC_QUALITY_LIMITATION_BANDWIDTH_MS: &str = "lk.rtc.quality_limitation.bandwidth_ms"; + +/// Time the encoder was limited by CPU, outbound video (cumulative). Unit: `ms`. +pub const LK_RTC_QUALITY_LIMITATION_CPU_MS: &str = "lk.rtc.quality_limitation.cpu_ms"; + +/// Time the encoder was limited for other reasons, outbound video (cumulative). Unit: `ms`. +pub const LK_RTC_QUALITY_LIMITATION_OTHER_MS: &str = "lk.rtc.quality_limitation.other_ms"; + +/// Average round-trip time in the window. Unit: `ms`. +pub const LK_RTC_RTT_MS_AVG: &str = "lk.rtc.rtt_ms.avg"; + +/// Maximum round-trip time in the window. Unit: `ms`. +pub const LK_RTC_RTT_MS_MAX: &str = "lk.rtc.rtt_ms.max"; + +/// Minimum round-trip time in the window. Unit: `ms`. +pub const LK_RTC_RTT_MS_MIN: &str = "lk.rtc.rtt_ms.min"; + +/// Stats readings folded into the window. Unit: `{reading}`. +pub const LK_RTC_SAMPLES: &str = "lk.rtc.samples"; + +/// Concealed samples that were silent, inbound audio (cumulative). Unit: `{sample}`. +pub const LK_RTC_SILENT_CONCEALED_SAMPLES: &str = "lk.rtc.silent_concealed_samples"; + +/// Actual length of the stats window. Unit: `ms`. +pub const LK_RTC_WINDOW_MS: &str = "lk.rtc.window_ms"; + +/// LiveKit SDK name. +pub const LK_SDK_NAME: &str = "lk.sdk.name"; + +/// LiveKit SDK version. +pub const LK_SDK_VERSION: &str = "lk.sdk.version"; + +/// Why the job shut down. +pub const LK_SHUTDOWN_REASON: &str = "lk.shutdown.reason"; + +/// The shutdown was requested by user code. +pub const LK_SHUTDOWN_USER_INITIATED: &str = "lk.shutdown.user_initiated"; + +/// The session is a simulation. +pub const LK_SIMULATION_ENABLED: &str = "lk.simulation.enabled"; + +/// Simulation run job (one scenario) behind the session. +pub const LK_SIMULATION_JOB_ID: &str = "lk.simulation.job_id"; + +/// Simulation run the session belongs to. +pub const LK_SIMULATION_RUN_ID: &str = "lk.simulation.run_id"; + +/// Seconds a speech handle waited in the queue before generation was authorized. Unit: `s`. +pub const LK_SPEECH_QUEUE_WAIT: &str = "lk.speech.queue_wait"; + +/// Id of the speech handle a span belongs to. +pub const LK_SPEECH_ID: &str = "lk.speech_id"; + +/// JSON list of transcript events on the user turn, in arrival order (received_at, type, transcript_length). +pub const LK_STT_EVENTS: &str = "lk.stt.events"; + +/// The session report is tagged successful. +pub const LK_SUCCESS: &str = "lk.success"; + +/// Batches waiting in the cache now. Unit: `{batch}`. +pub const LK_TELEMETRY_CACHE_BATCHES: &str = "lk.telemetry.cache.batches"; + +/// Sent batches the cache could not delete; never sent twice from the same launch. Unit: `{batch}`. +pub const LK_TELEMETRY_CACHE_DELETE_ERRORS: &str = "lk.telemetry.cache.delete_errors"; + +/// Times the cache could not be listed, so nothing was reconciled or sent. Unit: `{attempt}`. +pub const LK_TELEMETRY_CACHE_LIST_ERRORS: &str = "lk.telemetry.cache.list_errors"; + +/// Cached batches that could not be read right now, kept for the next pass. Unit: `{batch}`. +pub const LK_TELEMETRY_CACHE_READ_ERRORS: &str = "lk.telemetry.cache.read_errors"; + +/// Batches the disk refused, kept in memory instead. Unit: `{batch}`. +pub const LK_TELEMETRY_CACHE_WRITE_ERRORS: &str = "lk.telemetry.cache.write_errors"; + +/// Records no cache, not even memory, could take. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_CACHE_ERROR: &str = "lk.telemetry.dropped.cache_error"; + +/// Records evicted by the cache's size or file-count bound. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_CACHE_FULL: &str = "lk.telemetry.dropped.cache_full"; + +/// Records in cached batches that failed their CRC. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_CORRUPT: &str = "lk.telemetry.dropped.corrupt"; + +/// Records in batches past the cache's age limit. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_EXPIRED: &str = "lk.telemetry.dropped.expired"; + +/// Custom events, attributes or SDK strings over the limits, rejected. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_INVALID: &str = "lk.telemetry.dropped.invalid"; + +/// Single records larger than the collector accepts (413). Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_OVERSIZED: &str = "lk.telemetry.dropped.oversized"; + +/// Records evicted from the in-memory queue. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_QUEUE_FULL: &str = "lk.telemetry.dropped.queue_full"; + +/// Discrete events dropped by the flood guard. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_RATE_LIMITED: &str = "lk.telemetry.dropped.rate_limited"; + +/// Records the collector rejected (final 4xx/5xx, OTLP partial success). Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_REJECTED: &str = "lk.telemetry.dropped.rejected"; + +/// Records evicted from the cache during a server-directed pause. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_THROTTLED: &str = "lk.telemetry.dropped.throttled"; + +/// Log lines from a Room-shaped logging context with no telemetry scope behind it, dropped. Unit: `{record}`. +pub const LK_TELEMETRY_DROPPED_UNATTRIBUTED: &str = "lk.telemetry.dropped.unattributed"; + +/// Soft upload holds that reached their cap. Unit: `{hold}`. +pub const LK_TELEMETRY_HOLDS_CAPPED: &str = "lk.telemetry.holds.capped"; + +/// Compressed bytes the collector accepted. Unit: `By`. +pub const LK_TELEMETRY_UPLOADS_BYTES: &str = "lk.telemetry.uploads.bytes"; + +/// Upload attempts that failed transiently (no answer, 429, 5xx). Unit: `{attempt}`. +pub const LK_TELEMETRY_UPLOADS_FAILED: &str = "lk.telemetry.uploads.failed"; + +/// Batches the collector accepted. Unit: `{batch}`. +pub const LK_TELEMETRY_UPLOADS_SENT: &str = "lk.telemetry.uploads.sent"; + +/// Upload attempts that hit the export timeout. Unit: `{attempt}`. +pub const LK_TELEMETRY_UPLOADS_TIMEOUTS: &str = "lk.telemetry.uploads.timeouts"; + +/// 401/403 answers (tokens the collector refused). Unit: `{attempt}`. +pub const LK_TELEMETRY_UPLOADS_UNAUTHORIZED: &str = "lk.telemetry.uploads.unauthorized"; + +/// Tool set names offered. +pub const LK_TOOL_SETS: &str = "lk.tool_sets"; + +/// Whether the stats describe sent or received media. +pub const LK_TRACK_DIRECTION: &str = "lk.track.direction"; + +/// Values of [`LK_TRACK_DIRECTION`]. +pub mod lk_track_direction { + /// Received media (inbound-rtp). + pub const INBOUND: &str = "inbound"; + /// Sent media (outbound-rtp). + pub const OUTBOUND: &str = "outbound"; +} + +/// Media kind of the track. +pub const LK_TRACK_KIND: &str = "lk.track.kind"; + +/// Values of [`LK_TRACK_KIND`]. +pub mod lk_track_kind { + /// Audio. + pub const AUDIO: &str = "audio"; + /// Video. + pub const VIDEO: &str = "video"; +} + +/// Server-assigned track SID (`TR_...`). +pub const LK_TRACK_SID: &str = "lk.track_sid"; + +/// Source of the track, as the Agents RTC FFI `TrackSource` enum name. +pub const LK_TRACK_SOURCE: &str = "lk.track_source"; + +/// Values of [`LK_TRACK_SOURCE`]. +pub mod lk_track_source { + /// Unknown source. + pub const SOURCE_UNKNOWN: &str = "SOURCE_UNKNOWN"; + /// Camera. + pub const SOURCE_CAMERA: &str = "SOURCE_CAMERA"; + /// Microphone. + pub const SOURCE_MICROPHONE: &str = "SOURCE_MICROPHONE"; + /// Screen share video. + pub const SOURCE_SCREENSHARE: &str = "SOURCE_SCREENSHARE"; + /// Screen share audio. + pub const SOURCE_SCREENSHARE_AUDIO: &str = "SOURCE_SCREENSHARE_AUDIO"; +} + +/// Confidence of the final transcript. +pub const LK_TRANSCRIPT_CONFIDENCE: &str = "lk.transcript_confidence"; + +/// Seconds from the end of speech to the final transcript. Unit: `s`. +pub const LK_TRANSCRIPTION_DELAY: &str = "lk.transcription_delay"; + +/// Audio bytes the TTS driver collected (Node binding). Unit: `By`. +pub const LK_TTS_BYTES: &str = "lk.tts.bytes"; + +/// Audio frames the TTS driver collected (Node binding). +pub const LK_TTS_FRAMES: &str = "lk.tts.frames"; + +/// Label of the TTS instance. +pub const LK_TTS_LABEL: &str = "lk.tts.label"; + +/// The TTS reported no duplex path, so the stream adapter was selected (Node binding; not a failure). +pub const LK_TTS_NO_DUPLEX_PATH: &str = "lk.tts.no_duplex_path"; + +/// The TTS instance streams (duplex path). +pub const LK_TTS_STREAMING: &str = "lk.tts.streaming"; + +/// TTS metrics of the turn, serialized. +pub const LK_TTS_METRICS: &str = "lk.tts_metrics"; + +/// Model name the plugin reported. +pub const MODEL_NAME: &str = "model_name"; + +/// Provider the plugin reported. +pub const MODEL_PROVIDER: &str = "model_provider"; + +/// The session outcome, as a map with the field `outcome` (`success` or `fail`) and an optional `reason`. +pub const OUTCOME: &str = "outcome"; + +/// The Agents SDK version. +pub const SDK_VERSION: &str = "sdk_version"; + +/// The session's options, as a map of option name to value. +pub const SESSION_OPTIONS: &str = "session.options"; + +/// When the report was made, in Unix seconds. +pub const SESSION_REPORT_TIMESTAMP: &str = "session.report_timestamp"; + +/// The session's tags, sorted. Absent when the session has none. +pub const SESSION_TAGS: &str = "session.tags"; + +/// One tag with metadata, as a map with the fields `name` and `metadata`. +pub const TAG: &str = "tag"; + +/// Model usage, as a list of maps, one per model. Absent when there was none. +pub const USAGE: &str = "usage"; + +/// Upstream OTel keys that LiveKit signals reference (defined by the registry's dependencies, +/// never here), so a producer can name them from one place. +pub mod upstream { + /// The source code filename that identifies the code unit as uniquely as possible (preferably an absolute file path). This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Function'. This constraint is imposed to prevent redundancy and maintain data integrity. + pub const CODE_FILE_PATH: &str = "code.file.path"; + /// The method or function fully-qualified name without arguments. The value should fit the natural representation of the language runtime, which is also likely the same used within `code.stacktrace` attribute value. This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Function'. This constraint is imposed to prevent redundancy and maintain data integrity. + pub const CODE_FUNCTION_NAME: &str = "code.function.name"; + /// The line number in `code.file.path` best representing the operation. It SHOULD point within the code unit named in `code.function.name`. This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Line'. This constraint is imposed to prevent redundancy and maintain data integrity. + pub const CODE_LINE_NUMBER: &str = "code.line.number"; + /// The model identifier for the device + pub const DEVICE_MODEL_IDENTIFIER: &str = "device.model.identifier"; + /// Describes a class of error the operation ended with. + pub const ERROR_TYPE: &str = "error.type"; + + /// Values of [`ERROR_TYPE`]. + pub mod error_type { + /// A fallback error value to be used when the instrumentation doesn't define a custom value. + pub const OTHER: &str = "_OTHER"; + } + /// The exception message. + pub const EXCEPTION_MESSAGE: &str = "exception.message"; + /// A stacktrace as a string in the natural representation for the language runtime. The representation is to be determined and documented by each language SIG. + pub const EXCEPTION_STACKTRACE: &str = "exception.stacktrace"; + /// The type of the exception (its fully-qualified class name, if applicable). The dynamic type of the exception should be preferred over the static type in languages that support it. + pub const EXCEPTION_TYPE: &str = "exception.type"; + /// Human-readable name of the GenAI agent provided by the application. + pub const GEN_AI_AGENT_NAME: &str = "gen_ai.agent.name"; + /// The unique identifier for a conversation (session, thread), used to store and correlate messages within this conversation. + pub const GEN_AI_CONVERSATION_ID: &str = "gen_ai.conversation.id"; + /// A free-form explanation for the assigned score provided by the evaluator. + pub const GEN_AI_EVALUATION_EXPLANATION: &str = "gen_ai.evaluation.explanation"; + /// The name of the evaluation metric used for the GenAI response. + pub const GEN_AI_EVALUATION_NAME: &str = "gen_ai.evaluation.name"; + /// Human readable label for evaluation. + pub const GEN_AI_EVALUATION_SCORE_LABEL: &str = "gen_ai.evaluation.score.label"; + /// The chat history provided to the model as an input. + pub const GEN_AI_INPUT_MESSAGES: &str = "gen_ai.input.messages"; + /// The search query used to retrieve memories. + pub const GEN_AI_MEMORY_QUERY_TEXT: &str = "gen_ai.memory.query.text"; + /// The memory records stored or retrieved in a memory operation. + pub const GEN_AI_MEMORY_RECORDS: &str = "gen_ai.memory.records"; + /// The name of the operation being performed. + pub const GEN_AI_OPERATION_NAME: &str = "gen_ai.operation.name"; + + /// Values of [`GEN_AI_OPERATION_NAME`]. + pub mod gen_ai_operation_name { + /// Chat completion operation such as [OpenAI Chat API](https://platform.openai.com/docs/api-reference/chat) + pub const CHAT: &str = "chat"; + /// Multimodal content generation operation such as [Gemini Generate Content](https://ai.google.dev/api/generate-content) + pub const GENERATE_CONTENT: &str = "generate_content"; + /// Text completions operation such as [OpenAI Completions API (Legacy)](https://platform.openai.com/docs/api-reference/completions) + pub const TEXT_COMPLETION: &str = "text_completion"; + /// Embeddings operation such as [OpenAI Create embeddings API](https://platform.openai.com/docs/api-reference/embeddings/create) + pub const EMBEDDINGS: &str = "embeddings"; + /// Retrieval operation such as [OpenAI Search Vector Store API](https://platform.openai.com/docs/api-reference/vector-stores/search) + pub const RETRIEVAL: &str = "retrieval"; + /// Fetch a previously generated model response by its identifier, without performing inference, such as [OpenAI Get a model response](https://platform.openai.com/docs/api-reference/responses/get) + pub const FETCH_RESPONSE: &str = "fetch_response"; + /// Create GenAI agent + pub const CREATE_AGENT: &str = "create_agent"; + /// Invoke GenAI agent + pub const INVOKE_AGENT: &str = "invoke_agent"; + /// Execute a tool + pub const EXECUTE_TOOL: &str = "execute_tool"; + /// Invoke GenAI workflow + pub const INVOKE_WORKFLOW: &str = "invoke_workflow"; + /// Agent planning or task decomposition phase + pub const PLAN: &str = "plan"; + /// Search/query memories from a memory store + pub const SEARCH_MEMORY: &str = "search_memory"; + /// Create new memory records + pub const CREATE_MEMORY: &str = "create_memory"; + /// Update existing memory records + pub const UPDATE_MEMORY: &str = "update_memory"; + /// Create or update memory records without the caller choosing which + pub const UPSERT_MEMORY: &str = "upsert_memory"; + /// Delete memory records + pub const DELETE_MEMORY: &str = "delete_memory"; + /// Create or initialize a memory store + pub const CREATE_MEMORY_STORE: &str = "create_memory_store"; + /// Delete or deprovision a memory store + pub const DELETE_MEMORY_STORE: &str = "delete_memory_store"; + } + /// Messages returned by the model where each message represents a specific model response (choice, candidate). + pub const GEN_AI_OUTPUT_MESSAGES: &str = "gen_ai.output.messages"; + /// Represents the content type requested by the client. + pub const GEN_AI_OUTPUT_TYPE: &str = "gen_ai.output.type"; + + /// Values of [`GEN_AI_OUTPUT_TYPE`]. + pub mod gen_ai_output_type { + /// Plain text + pub const TEXT: &str = "text"; + /// JSON object with known or unknown schema + pub const JSON: &str = "json"; + /// Image + pub const IMAGE: &str = "image"; + /// Speech + pub const SPEECH: &str = "speech"; + } + /// The variables supplied to the prompt template, the `` being the variable name, the value being the variable value. + pub const GEN_AI_PROMPT_VARIABLE: &str = "gen_ai.prompt.variable"; + /// The Generative AI provider as identified by the client or server instrumentation. + pub const GEN_AI_PROVIDER_NAME: &str = "gen_ai.provider.name"; + + /// Values of [`GEN_AI_PROVIDER_NAME`]. + pub mod gen_ai_provider_name { + /// [OpenAI](https://openai.com/) + pub const OPENAI: &str = "openai"; + /// Any Google generative AI endpoint + pub const GCP_GEN_AI: &str = "gcp.gen_ai"; + /// [Vertex AI](https://cloud.google.com/vertex-ai) + pub const GCP_VERTEX_AI: &str = "gcp.vertex_ai"; + /// [Gemini](https://cloud.google.com/products/gemini) + pub const GCP_GEMINI: &str = "gcp.gemini"; + /// [Anthropic](https://www.anthropic.com/) + pub const ANTHROPIC: &str = "anthropic"; + /// [Cohere](https://cohere.com/) + pub const COHERE: &str = "cohere"; + /// Azure AI Inference + pub const AZURE_AI_INFERENCE: &str = "azure.ai.inference"; + /// [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/overview) + pub const AZURE_AI_OPENAI: &str = "azure.ai.openai"; + /// [IBM Watsonx AI](https://www.ibm.com/products/watsonx-ai) + pub const IBM_WATSONX_AI: &str = "ibm.watsonx.ai"; + /// [AWS Bedrock](https://aws.amazon.com/bedrock) + pub const AWS_BEDROCK: &str = "aws.bedrock"; + /// [Perplexity](https://www.perplexity.ai/) + pub const PERPLEXITY: &str = "perplexity"; + /// [xAI](https://x.ai/) + pub const X_AI: &str = "x_ai"; + /// [DeepSeek](https://www.deepseek.com/) + pub const DEEPSEEK: &str = "deepseek"; + /// [Groq](https://groq.com/) + pub const GROQ: &str = "groq"; + /// [Mistral](https://mistral.ai/) + pub const MISTRAL: &str = "mistral"; + /// [Moonshot AI](https://www.moonshot.ai/) + pub const MOONSHOT_AI: &str = "moonshot_ai"; + /// Mistral, as Agents sends it. The upstream member is `mistral`. Sent by LiveKit, not an upstream value. + pub const MISTRAL_AI: &str = "mistral_ai"; + } + /// The name of the GenAI model a request is being made to. + pub const GEN_AI_REQUEST_MODEL: &str = "gen_ai.request.model"; + /// Indicates whether the GenAI request was made in streaming mode. + pub const GEN_AI_REQUEST_STREAM: &str = "gen_ai.request.stream"; + /// Array of reasons the model stopped generating tokens, corresponding to each generation received. + pub const GEN_AI_RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons"; + + /// Values of [`GEN_AI_RESPONSE_FINISH_REASONS`]. + pub mod gen_ai_response_finish_reasons { + /// Declared in Agents' `GenAIFinishReason`. No Agents code path sends it today. Sent by LiveKit, not an upstream value. + pub const COMPACTION: &str = "compaction"; + /// A content filter stopped the output. Sent by LiveKit, not an upstream value. + pub const CONTENT_FILTER: &str = "content_filter"; + /// The generation ended abnormally. The upstream note names this value. Sent by LiveKit, not an upstream value. + pub const ERROR: &str = "error"; + /// The output reached the token limit. Sent by LiveKit, not an upstream value. + pub const LENGTH: &str = "length"; + /// The generation finished normally. Sent by LiveKit, not an upstream value. + pub const STOP: &str = "stop"; + /// The generation ended with a tool call. Sent by LiveKit, not an upstream value. + pub const TOOL_CALL: &str = "tool_call"; + } + /// The unique identifier for the completion. + pub const GEN_AI_RESPONSE_ID: &str = "gen_ai.response.id"; + /// The name of the model that generated the response. + pub const GEN_AI_RESPONSE_MODEL: &str = "gen_ai.response.model"; + /// Time to first chunk in a streaming response, measured from request issuance, in seconds. The value is measured from when the client issues the generation request to when the first chunk is received in the response stream. + pub const GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK: &str = "gen_ai.response.time_to_first_chunk"; + /// The documents retrieved. + pub const GEN_AI_RETRIEVAL_DOCUMENTS: &str = "gen_ai.retrieval.documents"; + /// The query text used for retrieval. + pub const GEN_AI_RETRIEVAL_QUERY_TEXT: &str = "gen_ai.retrieval.query.text"; + /// The system message or instructions provided to the GenAI model separately from the chat history. + pub const GEN_AI_SYSTEM_INSTRUCTIONS: &str = "gen_ai.system_instructions"; + /// Parameters passed to the tool call. + pub const GEN_AI_TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments"; + /// The tool call identifier supplied by the model or provider. + pub const GEN_AI_TOOL_CALL_ID: &str = "gen_ai.tool.call.id"; + /// The result returned by the tool call (if any and if execution was successful). + pub const GEN_AI_TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result"; + /// The list of tool definitions available to the GenAI agent or model. + pub const GEN_AI_TOOL_DEFINITIONS: &str = "gen_ai.tool.definitions"; + /// The tool description. + pub const GEN_AI_TOOL_DESCRIPTION: &str = "gen_ai.tool.description"; + /// Name of the tool utilized by the agent. + pub const GEN_AI_TOOL_NAME: &str = "gen_ai.tool.name"; + /// Type of the tool utilized by the agent + pub const GEN_AI_TOOL_TYPE: &str = "gen_ai.tool.type"; + /// The number of audio input tokens served from a provider-managed cache. + pub const GEN_AI_USAGE_AUDIO_CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.audio.cache_read.input_tokens"; + /// The number of audio input tokens. + pub const GEN_AI_USAGE_AUDIO_INPUT_TOKENS: &str = "gen_ai.usage.audio.input_tokens"; + /// The number of audio output tokens. + pub const GEN_AI_USAGE_AUDIO_OUTPUT_TOKENS: &str = "gen_ai.usage.audio.output_tokens"; + /// The number of input tokens served from a provider-managed cache. + pub const GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.cache_read.input_tokens"; + /// The number of input tokens written to a provider-managed cache. + pub const GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS: &str = "gen_ai.usage.cache_write.input_tokens"; + /// The number of image input tokens served from a provider-managed cache. + pub const GEN_AI_USAGE_IMAGE_CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.image.cache_read.input_tokens"; + /// The number of image input tokens. + pub const GEN_AI_USAGE_IMAGE_INPUT_TOKENS: &str = "gen_ai.usage.image.input_tokens"; + /// The number of tokens used in the GenAI input (prompt). + pub const GEN_AI_USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens"; + /// The number of tokens used in the GenAI response (completion). + pub const GEN_AI_USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens"; + /// The number of output tokens used for reasoning (e.g. chain-of-thought, extended thinking). + pub const GEN_AI_USAGE_REASONING_OUTPUT_TOKENS: &str = "gen_ai.usage.reasoning.output_tokens"; + /// The number of text input tokens served from a provider-managed cache. + pub const GEN_AI_USAGE_TEXT_CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.text.cache_read.input_tokens"; + /// The number of text input tokens. + pub const GEN_AI_USAGE_TEXT_INPUT_TOKENS: &str = "gen_ai.usage.text.input_tokens"; + /// The number of text output tokens. + pub const GEN_AI_USAGE_TEXT_OUTPUT_TOKENS: &str = "gen_ai.usage.text.output_tokens"; + /// Human-readable name of the GenAI workflow provided by the application. + pub const GEN_AI_WORKFLOW_NAME: &str = "gen_ai.workflow.name"; + /// The internet connection type. + pub const NETWORK_CONNECTION_TYPE: &str = "network.connection.type"; + + /// Values of [`NETWORK_CONNECTION_TYPE`]. + pub mod network_connection_type { + /// Wi-Fi + pub const WIFI: &str = "wifi"; + /// `wired`. + pub const WIRED: &str = "wired"; + /// `cell`. + pub const CELL: &str = "cell"; + /// `unavailable`. + pub const UNAVAILABLE: &str = "unavailable"; + /// `unknown`. + pub const UNKNOWN: &str = "unknown"; + } + /// Human readable operating system name. + pub const OS_NAME: &str = "os.name"; + /// The version string of the operating system as defined in [Version Attributes](/docs/resource/README.md#version-attributes). + pub const OS_VERSION: &str = "os.version"; + /// Identifies the class / type of event. + pub const OTEL_EVENT_NAME: &str = "otel.event.name"; + /// The fully-qualified logical name of the method from the RPC interface perspective. + pub const RPC_METHOD: &str = "rpc.method"; + /// Logical name of the service. + pub const SERVICE_NAME: &str = "service.name"; + /// The version string of the service component. The format is not defined by these conventions. + pub const SERVICE_VERSION: &str = "service.version"; + /// A unique ID to identify a session. + pub const SESSION_ID: &str = "session.id"; + /// The language of the telemetry SDK. + pub const TELEMETRY_SDK_LANGUAGE: &str = "telemetry.sdk.language"; + + /// Values of [`TELEMETRY_SDK_LANGUAGE`]. + pub mod telemetry_sdk_language { + /// [C++](https://opentelemetry.io/docs/languages/cpp/) + pub const CPP: &str = "cpp"; + /// [.NET](https://opentelemetry.io/docs/languages/dotnet/) + pub const DOTNET: &str = "dotnet"; + /// [Erlang/Elixir](https://opentelemetry.io/docs/languages/erlang/) + pub const ERLANG: &str = "erlang"; + /// [Go](https://opentelemetry.io/docs/languages/go/) + pub const GO: &str = "go"; + /// [Java](https://opentelemetry.io/docs/languages/java/) + pub const JAVA: &str = "java"; + /// [Kotlin](https://opentelemetry.io/docs/languages/kotlin/) + pub const KOTLIN: &str = "kotlin"; + /// [Node.js](https://opentelemetry.io/docs/languages/js/) + pub const NODEJS: &str = "nodejs"; + /// [PHP](https://opentelemetry.io/docs/languages/php/) + pub const PHP: &str = "php"; + /// [Python](https://opentelemetry.io/docs/languages/python/) + pub const PYTHON: &str = "python"; + /// [Ruby](https://opentelemetry.io/docs/languages/ruby/) + pub const RUBY: &str = "ruby"; + /// [Rust](https://opentelemetry.io/docs/languages/rust/) + pub const RUST: &str = "rust"; + /// [Swift](https://opentelemetry.io/docs/languages/swift/) + pub const SWIFT: &str = "swift"; + /// [Browser](https://opentelemetry.io/docs/languages/js/) + pub const WEBJS: &str = "webjs"; + } + /// The name of the telemetry SDK as defined above. + pub const TELEMETRY_SDK_NAME: &str = "telemetry.sdk.name"; + /// The version string of the telemetry SDK. + pub const TELEMETRY_SDK_VERSION: &str = "telemetry.sdk.version"; + + /// Upstream metrics that LiveKit producers create, imported from the registry's dependencies. + pub mod metric { + /// The duration of a single tool execution. Instrument: histogram, unit: `s`. + pub const GEN_AI_EXECUTE_TOOL_DURATION: &str = "gen_ai.execute_tool.duration"; + /// Unit of [`GEN_AI_EXECUTE_TOOL_DURATION`], as the upstream definition gives it (empty = none). + pub const GEN_AI_EXECUTE_TOOL_DURATION_UNIT: &str = "s"; + /// The end-to-end duration of a single in-process agent invocation, from the moment the invocation starts until the agent emits the last chunk of its final response or terminates with an error. Instrument: histogram, unit: `s`. + pub const GEN_AI_INVOKE_AGENT_DURATION: &str = "gen_ai.invoke_agent.duration"; + /// Unit of [`GEN_AI_INVOKE_AGENT_DURATION`], as the upstream definition gives it (empty = none). + pub const GEN_AI_INVOKE_AGENT_DURATION_UNIT: &str = "s"; + } +} +/// Event names (OTLP `LogRecord.event_name`). +pub mod event { + /// Namespace of app-defined events, named `custom.` and carrying the app's own attributes. + pub const CUSTOM: &str = "custom"; + /// OTel GenAI assistant message (also a function call), a span event on `llm_node`. + pub const GEN_AI_ASSISTANT_MESSAGE: &str = "gen_ai.assistant.message"; + /// OTel GenAI choice, a span event on `llm_node`. + pub const GEN_AI_CHOICE: &str = "gen_ai.choice"; + /// OTel GenAI system (or developer) message, a span event on `llm_node`. + pub const GEN_AI_SYSTEM_MESSAGE: &str = "gen_ai.system.message"; + /// OTel GenAI tool message (a function call output), a span event on `llm_node`. + pub const GEN_AI_TOOL_MESSAGE: &str = "gen_ai.tool.message"; + /// OTel GenAI user message, a span event on `llm_node`. + pub const GEN_AI_USER_MESSAGE: &str = "gen_ai.user.message"; + /// The app moved to the foreground or background. + pub const LK_DEVICE_APP_STATE_CHANGED: &str = "lk.device.app_state.changed"; + /// An audio session interruption began or ended. + pub const LK_DEVICE_AUDIO_INTERRUPTION: &str = "lk.device.audio.interruption"; + /// The audio route changed. + pub const LK_DEVICE_AUDIO_ROUTE_CHANGED: &str = "lk.device.audio_route.changed"; + /// Charging changed, or the level crossed 20 % or 10 % unplugged. + pub const LK_DEVICE_BATTERY_CHANGED: &str = "lk.device.battery.changed"; + /// A capture device failed. + pub const LK_DEVICE_CAPTURE_FAILED: &str = "lk.device.capture.failed"; + /// Low Power Mode changed. + pub const LK_DEVICE_LOW_POWER_CHANGED: &str = "lk.device.low_power.changed"; + /// Memory pressure changed. + pub const LK_DEVICE_MEMORY_CHANGED: &str = "lk.device.memory.changed"; + /// The network path changed. + pub const LK_DEVICE_NETWORK_CHANGED: &str = "lk.device.network.changed"; + /// Thermal state changed. + pub const LK_DEVICE_THERMAL_CHANGED: &str = "lk.device.thermal.changed"; + /// Pipeline smoke test. + pub const LK_PING: &str = "lk.ping"; + /// The Room left connected for good. + pub const LK_ROOM_DISCONNECTED: &str = "lk.room.disconnected"; + /// One RTC stats window for one track and direction. + pub const LK_RTC_STATS_SAMPLE: &str = "lk.rtc.stats.sample"; + /// Self-telemetry; counts since the previous report. + pub const LK_TELEMETRY_REPORT: &str = "lk.telemetry.report"; +} + +/// Span names. +pub mod span { + /// The agent session, parent of every turn. Kind: internal. + pub const AGENT_SESSION: &str = "agent_session"; + /// The agent's audio playing out. Kind: internal. + pub const AGENT_SPEAKING: &str = "agent_speaking"; + /// One agent turn (generation), parent of the LLM and TTS nodes. Kind: internal. + pub const AGENT_TURN: &str = "agent_turn"; + /// Answering-machine detection on an outbound call. Kind: internal. + pub const AMD: &str = "amd"; + /// Draining an agent activity before it ends. Kind: internal. + pub const DRAIN_AGENT_ACTIVITY: &str = "drain_agent_activity"; + /// One end-of-utterance inference. Kind: internal. + pub const EOU_DETECTION: &str = "eou_detection"; + /// The endpointing wait, from the user's last speech to the turn decision. Kind: internal. + pub const EOU_WAIT: &str = "eou_wait"; + /// A synchronous block on the agent event loop. Kind: internal. + pub const EVENT_LOOP_BLOCKED: &str = "event_loop_blocked"; + /// One function tool execution, the OTel GenAI execute-tool span. Kind: internal. + pub const FUNCTION_TOOL: &str = "function_tool"; + /// The job's entrypoint, root of a job's trace. Kind: internal. + pub const JOB_ENTRYPOINT: &str = "job_entrypoint"; + /// The job shutting down. Kind: internal. + pub const JOB_SHUTDOWN: &str = "job_shutdown"; + /// One LLM-judge evaluation of the session. Kind: internal. + pub const JUDGE_EVALUATION: &str = "judge_evaluation"; + /// A keyterm update to the STT. Kind: internal. + pub const KEYTERM_DETECTION: &str = "keyterm_detection"; + /// One connect attempt. Kind: client. + pub const LK_CONNECT: &str = "lk.connect"; + /// One track publish. Kind: internal. + pub const LK_PUBLISH: &str = "lk.publish"; + /// One reconnect cycle. Kind: client. + pub const LK_RECONNECT: &str = "lk.reconnect"; + /// One subscription, from the intent to subscribe to the first media. Kind: internal. + pub const LK_SUBSCRIBE: &str = "lk.subscribe"; + /// The LLM fallback adapter choosing an instance. Kind: internal. + pub const LLM_FALLBACK_ADAPTER: &str = "llm_fallback_adapter"; + /// The agent's LLM node for one generation. Kind: internal. + pub const LLM_NODE: &str = "llm_node"; + /// One LLM inference request, the OTel GenAI inference span. Kind: client. + pub const LLM_REQUEST: &str = "llm_request"; + /// The LLM request's stream being consumed. Kind: internal. + pub const LLM_REQUEST_RUN: &str = "llm_request_run"; + /// A plugin-task metric noted through the Node binding, stamped with its resolved frame. Kind: internal. + pub const METRICS: &str = "metrics"; + /// The agent's `on_enter` hook. Kind: internal. + pub const ON_ENTER: &str = "on_enter"; + /// The agent's `on_exit` hook. Kind: internal. + pub const ON_EXIT: &str = "on_exit"; + /// The session-end hook. Kind: internal. + pub const ON_SESSION_END: &str = "on_session_end"; + /// The `on_user_turn_completed` hook. Kind: internal. + pub const ON_USER_TURN_COMPLETED: &str = "on_user_turn_completed"; + /// Pausing an agent activity. Kind: internal. + pub const PAUSE_AGENT_ACTIVITY: &str = "pause_agent_activity"; + /// Publishing the agent's audio track. Kind: internal. + pub const PUBLISH_AUDIO_OUTPUT: &str = "publish_audio_output"; + /// One realtime-model generation. Kind: client. + pub const REALTIME_INFERENCE: &str = "realtime_inference"; + /// Realtime-model metrics attached to a turn. Kind: internal. + pub const REALTIME_METRICS: &str = "realtime_metrics"; + /// Resuming an agent activity. Kind: internal. + pub const RESUME_AGENT_ACTIVITY: &str = "resume_agent_activity"; + /// Connecting the job to its room. Kind: client. + pub const ROOM_CONNECT: &str = "room_connect"; + /// Disconnecting from the room. Kind: client. + pub const ROOM_DISCONNECT: &str = "room_disconnect"; + /// An outgoing RPC. Kind: client. + pub const RPC_CALL: &str = "rpc_call"; + /// An incoming RPC handled by the agent. Kind: server. + pub const RPC_HANDLER: &str = "rpc_handler"; + /// Closing the session. Kind: internal. + pub const SESSION_CLOSE: &str = "session_close"; + /// Uploading the session report. Kind: internal. + pub const SESSION_END_UPLOAD: &str = "session_end_upload"; + /// Starting the session (room I/O, first activity). Kind: internal. + pub const SESSION_START: &str = "session_start"; + /// Initializing the agent's tool sets. Kind: internal. + pub const SETUP_TOOLSETS: &str = "setup_toolsets"; + /// One shutdown callback. Kind: internal. + pub const SHUTDOWN_CALLBACK: &str = "shutdown_callback"; + /// Starting an agent activity. Kind: internal. + pub const START_AGENT_ACTIVITY: &str = "start_agent_activity"; + /// The TTS fallback adapter choosing an instance. Kind: internal. + pub const TTS_FALLBACK_ADAPTER: &str = "tts_fallback_adapter"; + /// The agent's TTS node for one generation. Kind: internal. + pub const TTS_NODE: &str = "tts_node"; + /// One TTS request. Kind: client. + pub const TTS_REQUEST: &str = "tts_request"; + /// The TTS request's stream being consumed. Kind: internal. + pub const TTS_REQUEST_RUN: &str = "tts_request_run"; + /// The Node binding driving `Tts::stream` (the duplex path). Kind: internal. + pub const TTS_STREAM: &str = "tts_stream"; + /// The sentence-splitting adapter over a non-streaming TTS. Kind: internal. + pub const TTS_STREAM_ADAPTER: &str = "tts_stream_adapter"; + /// The Node binding driving `Tts::synthesize` (the simplex path). Kind: internal. + pub const TTS_SYNTHESIZE: &str = "tts_synthesize"; + /// Replacing the session's agent. Kind: internal. + pub const UPDATE_AGENT: &str = "update_agent"; + /// The user speaking (VAD). Kind: internal. + pub const USER_SPEAKING: &str = "user_speaking"; + /// One user turn, from speech to the turn decision. Kind: internal. + pub const USER_TURN: &str = "user_turn"; + /// Waiting for the participant's audio track. Kind: internal. + pub const WAIT_FOR_AUDIO_TRACK: &str = "wait_for_audio_track"; + /// Waiting for the participant to link to. Kind: internal. + pub const WAIT_FOR_PARTICIPANT: &str = "wait_for_participant"; + /// Waiting for the participant's video track. Kind: internal. + pub const WAIT_FOR_VIDEO_TRACK: &str = "wait_for_video_track"; +} + +/// Checkpoint names: span events of the spans above. `lk.reconnect` names its attempts +/// `attempt ` instead. +pub mod checkpoint { + /// The agent state changed (`lk.old_state` -> `lk.new_state`). + pub const AGENT_STATE_CHANGED: &str = "agent_state_changed"; + /// The subscriber's SDP answer went out. + pub const ANSWER_SENT: &str = "answer_sent"; + /// A peer connection was created before the join response (JS port only). + pub const EARLY_PC_CREATED: &str = "early_pc_created"; + /// The RTC engine is ready (Swift stamps it with pc_connected). + pub const ENGINE: &str = "engine"; + /// The entrypoint was cancelled. + pub const ENTRYPOINT_CANCELLED: &str = "entrypoint_cancelled"; + /// The entrypoint returned. + pub const ENTRYPOINT_RETURNED: &str = "entrypoint_returned"; + /// The entrypoint started running. + pub const ENTRYPOINT_STARTED: &str = "entrypoint_started"; + /// A synchronous block on the event loop, with `lk.blocking.duration`; mirrors the span of the same name. + pub const EVENT_LOOP_BLOCKED: &str = "event_loop_blocked"; + /// The OTel exception event (`exception.*`), recorded on whichever span the exception was caught on; declared here once. + pub const EXCEPTION: &str = "exception"; + /// The first frame arrived. + pub const FIRST_FRAME: &str = "first_frame"; + /// The first inbound stats reading with bytes; the span ends ok here. + pub const FIRST_MEDIA: &str = "first_media"; + /// OTel GenAI inference details event (the GenAI registry defines it; events cannot be referenced + pub const GEN_AI_CLIENT_INFERENCE_OPERATION_DETAILS: &str = "gen_ai.client.inference.operation.details"; + /// One generation started (the generation attributes). + pub const GENERATION: &str = "generation"; + /// The worker accepted the job. + pub const JOB_ACCEPTED: &str = "job_accepted"; + /// The server assigned the job. + pub const JOB_ASSIGNED: &str = "job_assigned"; + /// The worker received the job request. + pub const JOB_RECEIVED: &str = "job_received"; + /// The server-side job start time, as the worker learned it. + pub const JOB_STARTED_ON_SERVER: &str = "job_started_on_server"; + /// The join response arrived. + pub const JOIN_RECV: &str = "join_recv"; + /// The publisher's SDP offer went out. + pub const OFFER_SENT: &str = "offer_sent"; + /// The linked participant left the room, with `lk.disconnect_reason`. + pub const PARTICIPANT_DISCONNECTED: &str = "participant_disconnected"; + /// The session linked to its participant. + pub const PARTICIPANT_LINKED: &str = "participant_linked"; + /// The primary peer connection connected. + pub const PC_CONNECTED: &str = "pc_connected"; + /// The peer connections exist (stamped once the join is adopted). + pub const PC_CREATED: &str = "pc_created"; + /// Pre-connect audio was replayed, with `lk.pre_connect_audio.duration`. + pub const PRE_CONNECT_AUDIO: &str = "pre_connect_audio"; + /// A preemptive generation was discarded in favour of this one (`lk.speech_id` of the discarded handle). + pub const PREEMPTIVE_GENERATION_DISCARDED: &str = "preemptive_generation_discarded"; + /// A job process was launched for it. + pub const PROCESS_ASSIGNED: &str = "process_assigned"; + /// A later trigger re-armed the wait. + pub const REARMED: &str = "rearmed"; + /// The Room reached the connected state. + pub const ROOM_CONNECTED: &str = "room_connected"; + /// Signaling is up (stamped with ws_open on the web and with join_recv on Swift). + pub const SIGNAL: &str = "signal"; + /// The hook stopped the response. + pub const STOP_RESPONSE: &str = "stop_response"; + /// The platform reported the remote track subscribed. + pub const SUBSCRIBED: &str = "subscribed"; + /// A newer detection superseded this one. + pub const SUPERSEDED: &str = "superseded"; + /// The track was subscribed, with `lk.track_sid` and `lk.track_source`. + pub const TRACK_SUBSCRIBED: &str = "track_subscribed"; + /// The user spoke again during the wait. + pub const USER_RESUMED: &str = "user_resumed"; + /// The user state changed (`lk.old_state` -> `lk.new_state`). + pub const USER_STATE_CHANGED: &str = "user_state_changed"; + /// The signaling WebSocket opened. + pub const WS_OPEN: &str = "ws_open"; +} + +/// Metric names. +pub mod metric { + /// GenAI operation duration, as the GenAI registry defines it (declared here because weaver cannot import it). Instrument: histogram, unit: `s`. + pub const GEN_AI_CLIENT_OPERATION_DURATION: &str = "gen_ai.client.operation.duration"; + /// Unit of [`GEN_AI_CLIENT_OPERATION_DURATION`], exactly as the producer creates the instrument (empty = none). + pub const GEN_AI_CLIENT_OPERATION_DURATION_UNIT: &str = "s"; + /// Time to the first chunk of a streaming GenAI response (deprecated upstream; declared as emitted). Instrument: histogram, unit: `s`. + pub const GEN_AI_CLIENT_OPERATION_TIME_TO_FIRST_CHUNK: &str = "gen_ai.client.operation.time_to_first_chunk"; + /// Unit of [`GEN_AI_CLIENT_OPERATION_TIME_TO_FIRST_CHUNK`], exactly as the producer creates the instrument (empty = none). + pub const GEN_AI_CLIENT_OPERATION_TIME_TO_FIRST_CHUNK_UNIT: &str = "s"; + /// Input and output tokens used by the GenAI operation (deprecated upstream; declared as emitted). Instrument: histogram, unit: `{token}`. + pub const GEN_AI_CLIENT_TOKEN_USAGE: &str = "gen_ai.client.token.usage"; + /// Unit of [`GEN_AI_CLIENT_TOKEN_USAGE`], exactly as the producer creates the instrument (empty = none). + pub const GEN_AI_CLIENT_TOKEN_USAGE_UNIT: &str = "{token}"; + /// Seconds to acquire a provider connection. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_CONNECTION_ACQUIRE_TIME: &str = "lk.agents.connection.acquire_time"; + /// Unit of [`LK_AGENTS_CONNECTION_ACQUIRE_TIME`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_CONNECTION_ACQUIRE_TIME_UNIT: &str = "s"; + /// Duration of synchronous blocks detected on an agent event loop. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_EVENT_LOOP_BLOCKED_DURATION: &str = "lk.agents.event_loop.blocked_duration"; + /// Unit of [`LK_AGENTS_EVENT_LOOP_BLOCKED_DURATION`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_EVENT_LOOP_BLOCKED_DURATION_UNIT: &str = "s"; + /// End-to-end latency of a turn. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_E2E_LATENCY: &str = "lk.agents.turn.e2e_latency"; + /// Unit of [`LK_AGENTS_TURN_E2E_LATENCY`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_E2E_LATENCY_UNIT: &str = "s"; + /// Delay from the end of speech to the turn decision. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_END_OF_TURN_DELAY: &str = "lk.agents.turn.end_of_turn_delay"; + /// Unit of [`LK_AGENTS_TURN_END_OF_TURN_DELAY`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_END_OF_TURN_DELAY_UNIT: &str = "s"; + /// Time to the first LLM token. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_LLM_TTFT: &str = "lk.agents.turn.llm_ttft"; + /// Unit of [`LK_AGENTS_TURN_LLM_TTFT`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_LLM_TTFT_UNIT: &str = "s"; + /// Duration of the `on_user_turn_completed` hook. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_ON_USER_TURN_COMPLETED_DELAY: &str = "lk.agents.turn.on_user_turn_completed_delay"; + /// Unit of [`LK_AGENTS_TURN_ON_USER_TURN_COMPLETED_DELAY`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_ON_USER_TURN_COMPLETED_DELAY_UNIT: &str = "s"; + /// Delay from the end of speech to the final transcript. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_TRANSCRIPTION_DELAY: &str = "lk.agents.turn.transcription_delay"; + /// Unit of [`LK_AGENTS_TURN_TRANSCRIPTION_DELAY`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_TRANSCRIPTION_DELAY_UNIT: &str = "s"; + /// Time to the first TTS byte. Instrument: histogram, unit: `s`. + pub const LK_AGENTS_TURN_TTS_TTFB: &str = "lk.agents.turn.tts_ttfb"; + /// Unit of [`LK_AGENTS_TURN_TTS_TTFB`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_TURN_TTS_TTFB_UNIT: &str = "s"; + /// Interruption-detector requests. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_INTERRUPTION_NUM_REQUESTS: &str = "lk.agents.usage.interruption_num_requests"; + /// Unit of [`LK_AGENTS_USAGE_INTERRUPTION_NUM_REQUESTS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_INTERRUPTION_NUM_REQUESTS_UNIT: &str = ""; + /// Realtime-model audio input tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_INPUT_AUDIO_TOKENS: &str = "lk.agents.usage.llm_input_audio_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_INPUT_AUDIO_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_INPUT_AUDIO_TOKENS_UNIT: &str = ""; + /// LLM cached input tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_INPUT_CACHED_TOKENS: &str = "lk.agents.usage.llm_input_cached_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_INPUT_CACHED_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_INPUT_CACHED_TOKENS_UNIT: &str = ""; + /// Realtime-model text input tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_INPUT_TEXT_TOKENS: &str = "lk.agents.usage.llm_input_text_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_INPUT_TEXT_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_INPUT_TEXT_TOKENS_UNIT: &str = ""; + /// LLM input tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_INPUT_TOKENS: &str = "lk.agents.usage.llm_input_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_INPUT_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_INPUT_TOKENS_UNIT: &str = ""; + /// Realtime-model audio output tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_OUTPUT_AUDIO_TOKENS: &str = "lk.agents.usage.llm_output_audio_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_OUTPUT_AUDIO_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_OUTPUT_AUDIO_TOKENS_UNIT: &str = ""; + /// Realtime-model text output tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_OUTPUT_TEXT_TOKENS: &str = "lk.agents.usage.llm_output_text_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_OUTPUT_TEXT_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_OUTPUT_TEXT_TOKENS_UNIT: &str = ""; + /// LLM output tokens. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_LLM_OUTPUT_TOKENS: &str = "lk.agents.usage.llm_output_tokens"; + /// Unit of [`LK_AGENTS_USAGE_LLM_OUTPUT_TOKENS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_OUTPUT_TOKENS_UNIT: &str = ""; + /// Realtime-model session seconds. Instrument: counter, unit: `s`. + pub const LK_AGENTS_USAGE_LLM_SESSION_DURATION: &str = "lk.agents.usage.llm_session_duration"; + /// Unit of [`LK_AGENTS_USAGE_LLM_SESSION_DURATION`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_LLM_SESSION_DURATION_UNIT: &str = "s"; + /// Seconds of audio transcribed. Instrument: counter, unit: `s`. + pub const LK_AGENTS_USAGE_STT_AUDIO_DURATION: &str = "lk.agents.usage.stt_audio_duration"; + /// Unit of [`LK_AGENTS_USAGE_STT_AUDIO_DURATION`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_STT_AUDIO_DURATION_UNIT: &str = "s"; + /// Seconds of audio synthesized. Instrument: counter, unit: `s`. + pub const LK_AGENTS_USAGE_TTS_AUDIO_DURATION: &str = "lk.agents.usage.tts_audio_duration"; + /// Unit of [`LK_AGENTS_USAGE_TTS_AUDIO_DURATION`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_TTS_AUDIO_DURATION_UNIT: &str = "s"; + /// Characters synthesized. Instrument: counter, unit: ``. + pub const LK_AGENTS_USAGE_TTS_CHARACTERS: &str = "lk.agents.usage.tts_characters"; + /// Unit of [`LK_AGENTS_USAGE_TTS_CHARACTERS`], exactly as the producer creates the instrument (empty = none). + pub const LK_AGENTS_USAGE_TTS_CHARACTERS_UNIT: &str = ""; +} diff --git a/livekit-telemetry/Cargo.toml b/livekit-telemetry/Cargo.toml index c74521015..66dce5b34 100644 --- a/livekit-telemetry/Cargo.toml +++ b/livekit-telemetry/Cargo.toml @@ -26,6 +26,8 @@ flate2 = "1" # 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"] } +# The registered telemetry names (`semconv`), generated from the protocol's Weaver registry. +livekit-protocol = { workspace = true } livekit-net = { workspace = true, optional = true } uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns"], optional = true } diff --git a/livekit-telemetry/SPEC.md b/livekit-telemetry/SPEC.md index 9fea4a4c3..8386cd6fe 100644 --- a/livekit-telemetry/SPEC.md +++ b/livekit-telemetry/SPEC.md @@ -1,12 +1,18 @@ # 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). +Event names, attributes and cadences emitted by LiveKit client SDKs. Every fixed name and every +enum value that the protocol's telemetry registry (`otel/` in livekit/protocol) defines comes from +the generated `livekit_protocol::semconv`, and `src/wire_tests.rs` pins what the core sends. Four +values stay literals until the registry defines them: the `network.connection.type` values `vpn`, +`bluetooth` and `other`, and the `error.type` value `timed_out`. Names that the app chooses, such +as custom event names, and the checkpoint name patterns are not registry constants. The registry +records each rename as `renamed_from` on the new name. LiveKit-defined names +carry the `lk.` prefix, everything else follows +[OpenTelemetry semantic conventions](https://github.com/open-telemetry/semantic-conventions). Every record carries a wall-clock timestamp: one without `timestamp_ns` is stamped when it is captured (queued), never at export; an explicit `timestamp_ns` is kept. Its attributes, like its -owner and timestamp, are taken at capture, its session's (`lk.room.*`, `lk.participant.*`, +owner and timestamp, are taken at capture, its session's (`lk.room_sid`, `lk.participant_id`, …, `session.id`) included, so a record captured before a disconnect keeps its room; only pipeline-wide attributes are added at export. A record ships each key once: of a key it repeats, the last value supplied wins, and its session's keys replace its own. Attribute keys the SDK @@ -30,8 +36,8 @@ Set once per pipeline (`TelemetryConfig.resource`): One pipeline per process — started at SDK init, so audio pre-initialization, permission failures and connect attempts that never reach a server are captured — and one **scope** per room (one -call). A scope is a trace id plus the attributes attached to its records (`lk.room.sid`, -`lk.participant.identity`, …); spans, RTC windows and events are filed under the scope that +call). A scope is a trace id plus the attributes attached to its records (`lk.room_sid`, +`lk.pii.participant_identity`, …); spans, RTC windows and events are filed under the scope that produced them, and `session.id` (OTel semconv) is written on every record as an attribute. A log record emitted inside a room's span is filed under that room's scope; anything emitted outside a scope — device state, pre-room errors, self-telemetry — belongs to the pipeline's own process @@ -240,7 +246,7 @@ cadence: one per track and direction per stats window (default 60 s, stretched b (`stats_poll_interval_ms`). Keyed by session and track: two rooms receiving the same track keep two windows. attributes: - lk.track.sid: string + lk.track_sid: string lk.track.kind: enum(audio | video) lk.track.direction: enum(inbound | outbound) lk.rtc.codec: string # mimeType, when known @@ -483,10 +489,10 @@ exempt. ```yaml event: lk.room.disconnected area: session -severity: info (client_initiated) | warn (anything else) +severity: info (CLIENT_INITIATED) | warn (anything else) cadence: once, when the Room leaves connected for good — never on a reconnect attributes: - lk.disconnect.reason: enum(client_initiated | duplicate_identity | server_shutdown | participant_removed | room_deleted | state_mismatch | join_failure | migration | signal_close | room_closed | user_unavailable | user_rejected | sip_trunk_failure | connection_timeout | media_failure | agent_error | reconnect_failed | unknown) # the protocol's DisconnectReason, plus the client giving up + lk.disconnect_reason: enum(CLIENT_INITIATED | DUPLICATE_IDENTITY | SERVER_SHUTDOWN | PARTICIPANT_REMOVED | ROOM_DELETED | STATE_MISMATCH | JOIN_FAILURE | MIGRATION | SIGNAL_CLOSE | ROOM_CLOSED | USER_UNAVAILABLE | USER_REJECTED | SIP_TRUNK_FAILURE | CONNECTION_TIMEOUT | MEDIA_FAILURE | AGENT_ERROR | RECONNECT_FAILED | UNKNOWN_REASON) # the protobuf DisconnectReason names, as Agents sends them, plus RECONNECT_FAILED (the client giving up); the body keeps snake case (`disconnected: client_initiated`) platforms: all ``` @@ -540,8 +546,8 @@ parent: the ambient span, when any; a pre-connect publish (a microphone publishe 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 + lk.track_source: enum(SOURCE_CAMERA | SOURCE_MICROPHONE | SOURCE_SCREENSHARE | SOURCE_SCREENSHARE_AUDIO | SOURCE_UNKNOWN) # the RTC FFI TrackSource names, as Agents sends them + lk.track_sid: string # on success outcome: ok | error (error.type) | cancelled ``` @@ -560,10 +566,10 @@ ends: at first media (the first inbound stats reading with bytes; the core see 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_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 + lk.track_source: enum(SOURCE_CAMERA | SOURCE_MICROPHONE | SOURCE_SCREENSHARE | SOURCE_SCREENSHARE_AUDIO | SOURCE_UNKNOWN) + lk.pii.remote_participant_identity: string checkpoints: subscribed, first_media ``` @@ -580,10 +586,10 @@ the policy. `Attribute { key, value }` survives only as the open bag: `emit_cust | `telemetry_disable()` | the opt-out, in effect when it returns: capture stops; everything unsent is then deleted | | `TelemetryConfig.sdk: TelemetryResource { sdk: Sdk, sdk_version, os_name, os_version, device_model }` | `service.name = livekit-client-`, `service.version`, `os.*`, `device.model.identifier`, plus `telemetry.sdk.*` | | `log(LogRecord { severity, source: LogSource, body, logger, function, file, line, timestamp_ns, span_id })` | a record with `code.function.name`, `code.file.path`, `code.line.number`, `lk.log.source`, `lk.log.logger`; the per-source floor (WebRTC at `error`, own module never) | -| `Scope::set_room(RoomIdentity { sid, name, participant_sid, participant_identity })` | `lk.room.*`, `lk.participant.*` on every record the scope captures from then on | +| `Scope::set_room(RoomIdentity { sid, name, participant_sid, participant_identity })` | `lk.room_sid`, `lk.pii.room_name`, `lk.participant_id`, `lk.pii.participant_identity` on every record the scope captures from then on | | `Scope::start(SpanName, parent) -> Span`; `Span::detached(name)` | an OTLP span (`lk.connect` / `lk.reconnect` are `client`, the rest `internal`); `Reconnect { reason }` sets `lk.reconnect.reason` | | `Span::step(SpanStep)` | a span event named `ws_open` … `room_connected`, `subscribed`, `first_media`, `attempt N quick|full` (which also sets `lk.reconnect.attempts` / `.mode`) | -| `Span::set_track(SpanTrack { sid, kind, source, remote_identity })` | `lk.track.sid`, `lk.track.kind`, `lk.track.source`, `lk.participant.remote_identity` | +| `Span::set_track(SpanTrack { sid, kind, source, remote_identity })` | `lk.track_sid`, `lk.track.kind`, `lk.track_source`, `lk.pii.remote_participant_identity` | | `Span::end(outcome, error)` / `fail(error)` / `cancel()` | status, `lk.outcome`, and `error.type` when it failed; ending twice is a no-op | | `Span::describe()` | `lk.connect: ws_open +1.49s, signal +0.03s, total 1.83s, ok` — the console line, identical on every platform | | `Span::context()` | `TraceContext { trace_id, span_id }` for log correlation; `None` when detached | @@ -595,7 +601,7 @@ the policy. `Attribute { key, value }` survives only as the open bag: `emit_cust | `Scope::record_stats_report(sid, kind, direction, Vec, ts)` | the same mapping for one track's report (platforms that poll per track) | | `DisconnectReason::from(i32)`, `ReconnectReason::from(i32)` | the protocol numbers → the shared enums (an unknown number is `Unknown`) | | `Scope::log(LogRecord)` | a record filed under the session without an ambient span (Dart has no task-local outside a zone); same floor and filters as `Telemetry::log` | -| `Scope::disconnected(DisconnectReason)` | `lk.room.disconnected` with `lk.disconnect.reason`, once — info when the client hung up, warn otherwise; pending subscribes end, the session's RTC windows ship and its state is retired; a repeated call is a no-op until a new `lk.connect` | +| `Scope::disconnected(DisconnectReason)` | `lk.room.disconnected` with `lk.disconnect_reason`, once — info when the client hung up, warn otherwise; pending subscribes end, the session's RTC windows ship and its state is retired; a repeated call is a no-op until a new `lk.connect` | | `RtcStatsSample.layer` (rid, ssrc or stats id) | simulcast layers folded into one monotonic series per track before windowing | Timing rule: span calls are synchronous and stamp the clock inside the core, so the only skew is diff --git a/livekit-telemetry/src/device.rs b/livekit-telemetry/src/device.rs index feb8ea019..5ca12f440 100644 --- a/livekit-telemetry/src/device.rs +++ b/livekit-telemetry/src/device.rs @@ -12,6 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +use livekit_protocol::semconv::{ + self, event, lk_device_app_state as app_state, lk_device_audio_interruption as interruption, + lk_device_audio_route_reason as route_reason, lk_device_capture_device as capture_device, + lk_device_capture_reason as capture_reason, lk_device_memory_pressure as memory, + lk_device_thermal_state as thermal, upstream, upstream::network_connection_type as network, +}; + use crate::TelemetryEvent; /// Thermal pressure as the OS reports it (`ProcessInfo.thermalState`, `PowerManager` thermal @@ -201,9 +208,9 @@ impl DeviceState { && previous.is_none_or(|p| p.thermal != self.thermal) { events.push( - TelemetryEvent::new("lk.device.thermal.changed") + TelemetryEvent::new(event::LK_DEVICE_THERMAL_CHANGED) .with_body(format!("thermal: {}", self.thermal.as_str())) - .with_attribute("lk.device.thermal.state", self.thermal.as_str()), + .with_attribute(semconv::LK_DEVICE_THERMAL_STATE, self.thermal.as_str()), ); } if let Some(low_power) = self @@ -211,23 +218,23 @@ impl DeviceState { .filter(|_| previous.is_none_or(|p| p.low_power_mode != self.low_power_mode)) { events.push( - TelemetryEvent::new("lk.device.low_power.changed") + TelemetryEvent::new(event::LK_DEVICE_LOW_POWER_CHANGED) .with_body(format!("low power mode: {}", if low_power { "on" } else { "off" })) - .with_attribute("lk.device.low_power.enabled", low_power), + .with_attribute(semconv::LK_DEVICE_LOW_POWER_ENABLED, low_power), ); } if previous.is_none_or(|p| p.app_state != self.app_state) { events.push( - TelemetryEvent::new("lk.device.app_state.changed") + TelemetryEvent::new(event::LK_DEVICE_APP_STATE_CHANGED) .with_body(format!("app: {}", self.app_state.as_str())) - .with_attribute("lk.device.app_state", self.app_state.as_str()), + .with_attribute(semconv::LK_DEVICE_APP_STATE, self.app_state.as_str()), ); } if previous.is_none_or(|p| p.memory != self.memory) { events.push( - TelemetryEvent::new("lk.device.memory.changed") + TelemetryEvent::new(event::LK_DEVICE_MEMORY_CHANGED) .with_body(format!("memory pressure: {}", self.memory.as_str())) - .with_attribute("lk.device.memory.pressure", self.memory.as_str()), + .with_attribute(semconv::LK_DEVICE_MEMORY_PRESSURE, self.memory.as_str()), ); } if previous.is_none_or(|p| { @@ -235,16 +242,19 @@ impl DeviceState { != (self.network, self.network_expensive, self.network_constrained) }) { events.push( - TelemetryEvent::new("lk.device.network.changed") + TelemetryEvent::new(event::LK_DEVICE_NETWORK_CHANGED) .with_body(format!( "network: {}{}{}", self.network.as_str(), if self.network_expensive { ", expensive" } else { "" }, if self.network_constrained { ", constrained" } else { "" } )) - .with_attribute("network.connection.type", self.network.as_str()) - .with_attribute("lk.device.network.expensive", self.network_expensive) - .with_attribute("lk.device.network.constrained", self.network_constrained), + .with_attribute(upstream::NETWORK_CONNECTION_TYPE, self.network.as_str()) + .with_attribute(semconv::LK_DEVICE_NETWORK_EXPENSIVE, self.network_expensive) + .with_attribute( + semconv::LK_DEVICE_NETWORK_CONSTRAINED, + self.network_constrained, + ), ); } // Per-percent updates are noise; report charging flips and bucket crossings. @@ -256,13 +266,13 @@ impl DeviceState { }) }) { events.push( - TelemetryEvent::new("lk.device.battery.changed") + TelemetryEvent::new(event::LK_DEVICE_BATTERY_CHANGED) .with_body(format!( "battery: {level}%{}", if self.battery_charging { ", charging" } else { "" } )) - .with_attribute("lk.device.battery.level", i64::from(level)) - .with_attribute("lk.device.battery.charging", self.battery_charging), + .with_attribute(semconv::LK_DEVICE_BATTERY_LEVEL, i64::from(level)) + .with_attribute(semconv::LK_DEVICE_BATTERY_CHARGING, self.battery_charging), ); } events @@ -272,11 +282,12 @@ impl DeviceState { impl ThermalState { fn as_str(self) -> &'static str { match self { + // Never sent: an unknown reading makes no change event. ThermalState::Unknown => "unknown", - ThermalState::Nominal => "nominal", - ThermalState::Fair => "fair", - ThermalState::Serious => "serious", - ThermalState::Critical => "critical", + ThermalState::Nominal => thermal::NOMINAL, + ThermalState::Fair => thermal::FAIR, + ThermalState::Serious => thermal::SERIOUS, + ThermalState::Critical => thermal::CRITICAL, } } } @@ -284,8 +295,8 @@ impl ThermalState { impl AppState { fn as_str(self) -> &'static str { match self { - AppState::Foreground => "foreground", - AppState::Background => "background", + AppState::Foreground => app_state::FOREGROUND, + AppState::Background => app_state::BACKGROUND, } } } @@ -293,9 +304,9 @@ impl AppState { impl MemoryPressure { fn as_str(self) -> &'static str { match self { - MemoryPressure::Normal => "normal", - MemoryPressure::Warning => "warning", - MemoryPressure::Critical => "critical", + MemoryPressure::Normal => memory::NORMAL, + MemoryPressure::Warning => memory::WARNING, + MemoryPressure::Critical => memory::CRITICAL, } } } @@ -303,14 +314,15 @@ impl MemoryPressure { impl NetworkType { fn as_str(self) -> &'static str { match self { - NetworkType::Unknown => "unknown", - NetworkType::Wifi => "wifi", - NetworkType::Cell => "cell", - NetworkType::Wired => "wired", + NetworkType::Unknown => network::UNKNOWN, + NetworkType::Wifi => network::WIFI, + NetworkType::Cell => network::CELL, + NetworkType::Wired => network::WIRED, + // Not OTel members; the registry notes that clients send them. NetworkType::Vpn => "vpn", NetworkType::Bluetooth => "bluetooth", NetworkType::Other => "other", - NetworkType::Unavailable => "unavailable", + NetworkType::Unavailable => network::UNAVAILABLE, } } } @@ -418,9 +430,46 @@ pub enum DeviceEvent { }, } -/// A variant's wire value: its `Debug` name in snake case (`OldDeviceUnavailable` → -/// `old_device_unavailable`). Variant names are therefore the wire contract: renaming a variant -/// changes what ships, and the tests pin every value to `SPEC.md`. +impl AudioRouteReason { + fn as_str(self) -> &'static str { + match self { + Self::NewDevice => route_reason::NEW_DEVICE, + Self::OldDeviceUnavailable => route_reason::OLD_DEVICE_UNAVAILABLE, + Self::CategoryChange => route_reason::CATEGORY_CHANGE, + Self::Override => route_reason::OVERRIDE, + Self::WakeFromSleep => route_reason::WAKE_FROM_SLEEP, + Self::NoSuitableRoute => route_reason::NO_SUITABLE_ROUTE, + Self::RouteConfigurationChange => route_reason::ROUTE_CONFIGURATION_CHANGE, + Self::Unknown => route_reason::UNKNOWN, + } + } +} + +impl CaptureDevice { + fn as_str(self) -> &'static str { + match self { + Self::Camera => capture_device::CAMERA, + Self::Microphone => capture_device::MICROPHONE, + Self::ScreenShare => capture_device::SCREEN_SHARE, + } + } +} + +impl CaptureFailure { + fn as_str(self) -> &'static str { + match self { + Self::PermissionDenied => capture_reason::PERMISSION_DENIED, + Self::NotFound => capture_reason::NOT_FOUND, + Self::InUse => capture_reason::IN_USE, + Self::Disconnected => capture_reason::DISCONNECTED, + Self::Other => capture_reason::OTHER, + } + } +} + +/// A variant's `Debug` name in snake case (`WiredHeadset` → `wired_headset`): the wire value of +/// an [`AudioOutput`], which the registry does not enumerate, and the display bodies. Renaming +/// such a variant changes what ships; the tests pin every value to `SPEC.md`. pub(crate) fn snake_case(debug: impl std::fmt::Debug) -> String { let mut out = String::new(); for (i, c) in format!("{debug:?}").chars().enumerate() { @@ -438,25 +487,25 @@ impl From for TelemetryEvent { match event { DeviceEvent::AudioRouteChanged { outputs, reason } => { let outputs = outputs.into_iter().map(snake_case).collect::>().join(","); - let reason = snake_case(reason); - TelemetryEvent::new("lk.device.audio_route.changed") + let reason = reason.as_str(); + TelemetryEvent::new(event::LK_DEVICE_AUDIO_ROUTE_CHANGED) .with_body(format!("audio route: {outputs} ({reason})")) - .with_attribute("lk.device.audio_route.outputs", outputs) - .with_attribute("lk.device.audio_route.reason", reason) + .with_attribute(semconv::LK_DEVICE_AUDIO_ROUTE_OUTPUTS, outputs) + .with_attribute(semconv::LK_DEVICE_AUDIO_ROUTE_REASON, reason) } DeviceEvent::AudioInterruption { began } => { - let phase = if began { "began" } else { "ended" }; - TelemetryEvent::new("lk.device.audio.interruption") + let phase = if began { interruption::BEGAN } else { interruption::ENDED }; + TelemetryEvent::new(event::LK_DEVICE_AUDIO_INTERRUPTION) .with_body(format!("audio interruption {phase}")) - .with_attribute("lk.device.audio.interruption", phase) + .with_attribute(semconv::LK_DEVICE_AUDIO_INTERRUPTION, phase) } DeviceEvent::CaptureFailed { device, reason } => { - let (device, reason) = (snake_case(device), snake_case(reason)); - TelemetryEvent::new("lk.device.capture.failed") + let (device, reason) = (device.as_str(), reason.as_str()); + TelemetryEvent::new(event::LK_DEVICE_CAPTURE_FAILED) .with_severity(crate::Severity::Warn) .with_body(format!("capture failed: {device} ({reason})")) - .with_attribute("lk.device.capture.device", device) - .with_attribute("lk.device.capture.reason", reason) + .with_attribute(semconv::LK_DEVICE_CAPTURE_DEVICE, device) + .with_attribute(semconv::LK_DEVICE_CAPTURE_REASON, reason) } } } @@ -598,7 +647,7 @@ mod tests { R::Unknown, ]; assert_eq!( - names(reasons.into_iter().map(snake_case).collect()), + names(reasons.into_iter().map(|r| r.as_str().to_owned()).collect()), "new_device | old_device_unavailable | category_change | override | wake_from_sleep \ | no_suitable_route | route_configuration_change | unknown" ); @@ -622,13 +671,13 @@ mod tests { let devices = [CaptureDevice::Camera, CaptureDevice::Microphone, CaptureDevice::ScreenShare]; assert_eq!( - names(devices.into_iter().map(snake_case).collect()), + names(devices.into_iter().map(|d| d.as_str().to_owned()).collect()), "camera | microphone | screen_share" ); use CaptureFailure as F; let failures = [F::PermissionDenied, F::NotFound, F::InUse, F::Disconnected, F::Other]; assert_eq!( - names(failures.into_iter().map(snake_case).collect()), + names(failures.into_iter().map(|f| f.as_str().to_owned()).collect()), "permission_denied | not_found | in_use | disconnected | other" ); } diff --git a/livekit-telemetry/src/device_tests.rs b/livekit-telemetry/src/device_tests.rs index 79fc1e390..8dc65203c 100644 --- a/livekit-telemetry/src/device_tests.rs +++ b/livekit-telemetry/src/device_tests.rs @@ -294,7 +294,7 @@ async fn a_subscribe_past_its_deadline_stays_timed_out_through_disconnect() { .find(|s| { s.attributes .iter() - .any(|a| a.key == "lk.track.sid" && format!("{:?}", a.value).contains(sid)) + .any(|a| a.key == "lk.track_sid" && format!("{:?}", a.value).contains(sid)) }) .expect("span"); span.attributes.iter().find(|a| a.key == "lk.outcome").map(|a| format!("{:?}", a.value)) @@ -420,12 +420,12 @@ async fn custom_data_is_room_scoped_validated_and_snapshotted() { other.emit_custom("elsewhere", vec![]); let long = "x".repeat(1025); - room.set_attribute("lk.room.sid", Some("spoof".into())); + room.set_attribute("lk.room_sid", Some("spoof".into())); room.set_attribute("session.id", Some("spoof".into())); room.set_attribute("app.long", Some(long.clone().into())); room.set_attribute(&"k".repeat(129), Some("v".into())); room.emit_custom(&"n".repeat(129), vec![]); - room.emit_custom("bad", vec![Attribute::new("lk.track.sid", "spoof")]); + room.emit_custom("bad", vec![Attribute::new("lk.track_sid", "spoof")]); room.emit_custom("bad", vec![Attribute::new("v", long.as_str())]); for n in 0..63 { room.set_attribute(&format!("app.k{n}"), Some("v".into())); diff --git a/livekit-telemetry/src/event.rs b/livekit-telemetry/src/event.rs index 9d51ef29f..72fe51f23 100644 --- a/livekit-telemetry/src/event.rs +++ b/livekit-telemetry/src/event.rs @@ -14,6 +14,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use livekit_protocol::semconv::{self, lk_log_source as source, upstream}; + /// A discrete telemetry event. /// /// Exported as one OTLP log record whose `event_name` is [`name`](Self::name), following the @@ -90,9 +92,9 @@ impl TelemetryEvent { /// when it doubles as the body. /// /// Counts the attributes the record carries: once queued, that includes its session's - /// (`lk.room.*`, `lk.participant.*`, `session.id`) and the app's correlation attributes, all - /// taken at capture. Pipeline-wide attributes are added at export: the exporter's drains count - /// them for every record (`Store::drain`, `Spans::drain`). + /// (`lk.room_sid`, `lk.participant_id`, …, `session.id`) and the app's correlation + /// attributes, all taken at capture. Pipeline-wide attributes are added at export: the + /// exporter's drains count them for every record (`Store::drain`, `Spans::drain`). pub fn size_hint(&self) -> usize { RECORD_OVERHEAD_BYTES + 2 * self.name.len() @@ -160,9 +162,9 @@ pub enum LogSource { impl LogSource { fn as_str(self) -> &'static str { match self { - Self::Sdk => "sdk", - Self::Ffi => "ffi", - Self::WebRtc => "webrtc", + Self::Sdk => source::SDK, + Self::Ffi => source::FFI, + Self::WebRtc => source::WEBRTC, } } } @@ -208,18 +210,18 @@ impl From for TelemetryEvent { let mut event = TelemetryEvent::default() .with_severity(record.severity) .with_body(record.body) - .with_attribute("lk.log.source", record.source.as_str()); + .with_attribute(semconv::LK_LOG_SOURCE, record.source.as_str()); if let Some(logger) = record.logger.filter(|s| !s.is_empty()) { - event = event.with_attribute("lk.log.logger", logger); + event = event.with_attribute(semconv::LK_LOG_LOGGER, logger); } if let Some(function) = record.function.filter(|s| !s.is_empty()) { - event = event.with_attribute("code.function.name", function); + event = event.with_attribute(upstream::CODE_FUNCTION_NAME, function); } if let Some(file) = record.file.filter(|s| !s.is_empty()) { - event = event.with_attribute("code.file.path", file); + event = event.with_attribute(upstream::CODE_FILE_PATH, file); } if let Some(line) = record.line.filter(|l| *l > 0) { - event = event.with_attribute("code.line.number", i64::from(line)); + event = event.with_attribute(upstream::CODE_LINE_NUMBER, i64::from(line)); } event.timestamp_ns = record.timestamp_ns; event.span_id = record.span_id; @@ -326,9 +328,9 @@ pub(crate) fn is_reserved(key: &str) -> bool { key.starts_with("lk.") || key.starts_with("otel.") || key.starts_with("code.") - || key == "session.id" - || key == "error.type" - || key == "network.connection.type" + || key == upstream::SESSION_ID + || key == upstream::ERROR_TYPE + || key == upstream::NETWORK_CONNECTION_TYPE } /// Whether an app-provided attribute is within the limits and outside the SDK's namespace. diff --git a/livekit-telemetry/src/exporter.rs b/livekit-telemetry/src/exporter.rs index c695aa959..dff421747 100644 --- a/livekit-telemetry/src/exporter.rs +++ b/livekit-telemetry/src/exporter.rs @@ -22,6 +22,7 @@ use std::{ }; use flate2::{write::GzEncoder, Compression}; +use livekit_protocol::semconv::span; use tokio::sync::{mpsc, oneshot}; use tokio::time::{sleep_until, timeout, Instant}; @@ -68,7 +69,7 @@ const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); /// anyway: the cap that bounds the policy when its signals lie. Hard holds have no such escape. const MAX_HOLD: Duration = Duration::from_secs(60); /// While one of these is open the uplink belongs to signaling and ICE/DTLS. -pub(crate) const SENSITIVE_SPANS: &[&str] = &["lk.connect", "lk.reconnect"]; +pub(crate) const SENSITIVE_SPANS: &[&str] = &[span::LK_CONNECT, span::LK_RECONNECT]; /// RFC 9218 request priority: lowest urgency, not incremental. A hint for HTTP/2+ hops that /// implement it; the host transport marks the local traffic class (see `TelemetryTransport`). const PRIORITY: &str = "u=7"; @@ -1758,12 +1759,12 @@ pub(crate) mod tests { #[test] fn gzip_shrinks_a_batch_of_records_at_least_this_much() { let session = ScopeState::new(); - session.set_attribute("lk.room.sid", Some("RM_UpE3vYzFmJp8".into())); + session.set_attribute("lk.room_sid", Some("RM_UpE3vYzFmJp8".into())); let records = (0..100) .map(|i| { let event = TelemetryEvent::new("lk.rtc.stats.sample") .with_body(format!("video inbound: {i} kbps, rtt 48 ms, 30 fps")) - .with_attribute("lk.track.sid", "TR_VCd8tCgNdE9h") + .with_attribute("lk.track_sid", "TR_VCd8tCgNdE9h") .with_attribute("lk.rtc.bytes", i as i64 * 1_000); QueuedEvent::new(event, session.clone()) }) diff --git a/livekit-telemetry/src/lib.rs b/livekit-telemetry/src/lib.rs index 43548196a..4b3cec7a6 100644 --- a/livekit-telemetry/src/lib.rs +++ b/livekit-telemetry/src/lib.rs @@ -79,6 +79,9 @@ mod backend_tests; /// The device contract, end to end through the pipeline and the process-wide opt-out. #[cfg(test)] mod device_tests; +/// Every name and enum value on the wire, pinned to the registry. +#[cfg(test)] +mod wire_tests; #[cfg(feature = "uniffi")] uniffi::setup_scaffolding!(); diff --git a/livekit-telemetry/src/otlp.rs b/livekit-telemetry/src/otlp.rs index 9716de74b..84a5d2191 100644 --- a/livekit-telemetry/src/otlp.rs +++ b/livekit-telemetry/src/otlp.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use livekit_protocol::semconv::{self, upstream}; use prost::Message; use crate::span::SpanKind; @@ -135,9 +136,9 @@ fn log_record( // set it (`otel.*` is reserved). let body = event.body.or_else(|| (!event.name.is_empty()).then(|| event.name.clone())); // The core's copy is the only one: a pipeline attribute of that name never ships. - event.attributes.retain(|a| a.key != "otel.event.name"); + event.attributes.retain(|a| a.key != upstream::OTEL_EVENT_NAME); if !event.name.is_empty() { - event.attributes.push(Attribute::new("otel.event.name", event.name.clone())); + event.attributes.push(Attribute::new(upstream::OTEL_EVENT_NAME, event.name.clone())); } last_value_wins(&mut event.attributes); LogRecord { @@ -160,7 +161,7 @@ fn log_record( fn otlp_span(mut record: SpanRecord, global: &[Attribute]) -> Span { fill_in_global(&mut record.attributes, global); // The outcome is the core's, as `session.id` is: a span's own copies never ship. - record.attributes.retain(|a| a.key != "lk.outcome" && a.key != "error.type"); + record.attributes.retain(|a| a.key != semconv::LK_OUTCOME && a.key != upstream::ERROR_TYPE); let mut attributes: Vec = record.attributes.iter().map(KeyValue::from).collect(); attributes.extend(record.outcome_attributes().iter().map(KeyValue::from)); Span { @@ -393,10 +394,10 @@ mod tests { /// The attributes `Scope::set_room` gives a session in a call. fn in_room(session: &crate::scope::ScopeState) { for (key, value) in [ - ("lk.room.sid", "RM_UpE3vYzFmJp8"), - ("lk.room.name", "standup-2026-10-08"), - ("lk.participant.sid", "PA_kQ2Fv8sWbE3n"), - ("lk.participant.identity", "user-4f1c2a"), + ("lk.room_sid", "RM_UpE3vYzFmJp8"), + ("lk.pii.room_name", "standup-2026-10-08"), + ("lk.participant_id", "PA_kQ2Fv8sWbE3n"), + ("lk.pii.participant_identity", "user-4f1c2a"), ] { session.set_attribute(key, Some(value.into())); } @@ -416,16 +417,16 @@ mod tests { let session = crate::scope::ScopeState::new(); in_room(&session); let spoofed = - TelemetryEvent::new("lk.room.disconnected").with_attribute("lk.room.sid", "x"); + TelemetryEvent::new("lk.room.disconnected").with_attribute("lk.room_sid", "x"); let queued = QueuedEvent::new(spoofed, session.clone()); // The platform clears the room right after the disconnect, before the next export. - session.set_attribute("lk.room.sid", None); - let global = [Attribute::new("lk.room.sid", "global"), Attribute::new("acme.tenant", "t1")]; + session.set_attribute("lk.room_sid", None); + let global = [Attribute::new("lk.room_sid", "global"), Attribute::new("acme.tenant", "t1")]; let bytes = encode_logs(&[], &global, vec![queued]); let decoded = ExportLogsServiceRequest::decode(&bytes[..]).expect("valid OTLP"); let record = &decoded.resource_logs[0].scope_logs[0].log_records[0]; let text = |s: &str| any_value::Value::StringValue(s.into()); - assert_eq!(values(&record.attributes, "lk.room.sid"), [text("RM_UpE3vYzFmJp8")]); + assert_eq!(values(&record.attributes, "lk.room_sid"), [text("RM_UpE3vYzFmJp8")]); assert_eq!(values(&record.attributes, "session.id"), [text(session.hex())]); assert_eq!(values(&record.attributes, "acme.tenant"), [text("t1")], "globals fill in"); } @@ -436,13 +437,13 @@ mod tests { in_room(&session); let mut spans = crate::span::Spans::new(1); let id = spans.begin_in("lk.publish", SpanKind::Internal, None, session.clone()); - spans.end(id, SpanOutcome::Ok, None, vec![Attribute::new("lk.room.sid", "x")]); - session.set_attribute("lk.room.sid", None); + spans.end(id, SpanOutcome::Ok, None, vec![Attribute::new("lk.room_sid", "x")]); + session.set_attribute("lk.room_sid", None); let bytes = encode_spans(&[], &[], spans.drain(1, usize::MAX, 0)); let decoded = ExportTraceServiceRequest::decode(&bytes[..]).expect("valid OTLP"); let span = &decoded.resource_spans[0].scope_spans[0].spans[0]; let text = |s: &str| any_value::Value::StringValue(s.into()); - assert_eq!(values(&span.attributes, "lk.room.sid"), [text("RM_UpE3vYzFmJp8")]); + assert_eq!(values(&span.attributes, "lk.room_sid"), [text("RM_UpE3vYzFmJp8")]); assert_eq!(values(&span.attributes, "session.id"), [text(session.hex())]); } diff --git a/livekit-telemetry/src/rtc.rs b/livekit-telemetry/src/rtc.rs index 8ea218285..8eb1af868 100644 --- a/livekit-telemetry/src/rtc.rs +++ b/livekit-telemetry/src/rtc.rs @@ -18,6 +18,9 @@ use std::{ time::Duration, }; +use livekit_protocol::semconv::{ + self, event, lk_track_direction as direction, lk_track_kind as kind, +}; use tokio::time::Instant; use crate::{ @@ -460,12 +463,13 @@ impl Gauge { (self.n > 0).then(|| self.sum / self.n as f64) } - fn attach(&self, event: TelemetryEvent, key: &str) -> TelemetryEvent { + /// The `.min`, `.max` and `.avg` keys, in that order. + fn attach(&self, event: TelemetryEvent, [min, max, avg_key]: [&str; 3]) -> TelemetryEvent { let Some(avg) = self.avg() else { return event }; event - .with_attribute(format!("{key}.min"), self.min) - .with_attribute(format!("{key}.max"), self.max) - .with_attribute(format!("{key}.avg"), avg) + .with_attribute(min, self.min) + .with_attribute(max, self.max) + .with_attribute(avg_key, avg) } } @@ -573,44 +577,64 @@ impl Window { let window_ms = end_ns.saturating_sub(self.start_ns) / 1_000_000; let body = self.summary(); let last = self.last; - let mut event = TelemetryEvent::new("lk.rtc.stats.sample") + let mut event = TelemetryEvent::new(event::LK_RTC_STATS_SAMPLE) .with_body(body) - .with_attribute("lk.track.sid", last.track_sid) - .with_attribute("lk.track.kind", last.kind.as_str()) - .with_attribute("lk.track.direction", last.direction.as_str()) - .with_attribute("lk.rtc.window_ms", window_ms as i64) - .with_attribute("lk.rtc.samples", self.samples as i64); + .with_attribute(semconv::LK_TRACK_SID, last.track_sid) + .with_attribute(semconv::LK_TRACK_KIND, last.kind.as_str()) + .with_attribute(semconv::LK_TRACK_DIRECTION, last.direction.as_str()) + .with_attribute(semconv::LK_RTC_WINDOW_MS, window_ms as i64) + .with_attribute(semconv::LK_RTC_SAMPLES, self.samples as i64); if let Some(codec) = last.codec { - event = event.with_attribute("lk.rtc.codec", codec); + event = event.with_attribute(semconv::LK_RTC_CODEC, codec); } let counters = [ - ("lk.rtc.bytes", last.bytes), - ("lk.rtc.packets", last.packets), - ("lk.rtc.packets_lost", last.packets_lost), - ("lk.rtc.freeze_count", last.freeze_count), - ("lk.rtc.freezes_duration_ms", last.freezes_duration_ms), - ("lk.rtc.concealed_samples", last.concealed_samples), - ("lk.rtc.concealment_events", last.concealment_events), - ("lk.rtc.jitter_buffer_delay_ms", last.jitter_buffer_delay_ms), - ("lk.rtc.jitter_buffer_emitted_count", last.jitter_buffer_emitted_count), - ("lk.rtc.quality_limitation.bandwidth_ms", last.quality_limitation_bandwidth_ms), - ("lk.rtc.quality_limitation.cpu_ms", last.quality_limitation_cpu_ms), - ("lk.rtc.quality_limitation.other_ms", last.quality_limitation_other_ms), - ("lk.rtc.pause_count", last.pause_count), - ("lk.rtc.pauses_duration_ms", last.pauses_duration_ms), - ("lk.rtc.silent_concealed_samples", last.silent_concealed_samples), - ("lk.rtc.interruption_count", last.interruption_count), - ("lk.rtc.interruptions_duration_ms", last.interruptions_duration_ms), + (semconv::LK_RTC_BYTES, last.bytes), + (semconv::LK_RTC_PACKETS, last.packets), + (semconv::LK_RTC_PACKETS_LOST, last.packets_lost), + (semconv::LK_RTC_FREEZE_COUNT, last.freeze_count), + (semconv::LK_RTC_FREEZES_DURATION_MS, last.freezes_duration_ms), + (semconv::LK_RTC_CONCEALED_SAMPLES, last.concealed_samples), + (semconv::LK_RTC_CONCEALMENT_EVENTS, last.concealment_events), + (semconv::LK_RTC_JITTER_BUFFER_DELAY_MS, last.jitter_buffer_delay_ms), + (semconv::LK_RTC_JITTER_BUFFER_EMITTED_COUNT, last.jitter_buffer_emitted_count), + (semconv::LK_RTC_QUALITY_LIMITATION_BANDWIDTH_MS, last.quality_limitation_bandwidth_ms), + (semconv::LK_RTC_QUALITY_LIMITATION_CPU_MS, last.quality_limitation_cpu_ms), + (semconv::LK_RTC_QUALITY_LIMITATION_OTHER_MS, last.quality_limitation_other_ms), + (semconv::LK_RTC_PAUSE_COUNT, last.pause_count), + (semconv::LK_RTC_PAUSES_DURATION_MS, last.pauses_duration_ms), + (semconv::LK_RTC_SILENT_CONCEALED_SAMPLES, last.silent_concealed_samples), + (semconv::LK_RTC_INTERRUPTION_COUNT, last.interruption_count), + (semconv::LK_RTC_INTERRUPTIONS_DURATION_MS, last.interruptions_duration_ms), ]; for (key, value) in counters { if let Some(value) = value { event = event.with_attribute(key, value as i64); } } - event = self.jitter.attach(event, "lk.rtc.jitter_ms"); - event = self.rtt.attach(event, "lk.rtc.rtt_ms"); - event = self.fps.attach(event, "lk.rtc.fps"); - event = self.audio_level.attach(event, "lk.rtc.audio_level"); + event = self.jitter.attach( + event, + [ + semconv::LK_RTC_JITTER_MS_MIN, + semconv::LK_RTC_JITTER_MS_MAX, + semconv::LK_RTC_JITTER_MS_AVG, + ], + ); + event = self.rtt.attach( + event, + [semconv::LK_RTC_RTT_MS_MIN, semconv::LK_RTC_RTT_MS_MAX, semconv::LK_RTC_RTT_MS_AVG], + ); + event = self.fps.attach( + event, + [semconv::LK_RTC_FPS_MIN, semconv::LK_RTC_FPS_MAX, semconv::LK_RTC_FPS_AVG], + ); + event = self.audio_level.attach( + event, + [ + semconv::LK_RTC_AUDIO_LEVEL_MIN, + semconv::LK_RTC_AUDIO_LEVEL_MAX, + semconv::LK_RTC_AUDIO_LEVEL_AVG, + ], + ); event.timestamp_ns = Some(end_ns); event } @@ -919,10 +943,10 @@ fn test_session() -> Arc { } impl TrackKind { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { - TrackKind::Audio => "audio", - TrackKind::Video => "video", + TrackKind::Audio => kind::AUDIO, + TrackKind::Video => kind::VIDEO, } } } @@ -930,8 +954,8 @@ impl TrackKind { impl StreamDirection { fn as_str(self) -> &'static str { match self { - StreamDirection::Inbound => "inbound", - StreamDirection::Outbound => "outbound", + StreamDirection::Inbound => direction::INBOUND, + StreamDirection::Outbound => direction::OUTBOUND, } } } diff --git a/livekit-telemetry/src/scope.rs b/livekit-telemetry/src/scope.rs index c8035588d..82ae294c9 100644 --- a/livekit-telemetry/src/scope.rs +++ b/livekit-telemetry/src/scope.rs @@ -19,6 +19,7 @@ use std::{ time::Duration, }; +use livekit_protocol::semconv::{self, event, lk_disconnect_reason as reason, upstream}; use tokio::time::Instant; use crate::{ @@ -28,12 +29,12 @@ use crate::{ }; /// One session's identity: the trace id every one of its records carries, and the attributes -/// copied into them when they are captured (`lk.room.sid`, `lk.participant.identity`, …). +/// copied into them when they are captured (`lk.room_sid`, `lk.pii.participant_identity`, …). pub(crate) struct ScopeState { pub trace_id: [u8; 16], /// The trace id as hex, formatted once: the session's owner in `Destinations`. hex: String, - /// SDK-owned attributes (`lk.room.*`, `lk.participant.*`), copied into each record when it is + /// SDK-owned attributes (the room and local participant), copied into each record when it is /// captured (see [`ScopeState::decorate`]). attributes: Mutex>, /// The app's correlation attributes, copied into each record when it is captured, so a @@ -278,8 +279,8 @@ impl ScopeState { own.push(attribute.clone()); } drop(session); - own.retain(|a| a.key != "session.id"); - own.push(Attribute::new("session.id", self.hex())); + own.retain(|a| a.key != upstream::SESSION_ID); + own.push(Attribute::new(upstream::SESSION_ID, self.hex())); } } @@ -307,16 +308,16 @@ impl fmt::Debug for ScopeState { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RoomIdentity { - /// The room's sid (`RM_…`), `lk.room.sid`. + /// The room's sid (`RM_…`), `lk.room_sid`. #[cfg_attr(feature = "uniffi", uniffi(default))] pub sid: Option, - /// The room's name, `lk.room.name`. + /// The room's name, `lk.pii.room_name`. #[cfg_attr(feature = "uniffi", uniffi(default))] pub name: Option, - /// The local participant's sid (`PA_…`), `lk.participant.sid`. + /// The local participant's sid (`PA_…`), `lk.participant_id`. #[cfg_attr(feature = "uniffi", uniffi(default))] pub participant_sid: Option, - /// The local participant's identity, `lk.participant.identity`. + /// The local participant's identity, `lk.pii.participant_identity`. #[cfg_attr(feature = "uniffi", uniffi(default))] pub participant_identity: Option, } @@ -363,6 +364,33 @@ pub enum DisconnectReason { ReconnectFailed, } +impl DisconnectReason { + /// The protobuf `DisconnectReason` name, the spelling Agents sends; `RECONNECT_FAILED` is the + /// one client-only value. + fn as_str(self) -> &'static str { + match self { + Self::Unknown => reason::UNKNOWN_REASON, + Self::ClientInitiated => reason::CLIENT_INITIATED, + Self::DuplicateIdentity => reason::DUPLICATE_IDENTITY, + Self::ServerShutdown => reason::SERVER_SHUTDOWN, + Self::ParticipantRemoved => reason::PARTICIPANT_REMOVED, + Self::RoomDeleted => reason::ROOM_DELETED, + Self::StateMismatch => reason::STATE_MISMATCH, + Self::JoinFailure => reason::JOIN_FAILURE, + Self::Migration => reason::MIGRATION, + Self::SignalClose => reason::SIGNAL_CLOSE, + Self::RoomClosed => reason::ROOM_CLOSED, + Self::UserUnavailable => reason::USER_UNAVAILABLE, + Self::UserRejected => reason::USER_REJECTED, + Self::SipTrunkFailure => reason::SIP_TRUNK_FAILURE, + Self::ConnectionTimeout => reason::CONNECTION_TIMEOUT, + Self::MediaFailure => reason::MEDIA_FAILURE, + Self::AgentError => reason::AGENT_ERROR, + Self::ReconnectFailed => reason::RECONNECT_FAILED, + } + } +} + impl From for DisconnectReason { /// The protocol's `DisconnectReason` number, so no SDK keeps its own switch; a number this SDK /// does not know is `Unknown`. @@ -467,14 +495,13 @@ impl Scope { } else { crate::Severity::Warn }; - let reason = crate::device::snake_case(reason); // The body repeats the reason on purpose: it is the display line a log view shows (like // the device events' bodies), and it takes the place of the name a body-less event shows. self.emit( - TelemetryEvent::new("lk.room.disconnected") + TelemetryEvent::new(event::LK_ROOM_DISCONNECTED) .with_severity(severity) - .with_body(format!("disconnected: {reason}")) - .with_attribute("lk.disconnect.reason", reason), + .with_body(format!("disconnected: {}", crate::device::snake_case(reason))) + .with_attribute(semconv::LK_DISCONNECT_REASON, reason.as_str()), ); } @@ -676,14 +703,15 @@ impl Scope { Span::bound(name, parent, self.telemetry.clone(), &self.state) } - /// The room and local participant, as `lk.room.*` / `lk.participant.*` on every record - /// captured from then on. + /// The room and local participant, as `lk.room_sid`, `lk.pii.room_name`, + /// `lk.participant_id` and `lk.pii.participant_identity` on every record captured from then + /// on. pub fn set_room(&self, room: RoomIdentity) { for (key, value) in [ - ("lk.room.sid", room.sid), - ("lk.room.name", room.name), - ("lk.participant.sid", room.participant_sid), - ("lk.participant.identity", room.participant_identity), + (semconv::LK_ROOM_SID, room.sid), + (semconv::LK_PII_ROOM_NAME, room.name), + (semconv::LK_PARTICIPANT_ID, room.participant_sid), + (semconv::LK_PII_PARTICIPANT_IDENTITY, room.participant_identity), ] { // Identifiers, not free text: an over-long one is not attached (counted as invalid). if value.as_ref().is_some_and(|v| v.len() > crate::event::MAX_VALUE_BYTES) { @@ -928,11 +956,11 @@ mod tests { .map(DisconnectReason::from) .chain([DisconnectReason::ReconnectFailed, DisconnectReason::from(0)]); assert_eq!( - reasons.map(crate::device::snake_case).collect::>().join(" | "), - "client_initiated | duplicate_identity | server_shutdown | participant_removed \ - | room_deleted | state_mismatch | join_failure | migration | signal_close \ - | room_closed | user_unavailable | user_rejected | sip_trunk_failure \ - | connection_timeout | media_failure | agent_error | reconnect_failed | unknown" + reasons.map(DisconnectReason::as_str).collect::>().join(" | "), + "CLIENT_INITIATED | DUPLICATE_IDENTITY | SERVER_SHUTDOWN | PARTICIPANT_REMOVED \ + | ROOM_DELETED | STATE_MISMATCH | JOIN_FAILURE | MIGRATION | SIGNAL_CLOSE \ + | ROOM_CLOSED | USER_UNAVAILABLE | USER_REJECTED | SIP_TRUNK_FAILURE \ + | CONNECTION_TIMEOUT | MEDIA_FAILURE | AGENT_ERROR | RECONNECT_FAILED | UNKNOWN_REASON" ); assert_eq!(DisconnectReason::from(99), DisconnectReason::Unknown); } @@ -940,7 +968,7 @@ mod tests { #[test] fn custom_attributes_reject_sdk_keys_and_fit_replacements_at_capacity() { for key in [ - "lk.room.sid", + "lk.room_sid", "otel.event.name", "otel.scope.name", "code.function.name", diff --git a/livekit-telemetry/src/span.rs b/livekit-telemetry/src/span.rs index b4fa52e66..f252e20a0 100644 --- a/livekit-telemetry/src/span.rs +++ b/livekit-telemetry/src/span.rs @@ -15,6 +15,8 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Weak}; +use livekit_protocol::semconv::{self, lk_outcome as outcome, upstream}; + use crate::{ event::{attributes_size_hint, now_unix_nanos}, scope::ScopeState, @@ -47,9 +49,9 @@ pub enum SpanOutcome { impl SpanOutcome { pub(crate) fn as_str(self) -> &'static str { match self { - SpanOutcome::Ok => "ok", - SpanOutcome::Error => "error", - SpanOutcome::Cancelled => "cancelled", + SpanOutcome::Ok => outcome::OK, + SpanOutcome::Error => outcome::ERROR, + SpanOutcome::Cancelled => outcome::CANCELLED, } } } @@ -284,7 +286,7 @@ impl Spans { /// Its attributes are taken now, like a log record's when it is queued: `attributes`, then /// the app's correlation attributes, cut to [`MAX_ATTRIBUTES_PER_SPAN`] — the app's go first, /// then the tail of `attributes` — counted in [`SpanRecord::dropped_attributes`] and warned - /// about once per drain. The session's own (`lk.room.*`, `session.id`) and the outcome are + /// about once per drain. The session's own (`lk.room_sid`, `session.id`, …) and the outcome are /// added past the cap and always ship. pub fn end( &mut self, @@ -397,9 +399,10 @@ impl SpanRecord { /// `lk.outcome`, plus `error.type` when the span failed: the attributes every span carries /// beyond the caller's. pub(crate) fn outcome_attributes(&self) -> Vec { - let mut attributes = vec![Attribute::new("lk.outcome", self.outcome.as_str())]; + let mut attributes = vec![Attribute::new(semconv::LK_OUTCOME, self.outcome.as_str())]; if let (SpanOutcome::Error, Some(error_type)) = (self.outcome, &self.error_type) { - attributes.push(Attribute::new("error.type", AttributeValue::Str(error_type.clone()))); + let error_type = AttributeValue::Str(error_type.clone()); + attributes.push(Attribute::new(upstream::ERROR_TYPE, error_type)); } attributes } @@ -509,7 +512,7 @@ mod tests { crate::test_log::capture(); let mut spans = Spans::new(8); let session = ScopeState::new(); - session.set_attribute("lk.room.sid", Some("RM_1".into())); + session.set_attribute("lk.room_sid", Some("RM_1".into())); assert!(session.set_custom("app.call_id", Some("c1".into()))); let overflow = |spans: &mut Spans| { let id = spans.begin_in("lk.publish", SpanKind::Internal, None, session.clone()); @@ -526,7 +529,7 @@ mod tests { assert_eq!(span.dropped_attributes_count, 1, "the app's call id"); let keys: Vec<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect(); assert!(!keys.contains(&"app.call_id"), "the app's own go first"); - for key in ["lk.publish.k127", "lk.room.sid", "session.id", "lk.outcome"] { + for key in ["lk.publish.k127", "lk.room_sid", "session.id", "lk.outcome"] { assert!(keys.contains(&key), "{key} survives the cap"); } } diff --git a/livekit-telemetry/src/stats.rs b/livekit-telemetry/src/stats.rs index ae198dcf9..83e38b2c7 100644 --- a/livekit-telemetry/src/stats.rs +++ b/livekit-telemetry/src/stats.rs @@ -14,6 +14,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; +use livekit_protocol::semconv::{self, event}; + use crate::TelemetryEvent; /// Declares the pipeline's health counters once: the shared atomics ([`Counters`]), their @@ -143,7 +145,7 @@ impl Snapshot { /// persisted on its own, never an extra request — plus one at shutdown, so every session /// leaves a summary the fleet's success rates can be computed from. pub fn report(&self, cached_batches: u64) -> TelemetryEvent { - let mut event = TelemetryEvent::new("lk.telemetry.report") + let mut event = TelemetryEvent::new(event::LK_TELEMETRY_REPORT) .with_body(format!( "telemetry: {} batches sent ({} B), {} failed, {} dropped, {} cached", self.uploads_sent, @@ -152,25 +154,25 @@ impl Snapshot { self.problem_drops(), cached_batches )) - .with_attribute("lk.telemetry.uploads.sent", int(self.uploads_sent)) - .with_attribute("lk.telemetry.uploads.bytes", int(self.upload_bytes)) - .with_attribute("lk.telemetry.uploads.failed", int(self.upload_failures)) - .with_attribute("lk.telemetry.cache.batches", int(cached_batches)); + .with_attribute(semconv::LK_TELEMETRY_UPLOADS_SENT, int(self.uploads_sent)) + .with_attribute(semconv::LK_TELEMETRY_UPLOADS_BYTES, int(self.upload_bytes)) + .with_attribute(semconv::LK_TELEMETRY_UPLOADS_FAILED, int(self.upload_failures)) + .with_attribute(semconv::LK_TELEMETRY_CACHE_BATCHES, int(cached_batches)); for (key, value) in [ - ("lk.telemetry.uploads.timeouts", self.upload_timeouts), - ("lk.telemetry.uploads.unauthorized", self.auth_denied), - ("lk.telemetry.holds.capped", self.hold_cap_hits), - ("lk.telemetry.cache.write_errors", self.cache_write_errors), - ("lk.telemetry.dropped.queue_full", self.queue_full), - ("lk.telemetry.dropped.cache_error", self.cache_error), - ("lk.telemetry.dropped.cache_full", self.cache_full), - ("lk.telemetry.dropped.expired", self.expired), - ("lk.telemetry.dropped.corrupt", self.corrupt), - ("lk.telemetry.dropped.invalid", self.invalid), - ("lk.telemetry.dropped.rejected", self.rejected), - ("lk.telemetry.dropped.oversized", self.oversized), - ("lk.telemetry.dropped.throttled", self.throttled), - ("lk.telemetry.dropped.rate_limited", self.rate_limited), + (semconv::LK_TELEMETRY_UPLOADS_TIMEOUTS, self.upload_timeouts), + (semconv::LK_TELEMETRY_UPLOADS_UNAUTHORIZED, self.auth_denied), + (semconv::LK_TELEMETRY_HOLDS_CAPPED, self.hold_cap_hits), + (semconv::LK_TELEMETRY_CACHE_WRITE_ERRORS, self.cache_write_errors), + (semconv::LK_TELEMETRY_DROPPED_QUEUE_FULL, self.queue_full), + (semconv::LK_TELEMETRY_DROPPED_CACHE_ERROR, self.cache_error), + (semconv::LK_TELEMETRY_DROPPED_CACHE_FULL, self.cache_full), + (semconv::LK_TELEMETRY_DROPPED_EXPIRED, self.expired), + (semconv::LK_TELEMETRY_DROPPED_CORRUPT, self.corrupt), + (semconv::LK_TELEMETRY_DROPPED_INVALID, self.invalid), + (semconv::LK_TELEMETRY_DROPPED_REJECTED, self.rejected), + (semconv::LK_TELEMETRY_DROPPED_OVERSIZED, self.oversized), + (semconv::LK_TELEMETRY_DROPPED_THROTTLED, self.throttled), + (semconv::LK_TELEMETRY_DROPPED_RATE_LIMITED, self.rate_limited), ] { if value > 0 { event = event.with_attribute(key, int(value)); diff --git a/livekit-telemetry/src/store.rs b/livekit-telemetry/src/store.rs index aafc33877..bcadecd02 100644 --- a/livekit-telemetry/src/store.rs +++ b/livekit-telemetry/src/store.rs @@ -240,7 +240,7 @@ mod tests { #[test] fn queued_bytes_count_the_sessions_attributes() { let session = ScopeState::new(); - session.set_attribute("lk.room.name", Some("r".repeat(100).into())); + session.set_attribute("lk.pii.room_name", Some("r".repeat(100).into())); let store = Store::new(10, usize::MAX, Arc::default()); store.push(QueuedEvent::new(TelemetryEvent::new("lk.ping"), session)); let bare = TelemetryEvent::new("lk.ping").size_hint(); diff --git a/livekit-telemetry/src/telemetry.rs b/livekit-telemetry/src/telemetry.rs index 1300f7352..d1be2be5e 100644 --- a/livekit-telemetry/src/telemetry.rs +++ b/livekit-telemetry/src/telemetry.rs @@ -20,6 +20,7 @@ use std::{ time::Duration, }; +use livekit_protocol::semconv::{self, upstream}; use tokio::sync::{mpsc, oneshot}; use tokio::time::{timeout, Instant}; @@ -206,7 +207,7 @@ impl TelemetryConfig { /// let room = telemetry.begin_scope(); /// room.set_server("wss://my-project.livekit.cloud", ""); /// room.emit_custom("checkout", vec![Attribute::new("plan", "pro")]); -/// telemetry.emit(TelemetryEvent::new("lk.ping")); +/// telemetry.emit(TelemetryEvent::new(livekit_protocol::semconv::event::LK_PING)); /// telemetry.shutdown().await; /// # } /// ``` @@ -737,7 +738,8 @@ impl Telemetry { /// the core sets on every record or span (`session.id`, `error.type`, `lk.outcome`), or 64 /// keys are already set. pub fn set_attribute(&self, key: &str, value: Option) { - let core_owned = matches!(key, "session.id" | "error.type" | "lk.outcome"); + let core_owned = + matches!(key, upstream::SESSION_ID | upstream::ERROR_TYPE | semconv::LK_OUTCOME); if core_owned || !within_limits(key, value.as_ref()) { return self.count_invalid(); } @@ -977,13 +979,13 @@ pub struct TelemetryResource { impl TelemetryResource { pub(crate) fn attributes(&self) -> Vec { let mut out = vec![ - Attribute::new("service.name", format!("livekit-client-{}", self.sdk.as_str())), - Attribute::new("service.version", self.sdk_version.as_str()), - Attribute::new("os.name", self.os_name.as_str()), - Attribute::new("os.version", self.os_version.as_str()), + Attribute::new(upstream::SERVICE_NAME, format!("livekit-client-{}", self.sdk.as_str())), + Attribute::new(upstream::SERVICE_VERSION, self.sdk_version.as_str()), + Attribute::new(upstream::OS_NAME, self.os_name.as_str()), + Attribute::new(upstream::OS_VERSION, self.os_version.as_str()), ]; if let Some(model) = &self.device_model { - out.push(Attribute::new("device.model.identifier", model.as_str())); + out.push(Attribute::new(upstream::DEVICE_MODEL_IDENTIFIER, model.as_str())); } out } @@ -998,10 +1000,10 @@ fn add_sdk_resource(resource: &mut Vec, sdk: Option<&TelemetryResourc } } let defaults = [ - ("service.name", "livekit-client"), - ("telemetry.sdk.name", env!("CARGO_PKG_NAME")), - ("telemetry.sdk.language", "rust"), - ("telemetry.sdk.version", env!("CARGO_PKG_VERSION")), + (upstream::SERVICE_NAME, "livekit-client"), + (upstream::TELEMETRY_SDK_NAME, env!("CARGO_PKG_NAME")), + (upstream::TELEMETRY_SDK_LANGUAGE, upstream::telemetry_sdk_language::RUST), + (upstream::TELEMETRY_SDK_VERSION, env!("CARGO_PKG_VERSION")), ]; for (key, value) in defaults { if !resource.iter().any(|a| a.key == key) { @@ -1199,7 +1201,7 @@ pub(crate) mod tests { .iter() .find(|s| { s.attributes.iter().any(|kv| { - kv.key == "lk.track.sid" + kv.key == "lk.track_sid" && kv.value.as_ref().and_then(|v| v.value.clone()) == Some(Value::StringValue(sid.into())) }) @@ -1228,7 +1230,7 @@ pub(crate) mod tests { "cancelled exports too" ); assert!( - !spans.iter().any(|s| s.attributes.iter().any(|kv| kv.key == "lk.track.sid" + !spans.iter().any(|s| s.attributes.iter().any(|kv| kv.key == "lk.track_sid" && kv.value.as_ref().and_then(|v| v.value.clone()) == Some(Value::StringValue("TR_d".into())))), "still open" @@ -1259,8 +1261,8 @@ pub(crate) mod tests { let sent = transport.sent(); let logs: Vec = sent.iter().flat_map(records).collect(); let with_room = - logs.iter().find(|r| attribute(r, "lk.room.sid").is_some()).expect("room record"); - assert_eq!(attribute(with_room, "lk.room.sid"), Some(Value::StringValue("RM_a".into()))); + logs.iter().find(|r| attribute(r, "lk.room_sid").is_some()).expect("room record"); + assert_eq!(attribute(with_room, "lk.room_sid"), Some(Value::StringValue("RM_a".into()))); assert!(logs.iter().any(|r| r.body.as_ref().and_then(|b| b.value.clone()) == Some(Value::StringValue("audio interruption began".into())))); let decoded = @@ -1942,13 +1944,13 @@ pub(crate) mod tests { sent.iter().filter(|r| r.url.ends_with("logs")).flat_map(records).collect(); assert_eq!(logs.len(), 4); assert_eq!(hex(&logs[0].trace_id), a.trace_id()); - assert_eq!(attribute(&logs[0], "lk.room.sid"), Some(Value::StringValue("RM_a".into()))); + assert_eq!(attribute(&logs[0], "lk.room_sid"), Some(Value::StringValue("RM_a".into()))); assert_eq!(attribute(&logs[0], "session.id"), Some(Value::StringValue(a.trace_id()))); assert_eq!(hex(&logs[1].trace_id), a.trace_id(), "resolved through the span"); assert_eq!(hex(&logs[2].trace_id), b.trace_id()); - assert_eq!(attribute(&logs[2], "lk.room.sid"), Some(Value::StringValue("RM_b".into()))); + assert_eq!(attribute(&logs[2], "lk.room_sid"), Some(Value::StringValue("RM_b".into()))); assert_eq!(hex(&logs[3].trace_id), telemetry.trace_id(), "device state: process session"); - assert_eq!(attribute(&logs[3], "lk.room.sid"), None); + assert_eq!(attribute(&logs[3], "lk.room_sid"), None); assert!( logs.iter() .all(|r| attribute(r, "acme.tenant") == Some(Value::StringValue("t1".into()))), @@ -1959,7 +1961,7 @@ pub(crate) mod tests { ExportTraceServiceRequest::decode(&gunzip(&traces_request.body)[..]).expect("otlp"); let otlp_span = &traces.resource_spans[0].scope_spans[0].spans[0]; assert_eq!(hex(&otlp_span.trace_id), a.trace_id()); - assert!(otlp_span.attributes.iter().any(|a| a.key == "lk.room.sid")); + assert!(otlp_span.attributes.iter().any(|a| a.key == "lk.room_sid")); // A record that names a span which has already ended — and been exported — is still that // session's: the SDK's log path hops threads, the span does not wait for it. @@ -2101,21 +2103,21 @@ pub(crate) mod tests { async fn session_attributes_are_attached_to_every_record() { let transport = FakeTransport::scripted([]); let telemetry = pipeline(transport.clone()); - telemetry.set_attribute("lk.room.sid", Some("RM_1".into())); + telemetry.set_attribute("lk.room_sid", Some("RM_1".into())); telemetry.emit(TelemetryEvent::new("lk.ping")); - telemetry.emit(TelemetryEvent::new("lk.ping").with_attribute("lk.room.sid", "RM_override")); + telemetry.emit(TelemetryEvent::new("lk.ping").with_attribute("lk.room_sid", "RM_override")); telemetry.flush().await; let first = records(&transport.sent()[0]); - assert_eq!(attribute(&first[0], "lk.room.sid"), Some(Value::StringValue("RM_1".into()))); + assert_eq!(attribute(&first[0], "lk.room_sid"), Some(Value::StringValue("RM_1".into()))); assert_eq!( - attribute(&first[1], "lk.room.sid"), + attribute(&first[1], "lk.room_sid"), Some(Value::StringValue("RM_override".into())), "an explicit attribute wins" ); - telemetry.set_attribute("lk.room.sid", None); + telemetry.set_attribute("lk.room_sid", None); telemetry.emit(TelemetryEvent::new("lk.ping")); telemetry.flush().await; - assert_eq!(attribute(&records(&transport.sent()[1])[0], "lk.room.sid"), None); + assert_eq!(attribute(&records(&transport.sent()[1])[0], "lk.room_sid"), None); } #[tokio::test(start_paused = true)] diff --git a/livekit-telemetry/src/trace.rs b/livekit-telemetry/src/trace.rs index b5d27afae..16eb5d1d6 100644 --- a/livekit-telemetry/src/trace.rs +++ b/livekit-telemetry/src/trace.rs @@ -22,9 +22,12 @@ use std::{ time::Duration, }; +use livekit_protocol::semconv::{ + self, checkpoint, lk_reconnect_mode as mode, lk_reconnect_reason as reconnect_reason, + lk_track_source as track_source, span, +}; use tokio::time::Instant; -use crate::device::snake_case; use crate::span::SpanKind; use crate::{ scope::ScopeState, telemetry::Shared, Attribute, AttributeValue, SpanOutcome, Telemetry, @@ -54,6 +57,21 @@ pub enum ReconnectReason { Unknown, } +impl ReconnectReason { + fn as_str(self) -> &'static str { + match self { + Self::SignalDisconnected => reconnect_reason::SIGNAL_DISCONNECTED, + Self::PublisherFailed => reconnect_reason::PUBLISHER_FAILED, + Self::SubscriberFailed => reconnect_reason::SUBSCRIBER_FAILED, + Self::TransportFailed => reconnect_reason::TRANSPORT_FAILED, + Self::SwitchCandidate => reconnect_reason::SWITCH_CANDIDATE, + Self::NetworkChanged => reconnect_reason::NETWORK_CHANGED, + Self::Debug => reconnect_reason::DEBUG, + Self::Unknown => reconnect_reason::UNKNOWN, + } + } +} + impl From for ReconnectReason { /// The protocol's `ReconnectReason` number (`RR_*`); a number this SDK does not know is /// `Unknown`. @@ -95,10 +113,10 @@ impl SpanName { /// The span name on the wire (`lk.connect`, …). pub fn label(&self) -> &str { match self { - Self::Connect => "lk.connect", - Self::Reconnect { .. } => "lk.reconnect", - Self::Publish => "lk.publish", - Self::Subscribe => "lk.subscribe", + Self::Connect => span::LK_CONNECT, + Self::Reconnect { .. } => span::LK_RECONNECT, + Self::Publish => span::LK_PUBLISH, + Self::Subscribe => span::LK_SUBSCRIBE, Self::Custom { name } => name, } } @@ -113,10 +131,10 @@ impl SpanName { fn attributes(&self) -> Vec { match self { Self::Reconnect { reason } => { - vec![Attribute::new("lk.reconnect.reason", snake_case(reason))] + vec![Attribute::new(semconv::LK_RECONNECT_REASON, reason.as_str())] } // The user-initiated connect is attempt 1; a platform that retries sets it again. - Self::Connect => vec![Attribute::new("lk.connect.attempt", 1i64)], + Self::Connect => vec![Attribute::new(semconv::LK_CONNECT_ATTEMPT, 1i64)], _ => Vec::new(), } } @@ -166,20 +184,19 @@ pub enum SpanStep { impl SpanStep { fn label(&self) -> String { match self { - Self::WsOpen => "ws_open".into(), - Self::Signal => "signal".into(), - Self::JoinRecv => "join_recv".into(), - Self::PcCreated => "pc_created".into(), - Self::OfferSent => "offer_sent".into(), - Self::AnswerSent => "answer_sent".into(), - Self::Engine => "engine".into(), - Self::PcConnected => "pc_connected".into(), - Self::RoomConnected => "room_connected".into(), - Self::Subscribed => "subscribed".into(), - Self::FirstMedia => "first_media".into(), - Self::Attempt { number, full } => { - format!("attempt {number} {}", if *full { "full" } else { "quick" }) - } + Self::WsOpen => checkpoint::WS_OPEN.into(), + Self::Signal => checkpoint::SIGNAL.into(), + Self::JoinRecv => checkpoint::JOIN_RECV.into(), + Self::PcCreated => checkpoint::PC_CREATED.into(), + Self::OfferSent => checkpoint::OFFER_SENT.into(), + Self::AnswerSent => checkpoint::ANSWER_SENT.into(), + Self::Engine => checkpoint::ENGINE.into(), + Self::PcConnected => checkpoint::PC_CONNECTED.into(), + Self::RoomConnected => checkpoint::ROOM_CONNECTED.into(), + Self::Subscribed => checkpoint::SUBSCRIBED.into(), + Self::FirstMedia => checkpoint::FIRST_MEDIA.into(), + // The registry's `attempt ` checkpoint pattern. + Self::Attempt { number, full } => format!("attempt {number} {}", reconnect_mode(*full)), Self::Custom { name } => name.clone(), } } @@ -187,15 +204,25 @@ impl SpanStep { fn attributes(&self) -> Vec { match self { Self::Attempt { number, full } => vec![ - Attribute::new("lk.reconnect.attempts", *number as i64), - Attribute::new("lk.reconnect.mode", if *full { "full" } else { "quick" }), + Attribute::new(semconv::LK_RECONNECT_ATTEMPTS, *number as i64), + Attribute::new(semconv::LK_RECONNECT_MODE, reconnect_mode(*full)), ], _ => Vec::new(), } } } -/// The protocol's `TrackSource`. +/// The `lk.reconnect.mode` value of an attempt. +fn reconnect_mode(full: bool) -> &'static str { + if full { + mode::FULL + } else { + mode::QUICK + } +} + +/// What produces a track. Sent as the RTC FFI `TrackSource` name (`SOURCE_CAMERA`, …), the +/// spelling Agents sends. #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrackSource { @@ -211,6 +238,18 @@ pub enum TrackSource { Unknown, } +impl TrackSource { + fn as_str(self) -> &'static str { + match self { + Self::Camera => track_source::SOURCE_CAMERA, + Self::Microphone => track_source::SOURCE_MICROPHONE, + Self::ScreenShare => track_source::SOURCE_SCREENSHARE, + Self::ScreenShareAudio => track_source::SOURCE_SCREENSHARE_AUDIO, + Self::Unknown => track_source::SOURCE_UNKNOWN, + } + } +} + /// The track a publish or subscribe span is about. #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Clone, PartialEq)] @@ -230,14 +269,17 @@ pub struct SpanTrack { impl SpanTrack { fn attributes(&self) -> Vec { let mut out = vec![ - Attribute::new("lk.track.kind", snake_case(self.kind)), - Attribute::new("lk.track.source", snake_case(self.source)), + Attribute::new(semconv::LK_TRACK_KIND, self.kind.as_str()), + Attribute::new(semconv::LK_TRACK_SOURCE, self.source.as_str()), ]; if let Some(sid) = &self.sid { - out.push(Attribute::new("lk.track.sid", sid.as_str())); + out.push(Attribute::new(semconv::LK_TRACK_SID, sid.as_str())); } if let Some(identity) = &self.remote_identity { - out.push(Attribute::new("lk.participant.remote_identity", identity.as_str())); + out.push(Attribute::new( + semconv::LK_PII_REMOTE_PARTICIPANT_IDENTITY, + identity.as_str(), + )); } out } @@ -589,7 +631,7 @@ mod tests { let invalid = || telemetry.shared.counters.snapshot().invalid; let room = telemetry.begin_scope(); let app = room.start(SpanName::Custom { name: "work".into() }, None); - app.set_attribute("lk.room.sid".into(), AttributeValue::Str("RM_x".into())); + app.set_attribute("lk.room_sid".into(), AttributeValue::Str("RM_x".into())); app.set_attribute("otel.scope.name".into(), AttributeValue::Str("x".into())); app.set_attribute("app.step".into(), AttributeValue::Int(1)); assert_eq!(invalid(), 2); @@ -647,8 +689,9 @@ mod tests { let attributes = span.lock().attributes.clone(); let get = |key: &str| attributes.iter().find(|a| a.key == key).map(|a| a.value.clone()); assert_eq!(get("lk.track.kind"), Some(AttributeValue::Str("video".into()))); - assert_eq!(get("lk.track.sid"), Some(AttributeValue::Str("TR_1".into()))); - assert_eq!(attributes.iter().filter(|a| a.key == "lk.track.source").count(), 1); + assert_eq!(get("lk.track_sid"), Some(AttributeValue::Str("TR_1".into()))); + assert_eq!(get("lk.track_source"), Some(AttributeValue::Str("SOURCE_CAMERA".into()))); + assert_eq!(attributes.iter().filter(|a| a.key == "lk.track_source").count(), 1); } /// What a span retains before export is bounded, however long it lives. diff --git a/livekit-telemetry/src/wire_tests.rs b/livekit-telemetry/src/wire_tests.rs new file mode 100644 index 000000000..1338c3adc --- /dev/null +++ b/livekit-telemetry/src/wire_tests.rs @@ -0,0 +1,472 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Every name and enum value the core sends, frozen. The names come from the protocol's registry +//! (`livekit_protocol::semconv`), so a registry change that renames one fails here before it +//! reaches a dashboard. + +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; + +use prost::Message; + +use crate::{ + proto::opentelemetry::proto::{ + collector::{logs::v1::ExportLogsServiceRequest, trace::v1::ExportTraceServiceRequest}, + common::v1::{any_value::Value, KeyValue}, + trace::v1::span::SpanKind, + }, + stats::Snapshot, + telemetry::tests::{gunzip, start_with_collector, test_config, FakeTransport}, + AppState, AudioOutput, AudioRouteReason, CaptureDevice, CaptureFailure, DeviceEvent, + DeviceState, DisconnectReason, LogRecord, LogSource, MemoryPressure, NetworkType, + ReconnectReason, RoomIdentity, RtcStatsSample, Scope, Sdk, Severity, SpanName, SpanOutcome, + SpanStep, SpanTrack, StreamDirection, TelemetryConfig, TelemetryResource, ThermalState, + TrackKind, TrackSource, +}; + +/// On every record and span of a session; listed once rather than under each record. +const SESSION_KEYS: &[&str] = &[ + "session.id", + "otel.event.name", + "lk.room_sid", + "lk.pii.room_name", + "lk.participant_id", + "lk.pii.participant_identity", +]; + +/// Keys whose values the core picks from a closed set: their values are pinned too. +const ENUM_KEYS: &[&str] = &[ + "service.name", + "telemetry.sdk.language", + "lk.device.thermal.state", + "lk.device.app_state", + "lk.device.memory.pressure", + "network.connection.type", + "lk.device.audio_route.reason", + "lk.device.audio_route.outputs", + "lk.device.audio.interruption", + "lk.device.capture.device", + "lk.device.capture.reason", + "lk.track.kind", + "lk.track.direction", + "lk.track_source", + "lk.reconnect.reason", + "lk.reconnect.mode", + "lk.disconnect_reason", + "lk.outcome", + "error.type", + "lk.log.source", +]; + +/// What the core sends: one line per record, span and checkpoint list, plus the values of every +/// [`ENUM_KEYS`] key. A line changes only when a name or value on the wire does. +const PINNED: &str = "\ +checkpoints lk.connect: answer_sent | engine | join_recv | offer_sent | pc_connected | pc_created \ + | room_connected | signal | ws_open +checkpoints lk.reconnect: attempt 1 quick | attempt 2 full | attempt 3 quick | attempt 4 full \ + | attempt 5 quick | attempt 6 full | attempt 7 quick | attempt 8 full +checkpoints lk.subscribe: first_media | subscribed +record -: code.file.path | code.function.name | code.line.number | lk.log.logger | lk.log.source +record lk.device.app_state.changed: lk.device.app_state +record lk.device.audio.interruption: lk.device.audio.interruption +record lk.device.audio_route.changed: lk.device.audio_route.outputs | lk.device.audio_route.reason +record lk.device.battery.changed: lk.device.battery.charging | lk.device.battery.level +record lk.device.capture.failed: lk.device.capture.device | lk.device.capture.reason +record lk.device.low_power.changed: lk.device.low_power.enabled +record lk.device.memory.changed: lk.device.memory.pressure +record lk.device.network.changed: lk.device.network.constrained | lk.device.network.expensive \ + | network.connection.type +record lk.device.thermal.changed: lk.device.thermal.state +record lk.room.disconnected: lk.disconnect_reason +record lk.rtc.stats.sample: lk.rtc.audio_level.avg | lk.rtc.audio_level.max \ + | lk.rtc.audio_level.min | lk.rtc.bytes | lk.rtc.codec | lk.rtc.concealed_samples \ + | lk.rtc.concealment_events | lk.rtc.fps.avg | lk.rtc.fps.max | lk.rtc.fps.min \ + | lk.rtc.freeze_count | lk.rtc.freezes_duration_ms | lk.rtc.interruption_count \ + | lk.rtc.interruptions_duration_ms | lk.rtc.jitter_buffer_delay_ms \ + | lk.rtc.jitter_buffer_emitted_count | lk.rtc.jitter_ms.avg | lk.rtc.jitter_ms.max \ + | lk.rtc.jitter_ms.min | lk.rtc.packets | lk.rtc.packets_lost | lk.rtc.pause_count \ + | lk.rtc.pauses_duration_ms | lk.rtc.quality_limitation.bandwidth_ms \ + | lk.rtc.quality_limitation.cpu_ms | lk.rtc.quality_limitation.other_ms | lk.rtc.rtt_ms.avg \ + | lk.rtc.rtt_ms.max | lk.rtc.rtt_ms.min | lk.rtc.samples | lk.rtc.silent_concealed_samples \ + | lk.rtc.window_ms | lk.track.direction | lk.track.kind | lk.track_sid +record lk.telemetry.report: lk.telemetry.cache.batches | lk.telemetry.cache.write_errors \ + | lk.telemetry.dropped.cache_error | lk.telemetry.dropped.cache_full \ + | lk.telemetry.dropped.corrupt | lk.telemetry.dropped.expired | lk.telemetry.dropped.invalid \ + | lk.telemetry.dropped.oversized | lk.telemetry.dropped.queue_full \ + | lk.telemetry.dropped.rate_limited | lk.telemetry.dropped.rejected \ + | lk.telemetry.dropped.throttled | lk.telemetry.holds.capped | lk.telemetry.uploads.bytes \ + | lk.telemetry.uploads.failed | lk.telemetry.uploads.sent | lk.telemetry.uploads.timeouts \ + | lk.telemetry.uploads.unauthorized +resource: device.model.identifier | os.name | os.version | service.name | service.version \ + | telemetry.sdk.language | telemetry.sdk.name | telemetry.sdk.version +session: lk.participant_id | lk.pii.participant_identity | lk.pii.room_name | lk.room_sid \ + | otel.event.name | session.id +severity -: ERROR +severity custom.checkout: INFO +severity lk.device.app_state.changed: INFO +severity lk.device.audio.interruption: INFO +severity lk.device.audio_route.changed: INFO +severity lk.device.battery.changed: INFO +severity lk.device.capture.failed: WARN +severity lk.device.low_power.changed: INFO +severity lk.device.memory.changed: INFO +severity lk.device.network.changed: INFO +severity lk.device.thermal.changed: INFO +severity lk.room.disconnected: INFO | WARN +severity lk.rtc.stats.sample: INFO +severity lk.telemetry.report: INFO +span lk.connect SPAN_KIND_CLIENT: lk.connect.attempt | lk.outcome +span lk.publish SPAN_KIND_INTERNAL: lk.outcome | lk.track.kind | lk.track_sid | lk.track_source +span lk.reconnect SPAN_KIND_CLIENT: lk.outcome | lk.reconnect.attempts | lk.reconnect.mode \ + | lk.reconnect.reason +span lk.subscribe SPAN_KIND_INTERNAL: error.type | lk.outcome | lk.pii.remote_participant_identity \ + | lk.track.kind | lk.track_sid | lk.track_source +value error.type: timed_out +value lk.device.app_state: background | foreground +value lk.device.audio.interruption: began | ended +value lk.device.audio_route.outputs: air_play | bluetooth | car_audio | hdmi | other | receiver \ + | speaker | usb | wired_headset +value lk.device.audio_route.reason: category_change | new_device | no_suitable_route \ + | old_device_unavailable | override | route_configuration_change | unknown | wake_from_sleep +value lk.device.capture.device: camera | microphone | screen_share +value lk.device.capture.reason: disconnected | in_use | not_found | other | permission_denied +value lk.device.memory.pressure: critical | normal | warning +value lk.device.thermal.state: critical | fair | nominal | serious +value lk.disconnect_reason: AGENT_ERROR | CLIENT_INITIATED | CONNECTION_TIMEOUT \ + | DUPLICATE_IDENTITY | JOIN_FAILURE | MEDIA_FAILURE | MIGRATION | PARTICIPANT_REMOVED \ + | RECONNECT_FAILED | ROOM_CLOSED | ROOM_DELETED | SERVER_SHUTDOWN | SIGNAL_CLOSE \ + | SIP_TRUNK_FAILURE | STATE_MISMATCH | UNKNOWN_REASON | USER_REJECTED | USER_UNAVAILABLE +value lk.log.source: ffi | sdk | webrtc +value lk.outcome: cancelled | error | ok +value lk.reconnect.mode: full | quick +value lk.reconnect.reason: debug | network_changed | publisher_failed | signal_disconnected \ + | subscriber_failed | switch_candidate | transport_failed | unknown +value lk.track.direction: inbound | outbound +value lk.track.kind: audio | video +value lk.track_source: SOURCE_CAMERA | SOURCE_MICROPHONE | SOURCE_SCREENSHARE \ + | SOURCE_SCREENSHARE_AUDIO | SOURCE_UNKNOWN +value network.connection.type: bluetooth | cell | other | unavailable | unknown | vpn | wifi \ + | wired +value service.name: livekit-client-swift +value telemetry.sdk.language: rust +"; + +#[tokio::test(start_paused = true)] +async fn every_name_and_value_on_the_wire_is_pinned() { + let transport = FakeTransport::scripted([]); + let config = TelemetryConfig { + sdk: Some(TelemetryResource { + sdk: Sdk::Swift, + sdk_version: "2.17.0".into(), + os_name: "iOS".into(), + os_version: "18.5".into(), + device_model: Some("iPhone17,1".into()), + }), + // Every variant below is a discrete event: none may meet the flood guard. + max_events_per_10min: 10_000, + ..test_config() + }; + let telemetry = start_with_collector(config, transport.clone()); + + device_states(&telemetry); + for reason in [ + AudioRouteReason::NewDevice, + AudioRouteReason::OldDeviceUnavailable, + AudioRouteReason::CategoryChange, + AudioRouteReason::Override, + AudioRouteReason::WakeFromSleep, + AudioRouteReason::NoSuitableRoute, + AudioRouteReason::RouteConfigurationChange, + AudioRouteReason::Unknown, + ] { + use AudioOutput::*; + let outputs = + vec![Speaker, Receiver, WiredHeadset, Bluetooth, CarAudio, AirPlay, Hdmi, Usb, Other]; + telemetry.device_event(DeviceEvent::AudioRouteChanged { outputs, reason }); + } + for began in [true, false] { + telemetry.device_event(DeviceEvent::AudioInterruption { began }); + } + for (device, reason) in [ + (CaptureDevice::Camera, CaptureFailure::PermissionDenied), + (CaptureDevice::Microphone, CaptureFailure::NotFound), + (CaptureDevice::ScreenShare, CaptureFailure::InUse), + (CaptureDevice::Camera, CaptureFailure::Disconnected), + (CaptureDevice::Camera, CaptureFailure::Other), + ] { + telemetry.device_event(DeviceEvent::CaptureFailed { device, reason }); + } + for source in [LogSource::Sdk, LogSource::Ffi, LogSource::WebRtc] { + telemetry.log(LogRecord { + severity: Severity::Error, + source, + body: "failed".into(), + logger: Some("Room".into()), + function: Some("connect()".into()), + file: Some("Room.swift".into()), + line: Some(42), + timestamp_ns: None, + span_id: None, + }); + } + let every_counter = Snapshot { + queue_full: 1, + cache_error: 1, + cache_full: 1, + expired: 1, + corrupt: 1, + invalid: 1, + rejected: 1, + oversized: 1, + throttled: 1, + disabled: 1, + rate_limited: 1, + purged: 1, + uploads_sent: 1, + upload_bytes: 1, + upload_failures: 1, + upload_timeouts: 1, + auth_denied: 1, + hold_cap_hits: 1, + cache_write_errors: 1, + }; + telemetry.emit(every_counter.report(1)); + + let room = telemetry.begin_scope(); + room.set_room(RoomIdentity { + sid: Some("RM_1".into()), + name: Some("standup".into()), + participant_sid: Some("PA_1".into()), + participant_identity: Some("alice".into()), + }); + room.emit_custom("checkout", Vec::new()); + spans(&room).await; + stats(&room); + telemetry.flush().await; + for reason in (0..=16).map(DisconnectReason::from).chain([DisconnectReason::ReconnectFailed]) { + telemetry.begin_scope().disconnected(reason); + } + telemetry.shutdown().await; + + assert_eq!(wire(&transport), PINNED); +} + +/// Every value of every device state field, ending at rest so nothing holds the uploads. +fn device_states(telemetry: &crate::Telemetry) { + let mut state = DeviceState { + low_power_mode: Some(true), + battery_level: Some(50), + network_expensive: true, + ..Default::default() + }; + telemetry.set_device_state(state); + for thermal in [ThermalState::Fair, ThermalState::Serious, ThermalState::Critical] { + telemetry.set_device_state(DeviceState { thermal, ..state }); + } + for memory in [MemoryPressure::Warning, MemoryPressure::Critical] { + telemetry.set_device_state(DeviceState { memory, ..state }); + } + for network in [ + NetworkType::Unknown, + NetworkType::Wifi, + NetworkType::Cell, + NetworkType::Wired, + NetworkType::Vpn, + NetworkType::Bluetooth, + NetworkType::Other, + NetworkType::Unavailable, + ] { + telemetry.set_device_state(DeviceState { network, ..state }); + } + state = DeviceState { + thermal: ThermalState::Nominal, + low_power_mode: Some(false), + app_state: AppState::Background, + memory: MemoryPressure::Normal, + network: NetworkType::Wifi, + network_expensive: false, + network_constrained: false, + battery_level: Some(50), + battery_charging: true, + }; + telemetry.set_device_state(state); + telemetry.set_device_state(DeviceState { app_state: AppState::Foreground, ..state }); +} + +/// Every typed span, checkpoint, reconnect reason, track source and outcome. +async fn spans(room: &Scope) { + let connect = room.start(SpanName::Connect, None); + for step in [ + SpanStep::WsOpen, + SpanStep::Signal, + SpanStep::JoinRecv, + SpanStep::PcCreated, + SpanStep::OfferSent, + SpanStep::AnswerSent, + SpanStep::Engine, + SpanStep::PcConnected, + SpanStep::RoomConnected, + ] { + connect.step(step); + } + connect.end(SpanOutcome::Ok, None); + for (n, reason) in [ + ReconnectReason::SignalDisconnected, + ReconnectReason::PublisherFailed, + ReconnectReason::SubscriberFailed, + ReconnectReason::TransportFailed, + ReconnectReason::SwitchCandidate, + ReconnectReason::NetworkChanged, + ReconnectReason::Debug, + ReconnectReason::Unknown, + ] + .into_iter() + .enumerate() + { + let reconnect = room.start(SpanName::Reconnect { reason }, None); + reconnect.step(SpanStep::Attempt { number: n as u32 + 1, full: n % 2 == 1 }); + reconnect.end(SpanOutcome::Ok, None); + } + let track = |sid: &str, kind, source| SpanTrack { + sid: Some(sid.into()), + kind, + source, + remote_identity: Some("bob".into()), + }; + for (sid, source) in [ + ("TR_cam", TrackSource::Camera), + ("TR_mic", TrackSource::Microphone), + ("TR_screen", TrackSource::ScreenShare), + ("TR_screen_audio", TrackSource::ScreenShareAudio), + ("TR_other", TrackSource::Unknown), + ] { + let publish = room.start(SpanName::Publish, None); + publish + .set_track(SpanTrack { remote_identity: None, ..track(sid, TrackKind::Audio, source) }); + publish.end(SpanOutcome::Ok, None); + } + // Ok at first media, cancelled when the track ends first, timed out without media. + room.subscribe_started(track("TR_ok", TrackKind::Video, TrackSource::Camera)); + room.subscribed(track("TR_ok", TrackKind::Video, TrackSource::Camera)); + let mut media = RtcStatsSample::new("TR_ok", TrackKind::Video, StreamDirection::Inbound); + media.bytes = Some(1_500); + room.record_stats(media); + room.subscribe_started(track("TR_gone", TrackKind::Audio, TrackSource::Microphone)); + room.track_ended("TR_gone"); + room.subscribe_started(track("TR_silent", TrackKind::Audio, TrackSource::Microphone)); + tokio::time::sleep(Scope::SUBSCRIBE_TIMEOUT + Duration::from_secs(1)).await; +} + +/// One window per kind and direction, every counter and gauge set. +fn stats(room: &Scope) { + for (sid, kind, direction) in [ + ("TR_av_in", TrackKind::Audio, StreamDirection::Inbound), + ("TR_av_out", TrackKind::Audio, StreamDirection::Outbound), + ("TR_vv_in", TrackKind::Video, StreamDirection::Inbound), + ("TR_vv_out", TrackKind::Video, StreamDirection::Outbound), + ] { + room.record_stats(RtcStatsSample { + codec: Some("opus".into()), + bytes: Some(1), + packets: Some(1), + packets_lost: Some(1), + freeze_count: Some(1), + freezes_duration_ms: Some(1), + concealed_samples: Some(1), + concealment_events: Some(1), + jitter_buffer_delay_ms: Some(1), + jitter_buffer_emitted_count: Some(1), + quality_limitation_bandwidth_ms: Some(1), + quality_limitation_cpu_ms: Some(1), + quality_limitation_other_ms: Some(1), + pause_count: Some(1), + pauses_duration_ms: Some(1), + silent_concealed_samples: Some(1), + interruption_count: Some(1), + interruptions_duration_ms: Some(1), + jitter_ms: Some(1.0), + rtt_ms: Some(1.0), + frames_per_second: Some(1.0), + audio_level: Some(1.0), + ..RtcStatsSample::new(sid, kind, direction) + }); + } +} + +type Lines = BTreeMap>; + +/// What went out, one line per signal: its keys, severities, checkpoints, and the values of +/// [`ENUM_KEYS`]. +fn wire(transport: &FakeTransport) -> String { + let mut lines = Lines::new(); + for request in transport.sent() { + let body = gunzip(&request.body); + if request.url.contains("traces") { + let decoded = ExportTraceServiceRequest::decode(&body[..]).expect("otlp traces"); + for resource_spans in decoded.resource_spans { + let resource = resource_spans.resource.unwrap_or_default(); + add_attributes(&mut lines, "resource", &resource.attributes); + for span in resource_spans.scope_spans.into_iter().flat_map(|s| s.spans) { + let kind = SpanKind::try_from(span.kind).expect("kind").as_str_name(); + let line = format!("span {} {kind}", span.name); + add_attributes(&mut lines, &line, &span.attributes); + for event in &span.events { + add(&mut lines, format!("checkpoints {}", span.name), &event.name); + } + } + } + } else { + let decoded = ExportLogsServiceRequest::decode(&body[..]).expect("otlp logs"); + for resource_logs in decoded.resource_logs { + let resource = resource_logs.resource.unwrap_or_default(); + add_attributes(&mut lines, "resource", &resource.attributes); + for record in resource_logs.scope_logs.into_iter().flat_map(|s| s.log_records) { + let name = Some(record.event_name.as_str()).filter(|n| !n.is_empty()); + let name = name.unwrap_or("-"); + add_attributes(&mut lines, &format!("record {name}"), &record.attributes); + add(&mut lines, format!("severity {name}"), &record.severity_text); + } + } + } + } + lines + .into_iter() + .map(|(line, items)| format!("{line}: {}\n", Vec::from_iter(items).join(" | "))) + .collect() +} + +fn add(lines: &mut Lines, line: String, item: &str) { + lines.entry(line).or_default().insert(item.to_owned()); +} + +/// The keys under `line` (a session's under `session`), and the values of [`ENUM_KEYS`]. +fn add_attributes(lines: &mut Lines, line: &str, attributes: &[KeyValue]) { + for kv in attributes { + let session = SESSION_KEYS.contains(&kv.key.as_str()); + add(lines, if session { "session".into() } else { line.into() }, &kv.key); + let Some(Value::StringValue(value)) = kv.value.as_ref().and_then(|v| v.value.as_ref()) + else { + continue; + }; + if ENUM_KEYS.contains(&kv.key.as_str()) { + // The audio route's outputs are one comma-separated value. + for value in value.split(',') { + add(lines, format!("value {}", kv.key), value); + } + } + } +}