Skip to main content

juncture_core/
observability.rs

1// Observability and cache key types
2//
3// This module provides cache key types for LLM response caching.
4
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use std::sync::Arc;
8
9use crate::llm::{CallOptions, ToolDefinition};
10use crate::state::Message;
11
12/// Cache key input for LLM response caching
13///
14/// Used to generate cache keys for LLM responses.
15#[derive(Debug, Clone)]
16pub struct CacheKeyInput {
17    /// Model name
18    pub model: String,
19    /// Messages
20    pub messages: Vec<Message>,
21    /// Tools
22    pub tools: Vec<ToolDefinition>,
23    /// Call options
24    pub config: Option<CallOptions>,
25}
26
27impl CacheKeyInput {
28    /// Create new cache key input
29    ///
30    /// # Arguments
31    ///
32    /// * `model` - Model name
33    /// * `messages` - Message list
34    /// * `tools` - Tool definitions
35    /// * `config` - Optional call options
36    pub fn new(
37        model: impl Into<String>,
38        messages: Vec<Message>,
39        tools: Vec<ToolDefinition>,
40        config: Option<CallOptions>,
41    ) -> Self {
42        Self {
43            model: model.into(),
44            messages,
45            tools,
46            config,
47        }
48    }
49
50    /// Generate hash for this cache key input
51    ///
52    /// Returns a stable hash value for use as cache key.
53    #[must_use]
54    pub fn hash(&self) -> u64 {
55        let mut hasher = DefaultHasher::new();
56
57        // Hash model
58        self.model.hash(&mut hasher);
59
60        // Hash messages (excluding IDs and timestamps)
61        for msg in &self.messages {
62            msg.role.hash(&mut hasher);
63            match &msg.content {
64                crate::state::Content::Text(text) => {
65                    text.hash(&mut hasher);
66                }
67                crate::state::Content::MultiPart(parts) => {
68                    for part in parts {
69                        match part {
70                            crate::state::ContentPart::Text { text } => {
71                                text.hash(&mut hasher);
72                            }
73                            crate::state::ContentPart::Image(data) => {
74                                data.media_type.hash(&mut hasher);
75                                match &data.source {
76                                    crate::state::ImageSource::Base64(data) => {
77                                        data.hash(&mut hasher);
78                                    }
79                                    crate::state::ImageSource::Url(url) => {
80                                        url.hash(&mut hasher);
81                                    }
82                                }
83                            }
84                            crate::state::ContentPart::Thinking { text, signature } => {
85                                text.hash(&mut hasher);
86                                signature.hash(&mut hasher);
87                            }
88                        }
89                    }
90                }
91            }
92            // Hash tool calls
93            for call in &msg.tool_calls {
94                call.id.hash(&mut hasher);
95                call.name.hash(&mut hasher);
96                if let Ok(s) = serde_json::to_string(&call.arguments) {
97                    s.hash(&mut hasher);
98                }
99            }
100        }
101
102        // Hash tools
103        for tool in &self.tools {
104            tool.name.hash(&mut hasher);
105            if let Ok(s) = serde_json::to_string(&tool.parameters) {
106                s.hash(&mut hasher);
107            }
108        }
109
110        // Hash config
111        if let Some(config) = &self.config {
112            if let Some(temp) = config.temperature {
113                (temp.to_bits()).hash(&mut hasher);
114            }
115            if let Some(max_tokens) = config.max_tokens {
116                max_tokens.hash(&mut hasher);
117            }
118            if let Some(top_p) = config.top_p {
119                (top_p.to_bits()).hash(&mut hasher);
120            }
121        }
122
123        hasher.finish()
124    }
125}
126
127/// Cache policy for LLM response caching
128#[derive(Default)]
129#[allow(
130    missing_debug_implementations,
131    clippy::type_complexity,
132    reason = "Contains Arc<dyn Fn> which doesn't implement Debug. Complex trait object type is required for dynamic tool configuration."
133)]
134#[derive(Clone)]
135pub struct CachePolicy {
136    /// Custom cache key generation function
137    pub key_func: Option<Arc<dyn Fn(&CacheKeyInput) -> String + Send + Sync>>,
138}
139
140impl CachePolicy {
141    /// Create new cache policy
142    #[must_use]
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Set custom cache key function
148    #[must_use]
149    pub fn with_key_func(mut self, f: Arc<dyn Fn(&CacheKeyInput) -> String + Send + Sync>) -> Self {
150        self.key_func = Some(f);
151        self
152    }
153
154    /// Generate cache key from input
155    #[must_use]
156    pub fn generate_key(&self, input: &CacheKeyInput) -> String {
157        self.key_func.as_ref().map_or_else(
158            || format!("{}:{}", input.model, input.hash()),
159            |func| func(input),
160        )
161    }
162}
163
164// ---------------------------------------------------------------------------
165// MetricsCollector trait
166// ---------------------------------------------------------------------------
167
168/// Trait for collecting metrics during graph execution.
169///
170/// Implementations can forward to OpenTelemetry, in-memory stores, or any
171/// other metrics backend. Injected via [`RunnableConfig::with_metrics_collector`].
172///
173/// The trait lives in `juncture-core` so the Pregel engine can emit metrics
174/// without depending on `juncture-tracing`. The `juncture-tracing` crate
175/// provides concrete implementations (`TestMetricsCollector`, `RegistryMetricsCollector`).
176///
177/// # Examples
178///
179/// ```ignore
180/// use std::sync::Arc;
181/// use juncture_core::observability::MetricsCollector;
182/// use juncture_core::config::RunnableConfig;
183///
184/// let collector: Arc<dyn MetricsCollector> = /* ... */;
185/// let config = RunnableConfig::new()
186///     .with_metrics_collector(collector);
187/// ```
188pub trait MetricsCollector: Send + Sync + 'static {
189    /// Increment a counter metric by `value`.
190    fn inc_counter(&self, name: &str, value: u64);
191
192    /// Record `value` to a histogram metric.
193    fn record_histogram(&self, name: &str, value: f64);
194
195    /// Set a gauge metric to `value`.
196    fn set_gauge(&self, name: &str, value: u64);
197}
198
199// ---------------------------------------------------------------------------
200// GraphLifecycleCallback trait
201// ---------------------------------------------------------------------------
202
203/// Callback trait for graph lifecycle events.
204///
205/// Implementations receive notifications at key points during graph execution.
206/// All methods have default no-op implementations. Injected via
207/// [`RunnableConfig::with_callback_handler`].
208///
209/// The trait lives in `juncture-core` so the Pregel engine can emit callbacks
210/// without depending on `juncture-tracing`. The `juncture-tracing` crate
211/// provides a blanket impl that forwards [`GraphCallbackHandler`] to this
212/// trait, so any type implementing [`GraphCallbackHandler`] can be passed
213/// to [`RunnableConfig::with_callback_handler`] directly.
214///
215/// [`GraphCallbackHandler`]: juncture_tracing::callback::GraphCallbackHandler
216/// [`RunnableConfig::with_callback_handler`]: crate::config::RunnableConfig::with_callback_handler
217///
218/// # Examples
219///
220/// ```ignore
221/// use std::sync::Arc;
222/// use juncture_core::observability::GraphLifecycleCallback;
223/// use juncture_core::config::RunnableConfig;
224///
225/// let handler: Arc<dyn GraphLifecycleCallback> = /* ... */;
226/// let config = RunnableConfig::new()
227///     .with_callback_handler(handler);
228/// ```
229pub trait GraphLifecycleCallback: Send + Sync + 'static {
230    /// Called when a node starts execution.
231    fn on_node_start(&self, node: &str, task_id: &str) {
232        let _ = (node, task_id);
233    }
234
235    /// Called when a node completes execution successfully.
236    fn on_node_end(&self, node: &str, task_id: &str, duration_ms: u64) {
237        let _ = (node, task_id, duration_ms);
238    }
239
240    /// Called when a node encounters an error.
241    fn on_node_error(&self, node: &str, error: &crate::JunctureError) {
242        let _ = (node, error);
243    }
244
245    /// Called when the graph execution completes.
246    fn on_graph_end(&self, result: &Result<(), crate::JunctureError>) {
247        let _ = result;
248    }
249
250    /// Called when a checkpoint is saved.
251    fn on_checkpoint_saved(&self, checkpoint_id: &str, step: usize) {
252        let _ = (checkpoint_id, step);
253    }
254}
255
256// Rust guideline compliant 2026-05-21