1use opentelemetry_proto::tonic::collector::logs::v1::{
20 ExportLogsPartialSuccess, ExportLogsServiceRequest, ExportLogsServiceResponse,
21};
22use opentelemetry_proto::tonic::collector::metrics::v1::{
23 ExportMetricsPartialSuccess, ExportMetricsServiceRequest, ExportMetricsServiceResponse,
24};
25use opentelemetry_proto::tonic::collector::trace::v1::{
26 ExportTracePartialSuccess, ExportTraceServiceRequest, ExportTraceServiceResponse,
27};
28use opentelemetry_proto::tonic::common::v1::{any_value, AnyValue, KeyValue};
29use opentelemetry_proto::tonic::metrics::v1::metric::Data;
30use prost::Message;
31use serde_json::{json, Map, Value};
32use std::io::Read;
33
34pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
39
40pub const MAX_NAMES: usize = 10;
42pub const MAX_LOG_BODIES: usize = 5;
43pub const MAX_ATTRIBUTES: usize = 20;
44pub const MAX_SERVICES: usize = 5;
45const NAME_BYTES: usize = 100;
46const VALUE_BYTES: usize = 200;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Signal {
51 Traces,
52 Metrics,
53 Logs,
54}
55
56impl Signal {
57 pub fn from_path(path: &str) -> Option<Signal> {
58 match path {
59 "/v1/traces" => Some(Signal::Traces),
60 "/v1/metrics" => Some(Signal::Metrics),
61 "/v1/logs" => Some(Signal::Logs),
62 _ => None,
63 }
64 }
65
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Signal::Traces => "traces",
69 Signal::Metrics => "metrics",
70 Signal::Logs => "logs",
71 }
72 }
73
74 pub fn items(self) -> &'static str {
76 match self {
77 Signal::Traces => "spans",
78 Signal::Metrics => "data points",
79 Signal::Logs => "log records",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Encoding {
87 Protobuf,
88 Json,
89}
90
91impl Encoding {
92 pub fn from_content_type(value: &str) -> Option<Encoding> {
94 match value
95 .split(';')
96 .next()
97 .unwrap_or("")
98 .trim()
99 .to_ascii_lowercase()
100 .as_str()
101 {
102 "application/x-protobuf" => Some(Encoding::Protobuf),
103 "application/json" => Some(Encoding::Json),
104 _ => None,
105 }
106 }
107
108 pub fn content_type(self) -> &'static str {
109 match self {
110 Encoding::Protobuf => "application/x-protobuf",
111 Encoding::Json => "application/json",
112 }
113 }
114
115 pub fn as_str(self) -> &'static str {
116 match self {
117 Encoding::Protobuf => "protobuf",
118 Encoding::Json => "json",
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum BodyError {
126 TooLarge,
128 BadGzip,
130}
131
132pub fn gunzip_bounded(body: &[u8], limit: usize) -> Result<Vec<u8>, BodyError> {
135 let mut out = Vec::new();
136 let mut decoder = flate2::read::MultiGzDecoder::new(body).take(limit as u64 + 1);
137 decoder
138 .read_to_end(&mut out)
139 .map_err(|_| BodyError::BadGzip)?;
140 if out.len() > limit {
141 return Err(BodyError::TooLarge);
142 }
143 Ok(out)
144}
145
146#[derive(Debug, Clone, Default, PartialEq)]
148pub struct Summary {
149 pub resource_count: usize,
150 pub services: Vec<String>,
151 pub resource_attributes: Map<String, Value>,
152 pub item_count: usize,
154 pub metric_count: usize,
156 pub error_count: usize,
158 pub names: Vec<String>,
160}
161
162impl Summary {
163 fn add_resource(&mut self, attributes: Vec<(String, String)>) {
164 self.resource_count += 1;
165 for (key, value) in &attributes {
166 if key == "service.name"
167 && !self.services.contains(value)
168 && self.services.len() < MAX_SERVICES
169 {
170 self.services.push(value.clone());
171 }
172 }
173 if self.resource_count == 1 {
174 for (key, value) in attributes.into_iter().take(MAX_ATTRIBUTES) {
175 self.resource_attributes.insert(
176 crate::utils::truncate_for_llm(&key, NAME_BYTES),
177 json!(value),
178 );
179 }
180 }
181 }
182
183 fn add_name(&mut self, name: &str, max: usize, bytes: usize) {
184 if self.names.len() < max {
185 self.names.push(crate::utils::truncate_for_llm(
186 &crate::utils::sanitize::line_field(name),
187 bytes,
188 ));
189 }
190 }
191
192 pub fn to_event(
194 &self,
195 signal: Signal,
196 encoding: Encoding,
197 compressed: bool,
198 body_bytes: usize,
199 ) -> Value {
200 let mut data = json!({
201 "signal": signal.as_str(),
202 "encoding": encoding.as_str(),
203 "compressed": compressed,
204 "body_bytes": body_bytes,
205 "resource_count": self.resource_count,
206 "service_name": self.services.first().cloned().unwrap_or_default(),
207 "resource_attributes": Value::Object(self.resource_attributes.clone()),
208 });
209 if self.services.len() > 1 {
210 data["service_names"] = json!(self.services);
211 }
212 match signal {
213 Signal::Traces => {
214 data["span_count"] = json!(self.item_count);
215 data["error_span_count"] = json!(self.error_count);
216 data["span_names"] = json!(self.names);
217 }
218 Signal::Metrics => {
219 data["metric_count"] = json!(self.metric_count);
220 data["data_point_count"] = json!(self.item_count);
221 data["metric_names"] = json!(self.names);
222 }
223 Signal::Logs => {
224 data["log_record_count"] = json!(self.item_count);
225 data["error_log_count"] = json!(self.error_count);
226 data["log_bodies"] = json!(self.names);
227 }
228 }
229 data
230 }
231}
232
233fn any_value_text(value: Option<&AnyValue>) -> String {
236 match value.and_then(|v| v.value.as_ref()) {
237 None => String::new(),
238 Some(any_value::Value::StringValue(s)) => s.clone(),
239 Some(any_value::Value::BoolValue(b)) => b.to_string(),
240 Some(any_value::Value::IntValue(i)) => i.to_string(),
241 Some(any_value::Value::DoubleValue(d)) => d.to_string(),
242 Some(any_value::Value::ArrayValue(a)) => format!("<array of {}>", a.values.len()),
243 Some(any_value::Value::KvlistValue(k)) => format!("<map of {}>", k.values.len()),
244 Some(any_value::Value::BytesValue(b)) => format!("<{} bytes>", b.len()),
245 }
246}
247
248fn attributes(list: &[KeyValue]) -> Vec<(String, String)> {
249 list.iter()
250 .map(|kv| {
251 (
252 kv.key.clone(),
253 crate::utils::truncate_for_llm(
254 &crate::utils::sanitize::line_field(&any_value_text(kv.value.as_ref())),
255 VALUE_BYTES,
256 ),
257 )
258 })
259 .collect()
260}
261
262pub fn summarize_protobuf(signal: Signal, body: &[u8]) -> Result<Summary, String> {
264 let mut summary = Summary::default();
265 match signal {
266 Signal::Traces => {
267 let request = ExportTraceServiceRequest::decode(body).map_err(|e| e.to_string())?;
268 for rs in &request.resource_spans {
269 summary.add_resource(
270 rs.resource
271 .as_ref()
272 .map(|r| attributes(&r.attributes))
273 .unwrap_or_default(),
274 );
275 for ss in &rs.scope_spans {
276 for span in &ss.spans {
277 summary.item_count += 1;
278 if span.status.as_ref().is_some_and(|s| s.code == 2) {
279 summary.error_count += 1;
280 }
281 summary.add_name(&span.name, MAX_NAMES, NAME_BYTES);
282 }
283 }
284 }
285 }
286 Signal::Metrics => {
287 let request = ExportMetricsServiceRequest::decode(body).map_err(|e| e.to_string())?;
288 for rm in &request.resource_metrics {
289 summary.add_resource(
290 rm.resource
291 .as_ref()
292 .map(|r| attributes(&r.attributes))
293 .unwrap_or_default(),
294 );
295 for sm in &rm.scope_metrics {
296 for metric in &sm.metrics {
297 summary.metric_count += 1;
298 summary.item_count += match &metric.data {
299 Some(Data::Gauge(g)) => g.data_points.len(),
300 Some(Data::Sum(s)) => s.data_points.len(),
301 Some(Data::Histogram(h)) => h.data_points.len(),
302 Some(Data::ExponentialHistogram(h)) => h.data_points.len(),
303 Some(Data::Summary(s)) => s.data_points.len(),
304 None => 0,
305 };
306 summary.add_name(&metric.name, MAX_NAMES, NAME_BYTES);
307 }
308 }
309 }
310 }
311 Signal::Logs => {
312 let request = ExportLogsServiceRequest::decode(body).map_err(|e| e.to_string())?;
313 for rl in &request.resource_logs {
314 summary.add_resource(
315 rl.resource
316 .as_ref()
317 .map(|r| attributes(&r.attributes))
318 .unwrap_or_default(),
319 );
320 for sl in &rl.scope_logs {
321 for record in &sl.log_records {
322 summary.item_count += 1;
323 if record.severity_number >= 17 {
324 summary.error_count += 1;
325 }
326 summary.add_name(
327 &any_value_text(record.body.as_ref()),
328 MAX_LOG_BODIES,
329 VALUE_BYTES,
330 );
331 }
332 }
333 }
334 }
335 }
336 Ok(summary)
337}
338
339fn field<'a>(obj: &'a Value, camel: &str, snake: &str) -> Option<&'a Value> {
341 obj.get(camel).or_else(|| obj.get(snake))
342}
343
344fn list<'a>(obj: &'a Value, camel: &str, snake: &str) -> Result<&'a [Value], String> {
346 match field(obj, camel, snake) {
347 None | Some(Value::Null) => Ok(&[]),
348 Some(Value::Array(a)) => Ok(a),
349 Some(_) => Err(format!("{camel} is not an array")),
350 }
351}
352
353fn object<'a>(value: &'a Value, what: &str) -> Result<&'a Value, String> {
354 if value.is_object() {
355 Ok(value)
356 } else {
357 Err(format!("{what} is not an object"))
358 }
359}
360
361fn json_any_value_text(value: Option<&Value>) -> String {
363 let Some(value) = value.and_then(Value::as_object) else {
364 return String::new();
365 };
366 let scalar = |v: &Value| match v {
367 Value::String(s) => s.clone(),
368 other => other.to_string(),
369 };
370 if let Some(v) = value
371 .get("stringValue")
372 .or_else(|| value.get("string_value"))
373 {
374 return scalar(v);
375 }
376 for key in [
377 "boolValue",
378 "bool_value",
379 "intValue",
380 "int_value",
381 "doubleValue",
382 "double_value",
383 ] {
384 if let Some(v) = value.get(key) {
385 return scalar(v);
386 }
387 }
388 let len = |v: &Value| {
389 v.get("values")
390 .and_then(Value::as_array)
391 .map(Vec::len)
392 .unwrap_or(0)
393 };
394 if let Some(v) = value.get("arrayValue").or_else(|| value.get("array_value")) {
395 return format!("<array of {}>", len(v));
396 }
397 if let Some(v) = value
398 .get("kvlistValue")
399 .or_else(|| value.get("kvlist_value"))
400 {
401 return format!("<map of {}>", len(v));
402 }
403 if let Some(Value::String(b)) = value.get("bytesValue").or_else(|| value.get("bytes_value")) {
404 return format!("<{} bytes>", b.trim_end_matches('=').len() * 3 / 4);
406 }
407 String::new()
408}
409
410fn json_attributes(resource: Option<&Value>) -> Result<Vec<(String, String)>, String> {
411 let Some(resource) = resource.filter(|r| !r.is_null()) else {
412 return Ok(Vec::new());
413 };
414 let resource = object(resource, "resource")?;
415 list(resource, "attributes", "attributes")?
416 .iter()
417 .map(|kv| {
418 let kv = object(kv, "attribute")?;
419 let key = kv
420 .get("key")
421 .and_then(Value::as_str)
422 .unwrap_or("")
423 .to_string();
424 let value = crate::utils::truncate_for_llm(
425 &crate::utils::sanitize::line_field(&json_any_value_text(kv.get("value"))),
426 VALUE_BYTES,
427 );
428 Ok((key, value))
429 })
430 .collect()
431}
432
433fn json_number(value: Option<&Value>) -> Option<i64> {
434 match value? {
435 Value::Number(n) => n.as_i64(),
436 Value::String(s) => s.parse().ok(),
437 _ => None,
438 }
439}
440
441pub fn summarize_json(signal: Signal, body: &[u8]) -> Result<Summary, String> {
443 let root: Value = serde_json::from_slice(body).map_err(|e| e.to_string())?;
444 let root = object(&root, "the request")?;
445 let mut summary = Summary::default();
446 match signal {
447 Signal::Traces => {
448 for rs in list(root, "resourceSpans", "resource_spans")? {
449 let rs = object(rs, "resourceSpans entry")?;
450 summary.add_resource(json_attributes(rs.get("resource"))?);
451 for ss in list(rs, "scopeSpans", "scope_spans")? {
452 for span in list(object(ss, "scopeSpans entry")?, "spans", "spans")? {
453 let span = object(span, "span")?;
454 summary.item_count += 1;
455 let code = span.get("status").and_then(|s| s.get("code"));
456 if json_number(code) == Some(2)
457 || code.and_then(Value::as_str) == Some("STATUS_CODE_ERROR")
458 {
459 summary.error_count += 1;
460 }
461 let name = span.get("name").and_then(Value::as_str).unwrap_or("");
462 summary.add_name(name, MAX_NAMES, NAME_BYTES);
463 }
464 }
465 }
466 }
467 Signal::Metrics => {
468 for rm in list(root, "resourceMetrics", "resource_metrics")? {
469 let rm = object(rm, "resourceMetrics entry")?;
470 summary.add_resource(json_attributes(rm.get("resource"))?);
471 for sm in list(rm, "scopeMetrics", "scope_metrics")? {
472 for metric in list(object(sm, "scopeMetrics entry")?, "metrics", "metrics")? {
473 let metric = object(metric, "metric")?;
474 summary.metric_count += 1;
475 for (camel, snake) in [
476 ("gauge", "gauge"),
477 ("sum", "sum"),
478 ("histogram", "histogram"),
479 ("exponentialHistogram", "exponential_histogram"),
480 ("summary", "summary"),
481 ] {
482 if let Some(data) = field(metric, camel, snake).filter(|d| !d.is_null())
483 {
484 summary.item_count +=
485 list(object(data, camel)?, "dataPoints", "data_points")?.len();
486 }
487 }
488 let name = metric.get("name").and_then(Value::as_str).unwrap_or("");
489 summary.add_name(name, MAX_NAMES, NAME_BYTES);
490 }
491 }
492 }
493 }
494 Signal::Logs => {
495 for rl in list(root, "resourceLogs", "resource_logs")? {
496 let rl = object(rl, "resourceLogs entry")?;
497 summary.add_resource(json_attributes(rl.get("resource"))?);
498 for sl in list(rl, "scopeLogs", "scope_logs")? {
499 for record in list(object(sl, "scopeLogs entry")?, "logRecords", "log_records")?
500 {
501 let record = object(record, "log record")?;
502 summary.item_count += 1;
503 if json_number(field(record, "severityNumber", "severity_number"))
504 .is_some_and(|n| n >= 17)
505 {
506 summary.error_count += 1;
507 }
508 summary.add_name(
509 &json_any_value_text(record.get("body")),
510 MAX_LOG_BODIES,
511 VALUE_BYTES,
512 );
513 }
514 }
515 }
516 }
517 }
518 Ok(summary)
519}
520
521pub fn summarize(signal: Signal, encoding: Encoding, body: &[u8]) -> Result<Summary, String> {
523 match encoding {
524 Encoding::Protobuf => summarize_protobuf(signal, body),
525 Encoding::Json => summarize_json(signal, body),
526 }
527}
528
529pub fn export_response(
532 signal: Signal,
533 encoding: Encoding,
534 partial: Option<(i64, &str)>,
535) -> Vec<u8> {
536 match encoding {
537 Encoding::Protobuf => match signal {
538 Signal::Traces => ExportTraceServiceResponse {
539 partial_success: partial.map(|(n, m)| ExportTracePartialSuccess {
540 rejected_spans: n,
541 error_message: m.to_string(),
542 }),
543 }
544 .encode_to_vec(),
545 Signal::Metrics => ExportMetricsServiceResponse {
546 partial_success: partial.map(|(n, m)| ExportMetricsPartialSuccess {
547 rejected_data_points: n,
548 error_message: m.to_string(),
549 }),
550 }
551 .encode_to_vec(),
552 Signal::Logs => ExportLogsServiceResponse {
553 partial_success: partial.map(|(n, m)| ExportLogsPartialSuccess {
554 rejected_log_records: n,
555 error_message: m.to_string(),
556 }),
557 }
558 .encode_to_vec(),
559 },
560 Encoding::Json => {
561 let body = match partial {
562 None => json!({}),
563 Some((n, message)) => {
564 let key = match signal {
565 Signal::Traces => "rejectedSpans",
566 Signal::Metrics => "rejectedDataPoints",
567 Signal::Logs => "rejectedLogRecords",
568 };
569 json!({"partialSuccess": {key: n.to_string(), "errorMessage": message}})
571 }
572 };
573 body.to_string().into_bytes()
574 }
575 }
576}
577
578#[derive(Clone, PartialEq, prost::Message)]
580pub struct RpcStatus {
581 #[prost(int32, tag = "1")]
582 pub code: i32,
583 #[prost(string, tag = "2")]
584 pub message: String,
585}
586
587pub fn grpc_code(http_status: u16) -> i32 {
589 match http_status {
590 400 => 3, 401 => 16, 403 => 7, 404 => 5, 413 | 429 => 8, 502 | 503 => 14, 504 => 4, _ => 13, }
599}
600
601pub const REJECT_STATUSES: &[u16] = &[400, 401, 403, 413, 429, 500, 502, 503, 504];
603
604pub fn retryable(http_status: u16) -> bool {
606 matches!(http_status, 429 | 502 | 503 | 504)
607}
608
609pub fn status_body(encoding: Encoding, http_status: u16, message: &str) -> Vec<u8> {
611 let code = grpc_code(http_status);
612 match encoding {
613 Encoding::Protobuf => RpcStatus {
614 code,
615 message: message.to_string(),
616 }
617 .encode_to_vec(),
618 Encoding::Json => json!({"code": code, "message": message})
619 .to_string()
620 .into_bytes(),
621 }
622}