|
| 1 | +use std::sync::Arc; |
| 2 | + |
| 3 | +use bytes::Bytes; |
| 4 | +use chrono::{TimeZone, Utc}; |
| 5 | +use http::StatusCode; |
| 6 | +use serde::Deserialize; |
| 7 | +use serde_json::Value; |
| 8 | +use vector_lib::{ |
| 9 | + EstimatedJsonEncodedSizeOf, |
| 10 | + config::LegacyKey, |
| 11 | + internal_event::{CountByteSize, InternalEventHandle as _}, |
| 12 | + json_size::JsonSize, |
| 13 | + lookup::{event_path, path}, |
| 14 | +}; |
| 15 | +use warp::{Filter, filters::BoxedFilter, path as warp_path, path::FullPath, reply::Response}; |
| 16 | + |
| 17 | +use super::{ApiKeyQueryParams, DatadogAgentConfig, DatadogAgentSource, RequestHandler}; |
| 18 | +use crate::{ |
| 19 | + common::http::ErrorMessage, |
| 20 | + event::{Event, LogEvent}, |
| 21 | + internal_events::DatadogAgentJsonParseError, |
| 22 | + sources::util::http::capped_body, |
| 23 | +}; |
| 24 | + |
| 25 | +pub(super) fn build_warp_filter( |
| 26 | + handler: RequestHandler, |
| 27 | + source: DatadogAgentSource, |
| 28 | +) -> BoxedFilter<(Response,)> { |
| 29 | + let direct = warp::post() |
| 30 | + .and(warp_path!("api" / "v2" / "llmobs" / ..)) |
| 31 | + .and(warp::path::full()) |
| 32 | + .and(warp::header::optional::<String>("content-encoding")) |
| 33 | + .and(warp::header::optional::<String>("dd-api-key")) |
| 34 | + .and(warp::query::<ApiKeyQueryParams>()) |
| 35 | + .and(capped_body()) |
| 36 | + .and_then({ |
| 37 | + let handler = handler.clone(); |
| 38 | + let source = source.clone(); |
| 39 | + move |path: FullPath, |
| 40 | + encoding_header: Option<String>, |
| 41 | + api_token: Option<String>, |
| 42 | + query_params: ApiKeyQueryParams, |
| 43 | + body: Bytes| { |
| 44 | + let events = source |
| 45 | + .decode(&encoding_header, body, path.as_str()) |
| 46 | + .and_then(|body| { |
| 47 | + decode_llmobs_body( |
| 48 | + body, |
| 49 | + source.api_key_extractor.extract( |
| 50 | + path.as_str(), |
| 51 | + api_token, |
| 52 | + query_params.dd_api_key, |
| 53 | + ), |
| 54 | + &source, |
| 55 | + ) |
| 56 | + }); |
| 57 | + handler.clone().handle_request(events, super::LLMOBS) |
| 58 | + } |
| 59 | + }); |
| 60 | + |
| 61 | + let evp_proxy = warp::post() |
| 62 | + .and(warp_path!( |
| 63 | + "evp_proxy" / "v2" / "api" / "v2" / "llmobs" / .. |
| 64 | + )) |
| 65 | + .and(warp::path::full()) |
| 66 | + .and(warp::header::optional::<String>("content-encoding")) |
| 67 | + .and(warp::header::optional::<String>("dd-api-key")) |
| 68 | + .and(warp::query::<ApiKeyQueryParams>()) |
| 69 | + .and(capped_body()) |
| 70 | + .and_then( |
| 71 | + move |path: FullPath, |
| 72 | + encoding_header: Option<String>, |
| 73 | + api_token: Option<String>, |
| 74 | + query_params: ApiKeyQueryParams, |
| 75 | + body: Bytes| { |
| 76 | + let events = source |
| 77 | + .decode(&encoding_header, body, path.as_str()) |
| 78 | + .and_then(|body| { |
| 79 | + decode_llmobs_body( |
| 80 | + body, |
| 81 | + source.api_key_extractor.extract( |
| 82 | + path.as_str(), |
| 83 | + api_token, |
| 84 | + query_params.dd_api_key, |
| 85 | + ), |
| 86 | + &source, |
| 87 | + ) |
| 88 | + }); |
| 89 | + handler.clone().handle_request(events, super::LLMOBS) |
| 90 | + }, |
| 91 | + ); |
| 92 | + |
| 93 | + direct.or(evp_proxy).unify().boxed() |
| 94 | +} |
| 95 | + |
| 96 | +#[derive(Deserialize)] |
| 97 | +struct LLMObsEnvelopeItem { |
| 98 | + #[serde(rename = "event_type")] |
| 99 | + _event_type: Option<String>, |
| 100 | + spans: Vec<LLMObsSpan>, |
| 101 | + #[serde(rename = "_dd.tracer_version")] |
| 102 | + dd_tracer_version: Option<String>, |
| 103 | + #[serde(rename = "_dd.scope")] |
| 104 | + _dd_scope: Option<String>, |
| 105 | +} |
| 106 | + |
| 107 | +/// The Datadog Agent and SDK clients (e.g. dd-trace-py's `LLMObsSpanEncoder`) POST a single |
| 108 | +/// JSON object envelope. Some fixtures/tools may send a JSON array of envelopes instead. |
| 109 | +/// Accept both shapes. |
| 110 | +#[derive(Deserialize)] |
| 111 | +#[serde(untagged)] |
| 112 | +enum LLMObsEnvelope { |
| 113 | + Single(LLMObsEnvelopeItem), |
| 114 | + Multiple(Vec<LLMObsEnvelopeItem>), |
| 115 | +} |
| 116 | + |
| 117 | +impl LLMObsEnvelope { |
| 118 | + fn into_items(self) -> Vec<LLMObsEnvelopeItem> { |
| 119 | + match self { |
| 120 | + LLMObsEnvelope::Single(item) => vec![item], |
| 121 | + LLMObsEnvelope::Multiple(items) => items, |
| 122 | + } |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +#[derive(Deserialize)] |
| 127 | +struct LLMObsSpan { |
| 128 | + span_id: String, |
| 129 | + trace_id: String, |
| 130 | + parent_id: Option<String>, |
| 131 | + name: Option<String>, |
| 132 | + session_id: Option<String>, |
| 133 | + service: Option<String>, |
| 134 | + start_ns: Option<i64>, |
| 135 | + duration: Option<i64>, |
| 136 | + status: Option<String>, |
| 137 | + status_message: Option<String>, |
| 138 | + meta: Option<Value>, |
| 139 | + metrics: Option<Value>, |
| 140 | + #[serde(default)] |
| 141 | + tags: Vec<String>, |
| 142 | + #[serde(rename = "_dd")] |
| 143 | + dd: Option<Value>, |
| 144 | + span_links: Option<Value>, |
| 145 | + #[serde(rename = "config")] |
| 146 | + config: Option<Value>, |
| 147 | + collection_errors: Option<Value>, |
| 148 | +} |
| 149 | + |
| 150 | +pub(crate) fn decode_llmobs_body( |
| 151 | + body: Bytes, |
| 152 | + api_key: Option<Arc<str>>, |
| 153 | + source: &DatadogAgentSource, |
| 154 | +) -> Result<Vec<Event>, ErrorMessage> { |
| 155 | + let envelope: Vec<LLMObsEnvelopeItem> = serde_json::from_slice::<LLMObsEnvelope>(&body) |
| 156 | + .map_err(|error| { |
| 157 | + emit!(DatadogAgentJsonParseError { error: &error }); |
| 158 | + ErrorMessage::new( |
| 159 | + StatusCode::BAD_REQUEST, |
| 160 | + format!("Error parsing JSON: {error:?}"), |
| 161 | + ) |
| 162 | + })? |
| 163 | + .into_items(); |
| 164 | + |
| 165 | + let now = Utc::now(); |
| 166 | + let mut event_bytes_received = JsonSize::zero(); |
| 167 | + |
| 168 | + let events: Vec<Event> = envelope |
| 169 | + .into_iter() |
| 170 | + .flat_map(|item| { |
| 171 | + let tracer_version = item.dd_tracer_version.clone(); |
| 172 | + item.spans.into_iter().map(move |span| { |
| 173 | + let mut log = LogEvent::default(); |
| 174 | + log.insert(event_path!("span_id"), span.span_id); |
| 175 | + log.insert(event_path!("trace_id"), span.trace_id); |
| 176 | + if let Some(v) = span.parent_id { |
| 177 | + log.insert(event_path!("parent_id"), v); |
| 178 | + } |
| 179 | + if let Some(v) = span.name { |
| 180 | + log.insert(event_path!("name"), v); |
| 181 | + } |
| 182 | + if let Some(v) = span.session_id { |
| 183 | + log.insert(event_path!("session_id"), v); |
| 184 | + } |
| 185 | + if let Some(v) = span.service { |
| 186 | + log.insert(event_path!("service"), v); |
| 187 | + } |
| 188 | + if let Some(ns) = span.start_ns { |
| 189 | + log.insert(event_path!("start_ns"), ns); |
| 190 | + source.log_namespace.insert_source_metadata( |
| 191 | + DatadogAgentConfig::NAME, |
| 192 | + &mut log, |
| 193 | + Some(LegacyKey::InsertIfEmpty(path!("timestamp"))), |
| 194 | + path!("timestamp"), |
| 195 | + Utc.timestamp_nanos(ns), |
| 196 | + ); |
| 197 | + } |
| 198 | + if let Some(v) = span.duration { |
| 199 | + log.insert(event_path!("duration"), v); |
| 200 | + } |
| 201 | + if let Some(v) = span.status { |
| 202 | + log.insert(event_path!("status"), v); |
| 203 | + } |
| 204 | + if let Some(v) = span.status_message { |
| 205 | + log.insert(event_path!("status_message"), v); |
| 206 | + } |
| 207 | + if let Some(v) = span.meta { |
| 208 | + log.insert(event_path!("meta"), v); |
| 209 | + } |
| 210 | + if let Some(v) = span.metrics { |
| 211 | + log.insert(event_path!("metrics"), v); |
| 212 | + } |
| 213 | + if !span.tags.is_empty() { |
| 214 | + log.insert(event_path!("tags"), span.tags.clone()); |
| 215 | + } |
| 216 | + if let Some(v) = span.span_links { |
| 217 | + log.insert(event_path!("span_links"), v); |
| 218 | + } |
| 219 | + if let Some(v) = span.config { |
| 220 | + log.insert(event_path!("config"), v); |
| 221 | + } |
| 222 | + if let Some(v) = span.collection_errors { |
| 223 | + log.insert(event_path!("collection_errors"), v); |
| 224 | + } |
| 225 | + |
| 226 | + // Extract ml_app: first check span._dd.ml_app, then fall back to tags array. |
| 227 | + let ml_app = span |
| 228 | + .dd |
| 229 | + .as_ref() |
| 230 | + .and_then(|dd| dd.get("ml_app")) |
| 231 | + .and_then(|v| v.as_str()) |
| 232 | + .map(str::to_owned) |
| 233 | + .or_else(|| { |
| 234 | + span.tags |
| 235 | + .iter() |
| 236 | + .find_map(|tag| tag.strip_prefix("ml_app:").map(str::to_owned)) |
| 237 | + }); |
| 238 | + if let Some(app) = ml_app { |
| 239 | + log.insert(event_path!("ml_app"), app); |
| 240 | + } |
| 241 | + |
| 242 | + if let Some(v) = tracer_version.clone() { |
| 243 | + log.insert(event_path!("_dd", "tracer_version"), v); |
| 244 | + } |
| 245 | + |
| 246 | + Event::Log(log) |
| 247 | + }) |
| 248 | + }) |
| 249 | + .map(|mut event| { |
| 250 | + if let Event::Log(ref mut log) = event { |
| 251 | + event_bytes_received += log.estimated_json_encoded_size_of(); |
| 252 | + source.log_namespace.insert_standard_vector_source_metadata( |
| 253 | + log, |
| 254 | + DatadogAgentConfig::NAME, |
| 255 | + now, |
| 256 | + ); |
| 257 | + if let Some(k) = &api_key { |
| 258 | + log.metadata_mut().set_datadog_api_key(Arc::clone(k)); |
| 259 | + } |
| 260 | + } |
| 261 | + event |
| 262 | + }) |
| 263 | + .collect(); |
| 264 | + |
| 265 | + source |
| 266 | + .events_received |
| 267 | + .emit(CountByteSize(events.len(), event_bytes_received)); |
| 268 | + |
| 269 | + Ok(events) |
| 270 | +} |
0 commit comments