Skip to main content

agentforge_observability/
datadog.rs

1use agentforge_core::Trace;
2use async_trait::async_trait;
3use reqwest::Client;
4use serde_json::json;
5
6use crate::{ExporterError, TraceExporter};
7
8/// Exports traces to Datadog APM via the Datadog Trace API.
9///
10/// Env vars:
11/// - `DD_API_KEY` — Datadog API key
12/// - `DD_SITE` — Datadog site (default: `datadoghq.com`)
13/// - `DD_SERVICE` — service name tag (default: `agentforge`)
14/// - `DD_ENV` — environment tag (default: `production`)
15pub struct DatadogExporter {
16    api_key: String,
17    traces_url: String,
18    service: String,
19    env: String,
20    client: Client,
21}
22
23impl DatadogExporter {
24    pub fn new(api_key: String, site: String, service: String, env: String) -> Self {
25        let traces_url = format!("https://trace.agent.{site}/api/v0.2/traces");
26        Self {
27            api_key,
28            traces_url,
29            service,
30            env,
31            client: Client::new(),
32        }
33    }
34
35    pub fn from_env() -> Self {
36        Self::new(
37            std::env::var("DD_API_KEY").unwrap_or_default(),
38            std::env::var("DD_SITE").unwrap_or_else(|_| "datadoghq.com".to_string()),
39            std::env::var("DD_SERVICE").unwrap_or_else(|_| "agentforge".to_string()),
40            std::env::var("DD_ENV").unwrap_or_else(|_| "production".to_string()),
41        )
42    }
43}
44
45#[async_trait]
46impl TraceExporter for DatadogExporter {
47    async fn export(&self, trace: &Trace) -> Result<(), ExporterError> {
48        if self.api_key.is_empty() {
49            return Err(ExporterError::Config("DD_API_KEY is not set".to_string()));
50        }
51
52        // Datadog APM expects trace_id and span_id as u64.
53        let trace_id = trace.id.as_u128() as u64;
54        let span_id = trace.run_id.as_u128() as u64;
55
56        let span = json!({
57            "trace_id": trace_id,
58            "span_id": span_id,
59            "name": "agentforge.eval",
60            "resource": format!("scenario/{}", trace.scenario_id),
61            "service": self.service,
62            "type": "custom",
63            "start": trace.created_at.timestamp_nanos_opt().unwrap_or(0),
64            "duration": (trace.latency_ms * 1_000_000) as i64, // ns
65            "meta": {
66                "env": self.env,
67                "run_id": trace.run_id.to_string(),
68                "scenario_id": trace.scenario_id.to_string(),
69                "status": trace.status.to_string(),
70                "failure_cluster": trace.failure_cluster.to_string(),
71                "aggregate_score": trace.aggregate_score.map(|s| s.to_string()).unwrap_or_default(),
72            },
73            "metrics": {
74                "llm_calls": trace.llm_calls,
75                "input_tokens": trace.input_tokens,
76                "output_tokens": trace.output_tokens,
77                "tool_invocations": trace.tool_invocations,
78            }
79        });
80
81        // Datadog expects an array of arrays (list of traces, each trace = list of spans)
82        let payload = json!([[span]]);
83
84        self.client
85            .put(&self.traces_url)
86            .header("DD-API-KEY", &self.api_key)
87            .header("Content-Type", "application/json")
88            .json(&payload)
89            .send()
90            .await?
91            .error_for_status()
92            .map_err(ExporterError::Http)?;
93
94        tracing::debug!(trace_id = %trace.id, "Exported trace to Datadog");
95        Ok(())
96    }
97}