enya_client/otlp/
http_tracing_client.rs1use 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
14pub 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 #[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 #[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(¶ms);
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(¶ms);
263 assert!(url.starts_with("http://localhost:3030/api/otlp/traces/search?"));
264 assert!(url.contains("limit=20")); });
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(¶ms);
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(¶ms);
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}