Skip to main content

everruns_core/
telemetry.rs

1// OpenTelemetry Telemetry Module
2//
3// This module provides OpenTelemetry integration for Everruns, including:
4// - Gen-AI semantic conventions for LLM operations
5// - Initialization helpers for OTLP exporters
6// - Span creation helpers with proper attribute naming
7
8// OTLP exporter wiring lives behind the `telemetry` feature so provider crates
9// that only need the gen-AI span conventions (below) do not pull the
10// OpenTelemetry SDK + exporter dependency tree. See crates/core/Cargo.toml.
11#[cfg(feature = "telemetry")]
12use opentelemetry::KeyValue;
13#[cfg(feature = "telemetry")]
14use opentelemetry::trace::TracerProvider as _;
15#[cfg(feature = "telemetry")]
16use opentelemetry_otlp::{SpanExporter, WithExportConfig};
17#[cfg(feature = "telemetry")]
18use opentelemetry_sdk::{
19    Resource,
20    trace::{RandomIdGenerator, Sampler, SdkTracerProvider},
21};
22#[cfg(feature = "telemetry")]
23use std::time::Duration;
24#[cfg(feature = "telemetry")]
25use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
26
27// ============================================================================
28// Gen-AI Semantic Conventions
29// See: https://opentelemetry.io/docs/specs/semconv/gen-ai/
30// ============================================================================
31
32/// Gen-AI semantic convention attribute names
33pub mod gen_ai {
34    // Operation and provider attributes
35    /// The name of the operation being performed (e.g., "chat", "embeddings")
36    pub const OPERATION_NAME: &str = "gen_ai.operation.name";
37    /// The name of the GenAI provider (e.g., "openai", "anthropic")
38    pub const PROVIDER_NAME: &str = "gen_ai.provider.name";
39
40    // Request attributes
41    /// The name of the model requested
42    pub const REQUEST_MODEL: &str = "gen_ai.request.model";
43    /// Maximum number of tokens in the response
44    pub const REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
45    /// Sampling temperature
46    pub const REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
47    /// Top-P sampling parameter
48    pub const REQUEST_TOP_P: &str = "gen_ai.request.top_p";
49    /// Top-K sampling parameter
50    pub const REQUEST_TOP_K: &str = "gen_ai.request.top_k";
51    /// Frequency penalty
52    pub const REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
53    /// Presence penalty
54    pub const REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
55    /// Stop sequences
56    pub const REQUEST_STOP_SEQUENCES: &str = "gen_ai.request.stop_sequences";
57    /// Random seed for reproducibility
58    pub const REQUEST_SEED: &str = "gen_ai.request.seed";
59
60    // Response attributes
61    /// Unique identifier for the completion
62    pub const RESPONSE_ID: &str = "gen_ai.response.id";
63    /// The actual model used (may differ from requested)
64    pub const RESPONSE_MODEL: &str = "gen_ai.response.model";
65    /// Reasons why generation stopped
66    pub const RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
67
68    // Token usage attributes
69    /// Number of tokens in the input/prompt
70    pub const USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
71    /// Number of tokens in the output/completion
72    pub const USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
73    /// Number of tokens read from cache (reduces cost)
74    pub const USAGE_CACHE_READ_TOKENS: &str = "gen_ai.usage.cache_read_tokens";
75    /// Number of tokens written to cache (Anthropic-specific)
76    pub const USAGE_CACHE_CREATION_TOKENS: &str = "gen_ai.usage.cache_creation_tokens";
77
78    // Content attributes (opt-in, may contain sensitive data)
79    /// Input messages/prompts
80    pub const INPUT_MESSAGES: &str = "gen_ai.input.messages";
81    /// Output messages/completions
82    pub const OUTPUT_MESSAGES: &str = "gen_ai.output.messages";
83    /// System instructions/prompts
84    pub const SYSTEM_INSTRUCTIONS: &str = "gen_ai.system_instructions";
85    /// Tool definitions available
86    pub const TOOL_DEFINITIONS: &str = "gen_ai.tool.definitions";
87
88    // Tool execution attributes
89    /// Name of the tool being executed
90    pub const TOOL_NAME: &str = "gen_ai.tool.name";
91    /// Type of tool (function, extension, datastore)
92    pub const TOOL_TYPE: &str = "gen_ai.tool.type";
93    /// Tool description
94    pub const TOOL_DESCRIPTION: &str = "gen_ai.tool.description";
95    /// Tool call identifier
96    pub const TOOL_CALL_ID: &str = "gen_ai.tool.call.id";
97    /// Tool call arguments (opt-in, may contain sensitive data)
98    pub const TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments";
99    /// Tool call result (opt-in, may contain sensitive data)
100    pub const TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result";
101
102    // Conversation tracking
103    /// Conversation or session identifier
104    pub const CONVERSATION_ID: &str = "gen_ai.conversation.id";
105
106    // Embeddings attributes
107    /// Number of dimensions in output embeddings
108    pub const EMBEDDINGS_DIMENSION_COUNT: &str = "gen_ai.embeddings.dimension.count";
109    /// Requested encoding formats
110    pub const REQUEST_ENCODING_FORMATS: &str = "gen_ai.request.encoding_formats";
111
112    // Additional request attributes
113    /// Number of response candidates to generate
114    pub const REQUEST_CHOICE_COUNT: &str = "gen_ai.request.choice.count";
115
116    // Output attributes
117    /// Output modality type (text, image, json, speech)
118    pub const OUTPUT_TYPE: &str = "gen_ai.output.type";
119
120    // Agent attributes (extension for agent frameworks)
121    /// Agent identifier
122    pub const AGENT_ID: &str = "gen_ai.agent.id";
123    /// Agent name
124    pub const AGENT_NAME: &str = "gen_ai.agent.name";
125    /// Agent description
126    pub const AGENT_DESCRIPTION: &str = "gen_ai.agent.description";
127
128    // Server attributes
129    /// GenAI server address
130    pub const SERVER_ADDRESS: &str = "server.address";
131    /// GenAI server port
132    pub const SERVER_PORT: &str = "server.port";
133
134    // System attribute (gen_ai.system — provider identifier for older convention usage)
135    /// Provider system identifier (e.g., "openai", "anthropic")
136    pub const SYSTEM: &str = "gen_ai.system";
137
138    /// Operation names as per semantic conventions
139    pub mod operation {
140        pub const CHAT: &str = "chat";
141        pub const EMBEDDINGS: &str = "embeddings";
142        pub const TEXT_COMPLETION: &str = "text_completion";
143        pub const GENERATE_CONTENT: &str = "generate_content";
144        pub const EXECUTE_TOOL: &str = "execute_tool";
145        pub const CREATE_AGENT: &str = "create_agent";
146        pub const INVOKE_AGENT: &str = "invoke_agent";
147        // Phase operations (agentic loop specific)
148        pub const REASON: &str = "reason";
149        pub const ACT: &str = "act";
150        pub const THINKING: &str = "thinking";
151    }
152
153    /// Provider names as per semantic conventions
154    pub mod provider {
155        pub const OPENAI: &str = "openai";
156        pub const ANTHROPIC: &str = "anthropic";
157    }
158
159    /// Tool types as per semantic conventions
160    pub mod tool_type {
161        pub const FUNCTION: &str = "function";
162        pub const EXTENSION: &str = "extension";
163        pub const DATASTORE: &str = "datastore";
164    }
165
166    /// Output types as per semantic conventions
167    pub mod output_type {
168        pub const TEXT: &str = "text";
169        pub const IMAGE: &str = "image";
170        pub const JSON: &str = "json";
171        pub const SPEECH: &str = "speech";
172    }
173}
174
175// ============================================================================
176// Telemetry Configuration
177// ============================================================================
178
179/// Configuration for OpenTelemetry
180#[derive(Debug, Clone)]
181pub struct TelemetryConfig {
182    /// Service name for traces
183    pub service_name: String,
184    /// Service version
185    pub service_version: Option<String>,
186    /// OTLP endpoint (e.g., "http://localhost:4317")
187    pub otlp_endpoint: Option<String>,
188    /// Environment (e.g., "development", "production")
189    pub environment: Option<String>,
190    /// Whether to enable console logging
191    pub enable_console: bool,
192    /// Log filter (e.g., "info", "debug", "everruns=debug")
193    pub log_filter: Option<String>,
194    /// Whether to enable content recording (input/output messages)
195    /// Disabled by default for privacy/security
196    pub record_content: bool,
197}
198
199impl Default for TelemetryConfig {
200    fn default() -> Self {
201        Self {
202            service_name: "everruns".to_string(),
203            service_version: None,
204            otlp_endpoint: None,
205            environment: None,
206            enable_console: true,
207            log_filter: None,
208            record_content: false,
209        }
210    }
211}
212
213impl TelemetryConfig {
214    /// Create configuration from environment variables
215    ///
216    /// Environment variables:
217    /// - `OTEL_SDK_DISABLED`: If "true", disables OpenTelemetry tracing entirely
218    /// - `OTEL_SERVICE_NAME`: Service name (default: "everruns")
219    /// - `OTEL_SERVICE_VERSION`: Service version
220    /// - `OTEL_EXPORTER_OTLP_ENDPOINT`: OTLP endpoint (e.g., "http://localhost:4317")
221    /// - `OTEL_ENVIRONMENT`: Deployment environment
222    /// - `RUST_LOG` or `LOG_LEVEL`: Log filter
223    /// - `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`: Record input/output content (standard OTel)
224    /// - `OTEL_RECORD_CONTENT`: Legacy alias for content recording
225    pub fn from_env() -> Self {
226        use crate::config::{env_bool, env_opt_string, env_string};
227
228        let sdk_disabled = env_bool("OTEL_SDK_DISABLED", false);
229
230        Self {
231            service_name: env_string("OTEL_SERVICE_NAME", "everruns"),
232            service_version: env_opt_string("OTEL_SERVICE_VERSION"),
233            otlp_endpoint: if sdk_disabled {
234                None
235            } else {
236                env_opt_string("OTEL_EXPORTER_OTLP_ENDPOINT")
237            },
238            environment: env_opt_string("OTEL_ENVIRONMENT"),
239            enable_console: true,
240            log_filter: env_opt_string("RUST_LOG").or_else(|| env_opt_string("LOG_LEVEL")),
241            // Standard OTel env var, with legacy alias fallback
242            record_content: std::env::var("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT")
243                .or_else(|_| std::env::var("OTEL_RECORD_CONTENT"))
244                .map(|v| v.to_lowercase() == "true")
245                .unwrap_or(false),
246        }
247    }
248}
249
250// ============================================================================
251// Initialization
252// ============================================================================
253
254/// Guard that shuts down the tracer provider when dropped
255#[cfg(feature = "telemetry")]
256pub struct TelemetryGuard {
257    _provider: Option<SdkTracerProvider>,
258}
259
260#[cfg(feature = "telemetry")]
261impl Drop for TelemetryGuard {
262    fn drop(&mut self) {
263        if let Some(provider) = self._provider.take()
264            && let Err(e) = provider.shutdown()
265        {
266            eprintln!("Failed to shutdown tracer provider: {:?}", e);
267        }
268    }
269}
270
271/// Install the rustls crypto provider (ring) for TLS.
272///
273/// Must be called before any TLS usage. Without this, concurrent TLS
274/// connections (e.g. parallel tool execution in ActAtom) panic because
275/// rustls 0.23 cannot auto-detect the provider when multiple threads race.
276///
277/// Safe to call multiple times — subsequent calls are no-ops.
278pub fn install_crypto_provider() {
279    // Already-installed is expected if called from multiple init paths
280    let _ = rustls::crypto::ring::default_provider().install_default();
281}
282
283/// Initialize OpenTelemetry with the given configuration
284///
285/// Returns a guard that will shut down the tracer provider when dropped.
286/// Keep this guard alive for the lifetime of your application.
287///
288/// # Example
289///
290/// ```ignore
291/// use everruns_core::telemetry::{init_telemetry, TelemetryConfig};
292///
293/// #[tokio::main]
294/// async fn main() {
295///     let config = TelemetryConfig::from_env();
296///     let _guard = init_telemetry(config);
297///     // ... your application code
298/// }
299/// ```
300#[cfg(feature = "telemetry")]
301pub fn init_telemetry(config: TelemetryConfig) -> TelemetryGuard {
302    install_crypto_provider();
303
304    // Build resource with service info
305    let mut resource_attrs = vec![KeyValue::new("service.name", config.service_name.clone())];
306
307    if let Some(version) = &config.service_version {
308        resource_attrs.push(KeyValue::new("service.version", version.clone()));
309    }
310
311    if let Some(env) = &config.environment {
312        resource_attrs.push(KeyValue::new("deployment.environment", env.clone()));
313    }
314
315    let resource = Resource::builder().with_attributes(resource_attrs).build();
316
317    // Build log filter
318    let filter = config
319        .log_filter
320        .as_ref()
321        .and_then(|f| EnvFilter::try_new(f).ok())
322        .unwrap_or_else(|| EnvFilter::new("info"));
323
324    // Build console layer if enabled
325    let console_layer = if config.enable_console {
326        Some(
327            tracing_subscriber::fmt::layer()
328                .with_target(true)
329                .with_filter(filter),
330        )
331    } else {
332        None
333    };
334
335    // Build OTLP tracer if endpoint is configured
336    let (tracer_provider, otel_layer, otel_status) = if let Some(endpoint) = &config.otlp_endpoint {
337        match build_otlp_tracer(endpoint, resource) {
338            Ok((provider, tracer)) => {
339                let layer = tracing_opentelemetry::layer().with_tracer(tracer);
340                (Some(provider), Some(layer), Some(Ok(endpoint.clone())))
341            }
342            Err(e) => (None, None, Some(Err(e.to_string()))),
343        }
344    } else {
345        (None, None, None)
346    };
347
348    // Initialize the subscriber
349    tracing_subscriber::registry()
350        .with(console_layer)
351        .with(otel_layer)
352        .init();
353
354    // Log OTEL status after subscriber is initialized
355    match otel_status {
356        Some(Ok(endpoint)) => {
357            tracing::info!(endpoint = %endpoint, "OpenTelemetry tracing enabled");
358        }
359        Some(Err(e)) => {
360            tracing::warn!(error = %e, "Failed to initialize OTLP tracer, continuing without tracing");
361        }
362        None => {
363            tracing::debug!("OpenTelemetry tracing disabled: OTEL_EXPORTER_OTLP_ENDPOINT not set");
364        }
365    }
366
367    TelemetryGuard {
368        _provider: tracer_provider,
369    }
370}
371
372#[cfg(feature = "telemetry")]
373fn build_otlp_tracer(
374    endpoint: &str,
375    resource: Resource,
376) -> Result<
377    (SdkTracerProvider, opentelemetry_sdk::trace::Tracer),
378    Box<dyn std::error::Error + Send + Sync>,
379> {
380    // Use HTTP OTLP instead of gRPC - more reliable with Docker DNS
381    let exporter = SpanExporter::builder()
382        .with_http()
383        .with_endpoint(endpoint)
384        .with_timeout(Duration::from_secs(10))
385        .build()?;
386
387    let provider = SdkTracerProvider::builder()
388        .with_batch_exporter(exporter)
389        .with_sampler(Sampler::AlwaysOn)
390        .with_id_generator(RandomIdGenerator::default())
391        .with_resource(resource)
392        .build();
393
394    let tracer = provider.tracer("everruns");
395
396    Ok((provider, tracer))
397}
398
399// ============================================================================
400// Span Helpers
401// ============================================================================
402
403/// Create a span name for LLM chat operations following gen-ai conventions
404///
405/// Format: `{operation_name} {model_name}`
406/// Example: "chat gpt-4"
407pub fn chat_span_name(model: &str) -> String {
408    format!("{} {}", gen_ai::operation::CHAT, model)
409}
410
411/// Create a span name for tool execution following gen-ai conventions
412///
413/// Format: `execute_tool {tool_name}`
414/// Example: "execute_tool read_file"
415pub fn tool_span_name(tool_name: &str) -> String {
416    format!("{} {}", gen_ai::operation::EXECUTE_TOOL, tool_name)
417}
418
419/// Create a span name for text completion following gen-ai conventions
420///
421/// Format: `text_completion {model_name}`
422/// Example: "text_completion gpt-3.5-turbo-instruct"
423pub fn text_completion_span_name(model: &str) -> String {
424    format!("{} {}", gen_ai::operation::TEXT_COMPLETION, model)
425}
426
427/// Create a span name for agent creation following gen-ai conventions
428///
429/// Format: `create_agent {agent_name}`
430/// Example: "create_agent customer_support_agent"
431pub fn create_agent_span_name(agent_name: &str) -> String {
432    format!("{} {}", gen_ai::operation::CREATE_AGENT, agent_name)
433}
434
435/// Create a span name for agent invocation following gen-ai conventions
436///
437/// Format: `invoke_agent {agent_name}`
438/// Example: "invoke_agent customer_support_agent"
439pub fn invoke_agent_span_name(agent_name: &str) -> String {
440    format!("{} {}", gen_ai::operation::INVOKE_AGENT, agent_name)
441}
442
443/// Create a span name for embeddings following gen-ai conventions
444///
445/// Format: `embeddings {model_name}`
446/// Example: "embeddings text-embedding-ada-002"
447pub fn embeddings_span_name(model: &str) -> String {
448    format!("{} {}", gen_ai::operation::EMBEDDINGS, model)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_chat_span_name() {
457        assert_eq!(chat_span_name("gpt-4"), "chat gpt-4");
458        assert_eq!(chat_span_name("claude-3-opus"), "chat claude-3-opus");
459    }
460
461    #[test]
462    fn test_tool_span_name() {
463        assert_eq!(tool_span_name("read_file"), "execute_tool read_file");
464        assert_eq!(tool_span_name("web_search"), "execute_tool web_search");
465    }
466
467    #[test]
468    fn test_text_completion_span_name() {
469        assert_eq!(
470            text_completion_span_name("gpt-3.5-turbo-instruct"),
471            "text_completion gpt-3.5-turbo-instruct"
472        );
473    }
474
475    #[test]
476    fn test_create_agent_span_name() {
477        assert_eq!(
478            create_agent_span_name("customer_support"),
479            "create_agent customer_support"
480        );
481    }
482
483    #[test]
484    fn test_invoke_agent_span_name() {
485        assert_eq!(
486            invoke_agent_span_name("customer_support"),
487            "invoke_agent customer_support"
488        );
489    }
490
491    #[test]
492    fn test_embeddings_span_name() {
493        assert_eq!(
494            embeddings_span_name("text-embedding-ada-002"),
495            "embeddings text-embedding-ada-002"
496        );
497    }
498
499    #[test]
500    fn test_config_defaults() {
501        let config = TelemetryConfig::default();
502        assert_eq!(config.service_name, "everruns");
503        assert!(config.otlp_endpoint.is_none());
504        assert!(config.enable_console);
505        assert!(!config.record_content);
506    }
507}