//! Ambient session context for telemetry — product events + Mixpanel via //! [`log_event`]. `session_id` and `turn_number` are injected from the //! task-local [`TelemetryCtx`] active for the duration of a session. //! //! Extracted from `xai-grok-shell::agent::telemetry`. use std::sync::Arc; use serde::Serialize; use serde_json::json; use crate::client::{self, Metadata, UserContext}; use crate::events::TelemetryEvent; /// Ambient session context for telemetry. Snapshotted synchronously by /// `log_event` at call time to avoid racing with turn increments. #[derive(Clone)] pub struct TelemetryCtx { pub session_id: String, pub prompt_index: Arc>, /// Per-prompt correlation UUID for the external OTEL stream (`prompt.id`, /// events only — never metrics). Set at turn start where `prompt_index` /// increments; `None` outside a prompt. pub prompt_id: Arc>>, } impl TelemetryCtx { pub fn new(session_id: String, prompt_index: Arc>) -> Self { Self { session_id, prompt_index, prompt_id: Arc::new(parking_lot::Mutex::new(None)), } } } /// Snapshot of the ambient ctx for the external OTEL stream. pub(crate) struct ExternalCtxSnapshot { pub session_id: String, pub turn_number: Option, pub prompt_id: Option, } /// Rotate the per-prompt correlation UUID at turn start (where /// `prompt_index` increments). No-op outside a session ctx scope. The id is /// attached as `prompt.id` to external OTEL events only. pub fn begin_prompt_id() { let _ = TELEMETRY_CTX.try_with(|c| { *c.prompt_id.lock() = Some(uuid::Uuid::new_v4().to_string()); }); } /// Snapshot the task-local ctx (if any) for external emission. Non-blocking: /// a contended `prompt_index` lock yields `turn_number = None` rather than /// stalling the emitting task. pub(crate) fn external_ctx_snapshot() -> Option { TELEMETRY_CTX .try_with(|c| ExternalCtxSnapshot { session_id: c.session_id.clone(), turn_number: c.prompt_index.try_lock().map(|g| *g as u32).ok(), prompt_id: c.prompt_id.lock().clone(), }) .ok() } tokio::task_local! { static TELEMETRY_CTX: Arc; } /// The `session_id` field name the debug-log firehose router keys on: /// `debug_log::SessionIdVisitor` stashes a `SessionId` extension on any span /// carrying this field — the span *name* is not load-bearing for routing. Shared /// so the `info_span!` here and the router in `debug_log` can't silently drift; a /// rename trips `session_span_exposes_router_field` below. pub(crate) const SESSION_ID_FIELD: &str = "session_id"; /// Build the per-session tracing span the firehose router routes by. The field /// name MUST be the literal `session_id` (tracing field names can't come from a /// const); the test below pins it against [`SESSION_ID_FIELD`]. fn session_span(session_id: &str) -> tracing::Span { tracing::info_span!("session", session_id = %session_id) } /// Run `fut` with telemetry context active. Also sets a `tracing` span. pub async fn with_session_ctx(ctx: TelemetryCtx, fut: F) -> F::Output { use tracing::Instrument; let span = session_span(&ctx.session_id); TELEMETRY_CTX .scope(Arc::new(ctx), fut.instrument(span)) .await } /// Product surface that emitted a telemetry event. Selects the analytics /// event-name prefix so shell and workspace events are distinguishable on the /// wire while sharing this emitter (and the `event_value` derivation in /// [`crate::client`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumCount)] pub enum EmitterOrigin { /// `xai-grok-shell` (and the pager/TUI that emit through it). Shell, /// `xai-grok-workspace` (remote sampler / workspace server). Workspace, } impl EmitterOrigin { /// Every emitter origin. [`crate::client::event_value`] iterates this to /// strip whichever prefix an event name carries. Iteration *order* is /// irrelevant: the prefixes are mutually exclusive (no /// [`EmitterOrigin::event_prefix`] is a prefix of another — pinned by /// `client`'s `emitter_prefixes_are_mutually_exclusive` test), so at most /// one entry ever matches a given name. Completeness is compiler-enforced /// by the `EmitterOrigin::ALL` length assertion below, so a newly added /// variant that is omitted here fails to compile. pub const ALL: [EmitterOrigin; 2] = [EmitterOrigin::Shell, EmitterOrigin::Workspace]; /// Analytics event-name prefix for this origin. [`crate::client::event_value`] /// strips the same prefix to derive the wire `event_value`, so the two must /// stay in lockstep. pub fn event_prefix(self) -> &'static str { match self { EmitterOrigin::Shell => "grok-shell-", EmitterOrigin::Workspace => "grok-workspace-", } } } /// Compile-time completeness guard for [`EmitterOrigin::ALL`]: adding a variant /// without listing it in `ALL` makes `ALL.len()` diverge from the /// `strum::EnumCount`-derived variant count and fails this assertion, so /// `client::event_value` can never silently stop stripping an origin's prefix. const _: () = assert!(EmitterOrigin::ALL.len() == ::COUNT); /// Product analytics event (type-safe). Only fires in `Enabled` mode. /// Unconditionally fans out to the external OTEL stream first ("one call /// site, two sinks, independent gates"): the external gate is /// `external::is_active()`, independent of `TelemetryMode`. pub fn log_event(data: T) { crate::external::emit(&data); if !client::is_enabled() { return; } emit_event(T::NAME, data); } /// Emit one event to the external stream always (no-op unless the stream is /// active) and to the product events/Mixpanel funnel only when `internal_enabled`. /// /// Used by call sites whose internal sink is gated by a *stricter* predicate /// than [`log_event`]'s own `TelemetryMode::Enabled` check (the shell's /// `telemetry_enabled` = `Enabled && !ZDR`, or `!is_data_collection_disabled()`). /// Because [`log_event`] already fans out to the external sink before its /// internal gate, the two branches are **mutually exclusive**: routing through /// `log_event` when internal is enabled reaches both sinks, and calling /// [`crate::external::emit`] directly otherwise keeps `session.count` / /// `turn.count` exactly-once on every path while never sending an internal /// record under ZDR. pub fn log_event_dual(internal_enabled: bool, data: T) { if internal_enabled { log_event(data); } else { crate::external::emit(&data); } } /// Session lifecycle event (type-safe). Fires in both `Enabled` and /// `SessionMetrics` modes. Emits with the [`EmitterOrigin::Shell`] prefix; /// workspace-side callers use [`log_session_event_with_origin`]. /// Unconditionally fans out to the external OTEL stream first (independent /// gate; see [`log_event`]). pub fn log_session_event(data: T) { crate::external::emit(&data); if !client::is_session_metrics_enabled() { return; } emit_event_with_origin(EmitterOrigin::Shell, T::NAME, data); } /// Session lifecycle event tagged with the emitting [`EmitterOrigin`]. Fires in /// both `Enabled` and `SessionMetrics` modes; the origin selects the analytics /// event-name prefix (`grok-shell-*` vs `grok-workspace-*`). /// /// Deliberately **no external fan-out** here: workspace-side callers /// (`EmitterOrigin::Workspace` — remote sampler / workspace server, a /// different process and monitoring audience) invoke this directly, and the /// external stream is Shell-origin only. An `external = …` macro arm on a /// workspace-only event therefore has no effect (pinned by test in /// `external::tests`). If the external stream ever needs workspace events, /// the hook moves here behind an explicit `origin == Shell` filter. pub fn log_session_event_with_origin(origin: EmitterOrigin, data: T) { if !client::is_session_metrics_enabled() { return; } emit_event_with_origin(origin, T::NAME, data); } /// Emit an event with the default [`EmitterOrigin::Shell`] prefix. pub fn emit_event(event_suffix: impl Into, data: T) { emit_event_with_origin(EmitterOrigin::Shell, event_suffix, data); } /// Emit an event whose analytics name is `{origin prefix}{event_suffix}`. pub fn emit_event_with_origin( origin: EmitterOrigin, event_suffix: impl Into, data: T, ) { let event_name = format!("{}{}", origin.event_prefix(), event_suffix.into()); let ctx_snapshot = TELEMETRY_CTX .try_with(|c| { ( c.session_id.clone(), c.prompt_index.try_lock().map(|g| *g as u32).ok(), ) }) .ok(); tokio::spawn(async move { let user_ctx = UserContext::collect(); let request_id = format!("{}-{}", event_name, uuid::Uuid::new_v4()); let mut metadata = match serde_json::to_value(data) { Ok(serde_json::Value::Object(map)) => map, Ok(other) => { let mut m = Metadata::new(); m.insert("value".into(), other); m } Err(_) => Metadata::new(), }; if let Some((session_id, turn_number)) = ctx_snapshot { metadata.insert("session_id".into(), json!(session_id)); if let Some(turn) = turn_number { metadata.insert("turn_number".into(), json!(turn)); } } client::track(&event_name, &request_id, &user_ctx, metadata).await; }); } #[cfg(test)] mod tests { use super::*; /// The debug-log firehose router (`debug_log`) finds the session span by its /// `session_id` field (not by name). That field name is a literal in /// `session_span` (tracing field names can't be a const), so pin it against the /// shared const here — a rename of either breaks this test instead of silently /// degrading routing to the per-pid fallback. #[test] fn session_span_exposes_router_field() { // A bare registry enables every callsite, so the span has live metadata. let subscriber = tracing_subscriber::registry(); tracing::subscriber::with_default(subscriber, || { let span = session_span("test-id"); let meta = span .metadata() .expect("session span must have metadata under an enabling subscriber"); assert!( meta.fields().field(SESSION_ID_FIELD).is_some(), "session span must expose `{SESSION_ID_FIELD}` for debug-log routing", ); }); } /// Event-name prefixes are wire contract — analytics queries match on them, so /// they must not drift. #[test] fn event_prefix_is_stable_per_origin() { assert_eq!(EmitterOrigin::Shell.event_prefix(), "grok-shell-"); assert_eq!(EmitterOrigin::Workspace.event_prefix(), "grok-workspace-"); } /// The `Shell` reroute must reproduce the historical /// `format!("grok-shell-{suffix}")` event name byte-for-byte, since every /// existing `log_session_event` / `log_event` / `emit_event` call funnels /// through `EmitterOrigin::Shell`. #[test] fn shell_origin_event_name_matches_legacy_format() { let suffix = "trace_upload_attempted"; let rerouted = format!("{}{}", EmitterOrigin::Shell.event_prefix(), suffix); let legacy = format!("grok-shell-{suffix}"); assert_eq!(rerouted, legacy); } #[test] fn workspace_origin_event_name_uses_workspace_prefix() { let name = format!("{}turn", EmitterOrigin::Workspace.event_prefix()); assert_eq!(name, "grok-workspace-turn"); } /// `ALL` must enumerate every variant so the stripper in `client` can /// recover the `event_value` for any origin the emitter produces. Length /// completeness is also compiler-enforced by the `const _` assertion in /// this module (via `strum::EnumCount`); this test additionally pins that /// the known variants are present and that every origin yields a distinct, /// non-empty prefix (which `EnumCount` alone does not guarantee). #[test] fn all_covers_every_origin_with_distinct_nonempty_prefixes() { assert!(EmitterOrigin::ALL.contains(&EmitterOrigin::Shell)); assert!(EmitterOrigin::ALL.contains(&EmitterOrigin::Workspace)); assert_eq!( EmitterOrigin::ALL.len(), ::COUNT, "ALL must list every EmitterOrigin variant", ); let mut prefixes: Vec<&str> = EmitterOrigin::ALL .iter() .map(|o| o.event_prefix()) .collect(); assert!( prefixes.iter().all(|p| !p.is_empty()), "every origin must have a non-empty prefix", ); let total = prefixes.len(); prefixes.sort_unstable(); prefixes.dedup(); assert_eq!( prefixes.len(), total, "every origin must yield a distinct prefix", ); } }