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