1use std::collections::HashMap;
39use std::sync::Arc;
40use std::time::Duration;
41
42use async_trait::async_trait;
43use serde_json::{json, Value};
44
45use crate::context::Context;
46use crate::outbound::{OutboundClient, OutboundRequest};
47use crate::plugins::resources::PluginResources;
48use crate::plugins::util::trace::{
49 build_traceparent, load_span, new_span_id, new_trace_id, now_ms, parse_traceparent, store_span,
50 SpanContext,
51};
52use crate::plugins::{Plugin, PluginOutput, PluginResult};
53
54#[derive(Debug, Clone, Copy, PartialEq)]
56enum Phase {
57 Start,
59 End,
61}
62
63#[derive(Debug, Clone, Copy)]
65enum Sampler {
66 AlwaysOn,
67 AlwaysOff,
68 TraceIdRatio(f64),
70}
71
72impl Sampler {
73 fn sample_new(&self, trace_id: &str) -> bool {
79 match self {
80 Sampler::AlwaysOn => true,
81 Sampler::AlwaysOff => false,
82 Sampler::TraceIdRatio(f) => ratio_draw(trace_id) < *f,
83 }
84 }
85}
86
87fn ratio_draw(trace_id: &str) -> f64 {
91 use std::collections::hash_map::DefaultHasher;
92 use std::hash::{Hash, Hasher};
93 let mut h = DefaultHasher::new();
94 trace_id.hash(&mut h);
95 h.finish() as f64 / (u64::MAX as f64 + 1.0)
96}
97
98pub struct OpenTelemetryPlugin {
100 phase: Phase,
101 collector: String,
104 service_name: String,
105 sampler: Sampler,
106 ssl_verify: bool,
107 timeout: Duration,
108 client: Arc<OutboundClient>,
109}
110
111fn parse_sampler(config: &HashMap<String, Value>) -> Result<Sampler, String> {
114 let Some(s) = config.get("sampler") else {
115 return Ok(Sampler::AlwaysOn);
116 };
117 let name = s
118 .get("name")
119 .and_then(|v| v.as_str())
120 .unwrap_or("always_on");
121 let fraction = s
122 .get("options")
123 .and_then(|o| o.get("fraction"))
124 .map(|f| {
125 f.as_f64()
126 .filter(|f| (0.0..=1.0).contains(f))
127 .ok_or("opentelemetry `sampler.options.fraction` must be a number in 0.0..=1.0")
128 })
129 .transpose()?
130 .unwrap_or(1.0);
131 match name {
132 "always_on" => Ok(Sampler::AlwaysOn),
133 "always_off" => Ok(Sampler::AlwaysOff),
134 "trace_id_ratio" => Ok(Sampler::TraceIdRatio(fraction)),
135 other => Err(format!(
136 "opentelemetry unknown sampler `{other}` (expected always_on, always_off, trace_id_ratio)"
137 )),
138 }
139}
140
141impl OpenTelemetryPlugin {
142 pub fn from_config(
175 config: &HashMap<String, Value>,
176 resources: &Arc<PluginResources>,
177 ) -> Result<Self, String> {
178 let phase = match config.get("phase").and_then(|v| v.as_str()) {
179 Some("start") => Phase::Start,
180 Some("end") => Phase::End,
181 Some(other) => {
182 return Err(format!(
183 "opentelemetry `phase` must be `start` or `end` (got `{other}`)"
184 ))
185 }
186 None => return Err("opentelemetry requires `phase` (`start` or `end`)".to_string()),
187 };
188
189 let collector = config
190 .get("endpoint")
191 .or_else(|| config.get("collector"))
192 .and_then(|v| v.as_str())
193 .filter(|s| !s.is_empty())
194 .unwrap_or("http://localhost:4318")
195 .trim_end_matches('/')
196 .to_string();
197
198 let service_name = config
199 .get("service_name")
200 .and_then(|v| v.as_str())
201 .unwrap_or("featherbit")
202 .to_string();
203
204 let sampler = parse_sampler(config)?;
205
206 let ssl_verify = config
207 .get("ssl_verify")
208 .and_then(|v| v.as_bool())
209 .unwrap_or(true);
210 let timeout =
211 Duration::from_secs(config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(3));
212
213 Ok(Self {
214 phase,
215 collector,
216 service_name,
217 sampler,
218 ssl_verify,
219 timeout,
220 client: resources.outbound.clone(),
221 })
222 }
223
224 fn run_start(&self, ctx: &mut Context) {
226 let incoming = ctx
227 .request
228 .headers
229 .get("traceparent")
230 .and_then(|v| v.first())
231 .and_then(|v| parse_traceparent(v));
232
233 let (trace_id, parent_span_id, sampled) = match incoming {
234 Some((trace_id, parent, sampled)) => (trace_id, Some(parent), sampled),
235 None => {
236 let trace_id = new_trace_id();
237 let sampled = self.sampler.sample_new(&trace_id);
238 (trace_id, None, sampled)
239 }
240 };
241
242 let span = SpanContext {
243 trace_id,
244 span_id: new_span_id(),
245 parent_span_id,
246 sampled,
247 start_ms: now_ms(),
248 };
249
250 ctx.request
252 .headers
253 .insert("traceparent".to_string(), vec![build_traceparent(&span)]);
254
255 store_span(ctx, &span);
256 }
257
258 fn run_end(&self, ctx: &Context) {
260 let Some(span) = load_span(ctx) else {
261 return;
262 };
263 if !span.sampled {
264 return;
265 }
266
267 let payload = build_otlp(&span, ctx, &self.service_name);
268 let body = match serde_json::to_vec(&payload) {
269 Ok(b) => b,
270 Err(_) => return,
271 };
272
273 let req = OutboundRequest {
274 method: http::Method::POST,
275 url: format!("{}/v1/traces", self.collector),
276 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
277 body: body.into(),
278 timeout: self.timeout,
279 ssl_verify: self.ssl_verify,
280 tls: None,
281 };
282 let client = self.client.clone();
283 tokio::spawn(async move {
285 let _ = client.request(req).await;
286 });
287 }
288}
289
290fn build_otlp(span: &SpanContext, ctx: &Context, service_name: &str) -> Value {
295 let start_nanos = span.start_ms.saturating_mul(1_000_000);
296 let end_nanos = (span.start_ms + span.duration_ms()).saturating_mul(1_000_000);
297 let status = ctx.response.status_code;
298 let name = format!("{} {}", ctx.request.method, ctx.request.path);
299
300 let mut span_json = json!({
301 "traceId": span.trace_id,
302 "spanId": span.span_id,
303 "name": name,
304 "kind": 2, "startTimeUnixNano": start_nanos.to_string(),
306 "endTimeUnixNano": end_nanos.to_string(),
307 "attributes": [
308 str_attr("http.method", &ctx.request.method),
309 int_attr("http.status_code", status as i64),
310 str_attr("http.target", &ctx.request.path),
311 str_attr("http.host", &ctx.request.host),
312 ],
313 "status": { "code": if status >= 500 { 2 } else { 0 } },
314 });
315 if let Some(parent) = &span.parent_span_id {
316 span_json["parentSpanId"] = json!(parent);
317 }
318
319 json!({
320 "resourceSpans": [{
321 "resource": {
322 "attributes": [ str_attr("service.name", service_name) ]
323 },
324 "scopeSpans": [{
325 "spans": [ span_json ]
326 }]
327 }]
328 })
329}
330
331fn str_attr(key: &str, value: &str) -> Value {
332 json!({ "key": key, "value": { "stringValue": value } })
333}
334
335fn int_attr(key: &str, value: i64) -> Value {
336 json!({ "key": key, "value": { "intValue": value.to_string() } })
337}
338
339#[async_trait]
340impl Plugin for OpenTelemetryPlugin {
341 fn plugin_type(&self) -> &str {
342 "opentelemetry"
343 }
344
345 async fn execute(
346 &self,
347 mut ctx: Context,
348 _named_inputs: &HashMap<String, Value>,
349 ) -> PluginResult {
350 match self.phase {
351 Phase::Start => self.run_start(&mut ctx),
352 Phase::End => self.run_end(&ctx),
353 }
354 Ok(PluginOutput {
355 context: ctx,
356 named_outputs: HashMap::new(),
357 })
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
365 use bytes::Bytes;
366
367 fn plugin(config: Value) -> Result<OpenTelemetryPlugin, String> {
368 let map: HashMap<String, Value> = serde_json::from_value(config).unwrap();
369 OpenTelemetryPlugin::from_config(&map, &PluginResources::empty())
370 }
371
372 fn test_ctx() -> Context {
373 Context {
374 request: GatewayRequest {
375 method: "GET".to_string(),
376 path: "/api/users".to_string(),
377 host: "example.com".to_string(),
378 scheme: "http".to_string(),
379 headers: HashMap::new(),
380 query_params: HashMap::new(),
381 body: Bytes::new(),
382 remote_addr: "10.0.0.1:5555".to_string(),
383 protocol: Protocol::Http1,
384 },
385 response: GatewayResponse {
386 status_code: 200,
387 headers: HashMap::new(),
388 body: Bytes::new(),
389 },
390 message: HashMap::new(),
391 errors: Vec::new(),
392 }
393 }
394
395 fn a_span(sampled: bool, parent: Option<&str>) -> SpanContext {
396 SpanContext {
397 trace_id: "0af7651916cd43dd8448eb211c80319c".to_string(),
398 span_id: "b7ad6b7169203331".to_string(),
399 parent_span_id: parent.map(String::from),
400 sampled,
401 start_ms: 1_700_000_000_000,
402 }
403 }
404
405 #[test]
406 fn config_requires_valid_phase() {
407 assert!(plugin(json!({})).is_err());
408 assert!(plugin(json!({ "phase": "middle" })).is_err());
409 assert!(plugin(json!({ "phase": "start" })).is_ok());
410 assert!(plugin(json!({ "phase": "end" })).is_ok());
411 }
412
413 #[test]
414 fn config_rejects_bad_sampler() {
415 assert!(plugin(json!({ "phase": "start", "sampler": { "name": "bogus" } })).is_err());
416 assert!(plugin(json!({
417 "phase": "start", "sampler": { "name": "trace_id_ratio", "options": { "fraction": 2 } }
418 }))
419 .is_err());
420 assert!(plugin(json!({
421 "phase": "start", "sampler": { "name": "trace_id_ratio", "options": { "fraction": 0.5 } }
422 }))
423 .is_ok());
424 }
425
426 #[test]
427 fn sampler_extremes() {
428 assert!(!Sampler::AlwaysOff.sample_new("abc"));
429 assert!(Sampler::AlwaysOn.sample_new("abc"));
430 assert!(!Sampler::TraceIdRatio(0.0).sample_new("abc"));
431 assert!(Sampler::TraceIdRatio(1.0).sample_new("abc"));
432 }
433
434 #[test]
435 fn otlp_payload_shape() {
436 let mut ctx = test_ctx();
437 ctx.response.status_code = 503;
438 let span = a_span(true, Some("0020000000000001"));
439 let v = build_otlp(&span, &ctx, "svc");
440 let s = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
441 assert_eq!(s["traceId"], "0af7651916cd43dd8448eb211c80319c");
442 assert_eq!(s["spanId"], "b7ad6b7169203331");
443 assert_eq!(s["parentSpanId"], "0020000000000001");
444 assert_eq!(s["kind"], 2);
445 assert_eq!(s["name"], "GET /api/users");
446 assert_eq!(s["status"]["code"], 2);
448 assert_eq!(
450 v["resourceSpans"][0]["resource"]["attributes"][0]["value"]["stringValue"],
451 "svc"
452 );
453 }
454
455 #[test]
456 fn otlp_omits_parent_when_root() {
457 let span = a_span(true, None);
458 let v = build_otlp(&span, &test_ctx(), "svc");
459 let s = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
460 assert!(s.get("parentSpanId").is_none());
461 assert_eq!(s["status"]["code"], 0);
463 }
464
465 #[tokio::test]
466 async fn start_creates_new_trace_and_injects_header() {
467 let p = plugin(json!({ "phase": "start", "sampler": { "name": "always_on" } })).unwrap();
468 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
469 let span = load_span(&out.context).expect("span stored");
471 assert!(span.parent_span_id.is_none());
472 assert!(span.sampled);
473 assert_eq!(span.trace_id.len(), 32);
474 let hdr = &out.context.request.headers["traceparent"][0];
476 assert_eq!(*hdr, build_traceparent(&span));
477 }
478
479 #[tokio::test]
480 async fn start_continues_incoming_trace() {
481 let p = plugin(json!({ "phase": "start" })).unwrap();
482 let mut ctx = test_ctx();
483 ctx.request.headers.insert(
484 "traceparent".to_string(),
485 vec!["00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string()],
486 );
487 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
488 let span = load_span(&out.context).unwrap();
489 assert_eq!(span.trace_id, "0af7651916cd43dd8448eb211c80319c");
490 assert_eq!(span.parent_span_id.as_deref(), Some("b7ad6b7169203331"));
491 assert!(span.sampled);
492 assert_ne!(span.span_id, "b7ad6b7169203331");
494 }
495
496 #[tokio::test]
497 async fn end_passes_through_without_span() {
498 let p = plugin(json!({ "phase": "end" })).unwrap();
499 let out = p.execute(test_ctx(), &HashMap::new()).await.unwrap();
500 assert_eq!(out.context.response.status_code, 200);
501 }
502
503 #[tokio::test]
504 async fn end_returns_ok_with_stored_span() {
505 let p = plugin(json!({ "phase": "end", "endpoint": "http://127.0.0.1:1" })).unwrap();
506 let mut ctx = test_ctx();
507 store_span(&mut ctx, &a_span(true, None));
508 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
510 assert_eq!(out.context.request.path, "/api/users");
511 }
512
513 #[tokio::test]
514 async fn end_skips_export_when_unsampled() {
515 let p = plugin(json!({ "phase": "end" })).unwrap();
516 let mut ctx = test_ctx();
517 store_span(&mut ctx, &a_span(false, None));
518 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
519 assert_eq!(out.context.response.status_code, 200);
520 }
521}