Skip to main content

enya_client/otlp/
http_tracing_client.rs

1//! HTTP-based OTLP tracing client.
2//!
3//! Queries the agent daemon's OTLP store over HTTP, following the same
4//! promise-based async pattern as [`TempoClient`](crate::tracing::tempo::TempoClient).
5
6use poll_promise::Promise;
7
8use crate::error::ClientError;
9use crate::normalize_url;
10use crate::promise::promise_channel;
11use crate::tracing::tempo::types::{Trace, TraceSearchParams, TraceSummary};
12use crate::tracing::{SearchResult, TraceResult, TracingClient};
13
14/// HTTP client for querying the agent's OTLP telemetry store.
15///
16/// Sends GET requests to `/api/otlp/traces/{id}` and `/api/otlp/traces/search`
17/// endpoints on the agent daemon.
18pub struct OtlpHttpTracingClient {
19    base_url: String,
20    http_client: reqwest::Client,
21    #[cfg(not(target_arch = "wasm32"))]
22    runtime_handle: tokio::runtime::Handle,
23}
24
25impl OtlpHttpTracingClient {
26    /// Create a new OTLP HTTP tracing client.
27    ///
28    /// # Arguments
29    ///
30    /// * `base_url` - The agent daemon URL (e.g., "http://localhost:3030")
31    ///
32    /// # Panics
33    ///
34    /// On native, panics if called outside a tokio runtime context.
35    #[must_use]
36    pub fn new(base_url: impl Into<String>) -> Self {
37        Self {
38            base_url: normalize_url(base_url),
39            http_client: reqwest::Client::new(),
40            #[cfg(not(target_arch = "wasm32"))]
41            runtime_handle: tokio::runtime::Handle::current(),
42        }
43    }
44
45    /// Create a new client with an explicit runtime handle.
46    #[cfg(not(target_arch = "wasm32"))]
47    #[must_use]
48    pub fn with_runtime(base_url: impl Into<String>, handle: tokio::runtime::Handle) -> Self {
49        Self {
50            base_url: normalize_url(base_url),
51            http_client: reqwest::Client::new(),
52            runtime_handle: handle,
53        }
54    }
55
56    #[cfg(not(target_arch = "wasm32"))]
57    fn spawn<F>(&self, future: F)
58    where
59        F: std::future::Future<Output = ()> + Send + 'static,
60    {
61        self.runtime_handle.spawn(future);
62    }
63
64    #[cfg(target_arch = "wasm32")]
65    fn spawn<F>(&self, future: F)
66    where
67        F: std::future::Future<Output = ()> + 'static,
68    {
69        wasm_bindgen_futures::spawn_local(future);
70    }
71
72    fn build_search_url(&self, params: &TraceSearchParams) -> String {
73        let mut url = format!("{}/api/otlp/traces/search?", self.base_url);
74        let mut first = true;
75
76        fn append(url: &mut String, first: &mut bool, key: &str, value: &str) {
77            if !*first {
78                url.push('&');
79            }
80            *first = false;
81            url.push_str(key);
82            url.push('=');
83            url.push_str(value);
84        }
85
86        if let Some(ref service) = params.service_name {
87            append(&mut url, &mut first, "service_name", service);
88        }
89
90        if let Some(ref op) = params.operation_name {
91            append(&mut url, &mut first, "operation_name", op);
92        }
93
94        if let Some(min_dur) = params.min_duration_ms {
95            append(
96                &mut url,
97                &mut first,
98                "min_duration_ms",
99                &min_dur.to_string(),
100            );
101        }
102
103        if let Some(max_dur) = params.max_duration_ms {
104            append(
105                &mut url,
106                &mut first,
107                "max_duration_ms",
108                &max_dur.to_string(),
109            );
110        }
111
112        if let Some(limit) = params.limit {
113            append(&mut url, &mut first, "limit", &limit.to_string());
114        } else {
115            append(&mut url, &mut first, "limit", "20");
116        }
117
118        if let Some(start) = params.start_time_secs {
119            append(&mut url, &mut first, "start_time_secs", &start.to_string());
120        }
121
122        if let Some(end) = params.end_time_secs {
123            append(&mut url, &mut first, "end_time_secs", &end.to_string());
124        }
125
126        url
127    }
128}
129
130impl TracingClient for OtlpHttpTracingClient {
131    fn get_trace(&self, trace_id: &str, ctx: &egui::Context) -> Promise<TraceResult> {
132        let url = format!("{}/api/otlp/traces/{}", self.base_url, trace_id);
133
134        log::debug!("OTLP HTTP get_trace: {url}");
135
136        let (sender, promise) = promise_channel();
137        let ctx = ctx.clone();
138        let client = self.http_client.clone();
139
140        self.spawn(async move {
141            let result = match client.get(&url).send().await {
142                Ok(response) => {
143                    let status = response.status();
144                    if status.is_success() {
145                        match response.bytes().await {
146                            Ok(bytes) => serde_json::from_slice::<Trace>(&bytes)
147                                .map_err(|e| ClientError::ParseError(e.to_string())),
148                            Err(e) => Err(ClientError::NetworkError(e.to_string())),
149                        }
150                    } else if status.as_u16() == 404 {
151                        Err(ClientError::BackendError {
152                            status: 404,
153                            message: "Trace not found".to_string(),
154                        })
155                    } else {
156                        Err(ClientError::BackendError {
157                            status: status.as_u16(),
158                            message: status.canonical_reason().unwrap_or("Unknown").to_string(),
159                        })
160                    }
161                }
162                Err(e) => Err(ClientError::NetworkError(e.to_string())),
163            };
164            sender.send(result);
165            ctx.request_repaint();
166        });
167
168        promise
169    }
170
171    fn search_traces(
172        &self,
173        params: TraceSearchParams,
174        ctx: &egui::Context,
175    ) -> Promise<SearchResult> {
176        let url = self.build_search_url(&params);
177
178        log::debug!("OTLP HTTP search_traces: {url}");
179
180        let (sender, promise) = promise_channel();
181        let ctx = ctx.clone();
182        let client = self.http_client.clone();
183
184        self.spawn(async move {
185            let result = match client.get(&url).send().await {
186                Ok(response) => {
187                    let status = response.status();
188                    if status.is_success() {
189                        match response.bytes().await {
190                            Ok(bytes) => serde_json::from_slice::<Vec<TraceSummary>>(&bytes)
191                                .map_err(|e| ClientError::ParseError(e.to_string())),
192                            Err(e) => Err(ClientError::NetworkError(e.to_string())),
193                        }
194                    } else {
195                        Err(ClientError::BackendError {
196                            status: status.as_u16(),
197                            message: status.canonical_reason().unwrap_or("Unknown").to_string(),
198                        })
199                    }
200                }
201                Err(e) => Err(ClientError::NetworkError(e.to_string())),
202            };
203            sender.send(result);
204            ctx.request_repaint();
205        });
206
207        promise
208    }
209
210    fn backend_type(&self) -> &'static str {
211        "otlp"
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn with_runtime<F: FnOnce()>(f: F) {
220        let rt = tokio::runtime::Runtime::new().unwrap();
221        let _guard = rt.enter();
222        f();
223    }
224
225    #[test]
226    fn test_new_removes_trailing_slash() {
227        with_runtime(|| {
228            let client = OtlpHttpTracingClient::new("http://localhost:3030/");
229            assert_eq!(client.base_url, "http://localhost:3030");
230        });
231    }
232
233    #[test]
234    fn test_new_adds_http_protocol() {
235        with_runtime(|| {
236            let client = OtlpHttpTracingClient::new("localhost:3030");
237            assert_eq!(client.base_url, "http://localhost:3030");
238        });
239    }
240
241    #[test]
242    fn test_new_preserves_https() {
243        with_runtime(|| {
244            let client = OtlpHttpTracingClient::new("https://agent.example.com");
245            assert_eq!(client.base_url, "https://agent.example.com");
246        });
247    }
248
249    #[test]
250    fn test_backend_type() {
251        with_runtime(|| {
252            let client = OtlpHttpTracingClient::new("http://localhost:3030");
253            assert_eq!(client.backend_type(), "otlp");
254        });
255    }
256
257    #[test]
258    fn test_build_search_url_minimal() {
259        with_runtime(|| {
260            let client = OtlpHttpTracingClient::new("http://localhost:3030");
261            let params = TraceSearchParams::default();
262            let url = client.build_search_url(&params);
263            assert!(url.starts_with("http://localhost:3030/api/otlp/traces/search?"));
264            assert!(url.contains("limit=20")); // default limit
265        });
266    }
267
268    #[test]
269    fn test_build_search_url_with_service() {
270        with_runtime(|| {
271            let client = OtlpHttpTracingClient::new("http://localhost:3030");
272            let params = TraceSearchParams {
273                service_name: Some("my-api".to_string()),
274                limit: Some(10),
275                ..Default::default()
276            };
277            let url = client.build_search_url(&params);
278            assert!(url.contains("service_name=my-api"));
279            assert!(url.contains("limit=10"));
280        });
281    }
282
283    #[test]
284    fn test_build_search_url_all_params() {
285        with_runtime(|| {
286            let client = OtlpHttpTracingClient::new("http://localhost:3030");
287            let params = TraceSearchParams {
288                service_name: Some("svc".to_string()),
289                operation_name: Some("GET /".to_string()),
290                tags: Default::default(),
291                min_duration_ms: Some(100),
292                max_duration_ms: Some(5000),
293                limit: Some(50),
294                start_time_secs: Some(1000),
295                end_time_secs: Some(2000),
296            };
297            let url = client.build_search_url(&params);
298            assert!(url.contains("service_name=svc"));
299            assert!(url.contains("operation_name=GET /"));
300            assert!(url.contains("min_duration_ms=100"));
301            assert!(url.contains("max_duration_ms=5000"));
302            assert!(url.contains("limit=50"));
303            assert!(url.contains("start_time_secs=1000"));
304            assert!(url.contains("end_time_secs=2000"));
305        });
306    }
307}