Skip to main content

document_svg/document/
otlp.rs

1//! Bounded OpenTelemetry Protocol (OTLP) JSON previews.
2//!
3//! OTLP JSON payloads contain traces, metrics, logs and profiles with nested
4//! attributes and bodies. This adapter renders signal/scope counts and optional
5//! service metadata only; attribute values, log bodies, exemplars, links and
6//! endpoints remain inert and are never exported, fetched or executed.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_OTLP_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_OTLP_DEPTH: usize = 100;
18const MAX_OTLP_VALUES: usize = 300_000;
19const MAX_OTLP_ROWS: usize = 200_000;
20const MAX_OTLP_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_OTLP_DISPLAY_BYTES: usize = 256;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24    let text = String::from_utf8_lossy(prefix);
25    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26    if !trimmed.starts_with('{') {
27        return false;
28    }
29    if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(trimmed) {
30        return SIGNAL_KEYS.iter().any(|key| {
31            object
32                .get(*key)
33                .and_then(Value::as_array)
34                .is_some_and(|items| !items.is_empty())
35        });
36    }
37    SIGNAL_KEYS
38        .iter()
39        .any(|key| text.contains(&format!("\"{key}\"")))
40}
41
42const SIGNAL_KEYS: &[&str] = &[
43    "resourceSpans",
44    "resourceMetrics",
45    "resourceLogs",
46    "resourceProfiles",
47];
48
49struct OtlpPageSink<'a> {
50    inner: &'a mut dyn PageConsumer,
51    warnings: &'a [String],
52}
53
54impl PageConsumer for OtlpPageSink<'_> {
55    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
56        page.source_format = "otlp-json".into();
57        if page.title.is_empty() {
58            page.title = "OpenTelemetry OTLP JSON".into();
59        }
60        page.description =
61            "OTLP JSON signal and scope metadata is rendered inertly; attribute values, log bodies and telemetry links are not displayed or exported".into();
62        for warning in self.warnings {
63            page.warn(warning.clone());
64        }
65        self.inner.consume(page)
66    }
67}
68
69pub(crate) fn convert(
70    path: &Path,
71    options: &ConvertOptions,
72    sink: &mut dyn PageConsumer,
73) -> Result<Vec<String>> {
74    let bytes = read_limited_file(
75        path,
76        options.max_input_bytes.min(MAX_OTLP_BYTES),
77        "OTLP JSON input",
78    )?;
79    let text = String::from_utf8(bytes)
80        .map_err(|error| Error::InvalidInput(format!("OTLP JSON must be UTF-8 JSON: {error}")))?;
81    let (table, metadata, warnings) = parse(&text)?;
82    let blocks = vec![
83        HtmlBlock::Heading {
84            level: 1,
85            text: "OpenTelemetry OTLP JSON".into(),
86        },
87        HtmlBlock::Paragraph { text: metadata },
88        HtmlBlock::Table(table),
89    ];
90    let mut page_sink = OtlpPageSink {
91        inner: sink,
92        warnings: &warnings,
93    };
94    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
95    Ok(warnings)
96}
97
98#[derive(Default)]
99struct Summary {
100    rows: Vec<Vec<String>>,
101    resources: usize,
102    spans: usize,
103    metrics: usize,
104    data_points: usize,
105    logs: usize,
106    profiles: usize,
107    attributes: usize,
108}
109
110fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
111    if text.len() as u64 > MAX_OTLP_BYTES {
112        return Err(Error::LimitExceeded(format!(
113            "OTLP JSON exceeds {MAX_OTLP_BYTES} bytes"
114        )));
115    }
116    preflight_depth(text)?;
117    let value: Value = serde_json::from_str(text)
118        .map_err(|error| Error::InvalidInput(format!("invalid OTLP JSON: {error}")))?;
119    let mut values = 0usize;
120    count_values(&value, 0, &mut values)?;
121    let root = value
122        .as_object()
123        .ok_or_else(|| Error::InvalidInput("OTLP JSON root must be an object".into()))?;
124    let mut summary = Summary::default();
125    let mut warnings = Vec::new();
126    if let Some(resource_spans) = signal_array(root, "resourceSpans")? {
127        for resource_span in resource_spans {
128            summarize_traces(resource_span, &mut summary)?;
129        }
130    }
131    if let Some(resource_metrics) = signal_array(root, "resourceMetrics")? {
132        for resource_metric in resource_metrics {
133            summarize_metrics(resource_metric, &mut summary)?;
134        }
135    }
136    if let Some(resource_logs) = signal_array(root, "resourceLogs")? {
137        for resource_log in resource_logs {
138            summarize_logs(resource_log, &mut summary)?;
139        }
140    }
141    if let Some(resource_profiles) = signal_array(root, "resourceProfiles")? {
142        for resource_profile in resource_profiles {
143            summarize_profiles(resource_profile, &mut summary)?;
144        }
145    }
146    if summary.rows.is_empty() {
147        return Err(Error::InvalidInput(
148            "OTLP JSON requires a non-empty resourceSpans, resourceMetrics, resourceLogs or resourceProfiles array".into(),
149        ));
150    }
151    let metadata = format!(
152        "Rows: {}\nResources: {}\nSpans: {}\nMetrics: {}\nData points: {}\nLog records: {}\nProfiles: {}\nAttribute keys: {}",
153        summary.rows.len(),
154        summary.resources,
155        summary.spans,
156        summary.metrics,
157        summary.data_points,
158        summary.logs,
159        summary.profiles,
160        summary.attributes
161    );
162    warnings.push("OTLP attribute values, log bodies, trace/span IDs, exemplars, links, schema URLs and instrumentation payloads are omitted; no collector, endpoint, exporter or network operation runs".into());
163    warnings.push("OTLP JSON uses the protobuf JSON camelCase field names and bounded traversal; numeric timestamps and values are counted but not interpreted".into());
164    Ok((
165        TableData {
166            headers: vec![
167                "Sig".into(),
168                "Svc".into(),
169                "Scope".into(),
170                "N".into(),
171                "Structure".into(),
172            ],
173            rows: summary.rows,
174            alignments: vec![TableAlign::Left; 5],
175            raw_source: String::new(),
176        },
177        metadata,
178        warnings,
179    ))
180}
181
182fn signal_array<'a>(
183    root: &'a serde_json::Map<String, Value>,
184    key: &str,
185) -> Result<Option<&'a [Value]>> {
186    root.get(key)
187        .map(|value| {
188            value
189                .as_array()
190                .map(Vec::as_slice)
191                .ok_or_else(|| Error::InvalidInput(format!("OTLP {key} must be an array")))
192        })
193        .transpose()
194}
195
196fn summarize_traces(resource_span: &Value, summary: &mut Summary) -> Result<()> {
197    let object = resource_span
198        .as_object()
199        .ok_or_else(|| Error::InvalidInput("OTLP resourceSpans item must be an object".into()))?;
200    summary.resources = summary.resources.saturating_add(1);
201    summary.attributes = summary
202        .attributes
203        .saturating_add(attribute_count(object.get("resource")));
204    let service = service_name(object.get("resource"));
205    let scopes = object
206        .get("scopeSpans")
207        .and_then(Value::as_array)
208        .ok_or_else(|| {
209            Error::InvalidInput("OTLP resourceSpans item requires scopeSpans array".into())
210        })?;
211    for scope in scopes {
212        let scope_object = scope
213            .as_object()
214            .ok_or_else(|| Error::InvalidInput("OTLP scopeSpans item must be an object".into()))?;
215        let spans = scope_object
216            .get("spans")
217            .and_then(Value::as_array)
218            .ok_or_else(|| {
219                Error::InvalidInput("OTLP scopeSpans item requires spans array".into())
220            })?;
221        summary.spans = summary.spans.saturating_add(spans.len());
222        let events = spans
223            .iter()
224            .map(|span| {
225                span.get("events")
226                    .and_then(Value::as_array)
227                    .map_or(0, Vec::len)
228            })
229            .sum::<usize>();
230        let links = spans
231            .iter()
232            .map(|span| {
233                span.get("links")
234                    .and_then(Value::as_array)
235                    .map_or(0, Vec::len)
236            })
237            .sum::<usize>();
238        summary.attributes = summary
239            .attributes
240            .saturating_add(attribute_count(scope_object.get("scope")));
241        push_row(
242            summary,
243            vec![
244                "traces".into(),
245                service.clone(),
246                scope_name(scope_object.get("scope")),
247                spans.len().to_string(),
248                format!("events {events} · links {links} · attrs omitted"),
249            ],
250        )?;
251    }
252    Ok(())
253}
254
255fn summarize_metrics(resource_metric: &Value, summary: &mut Summary) -> Result<()> {
256    let object = resource_metric
257        .as_object()
258        .ok_or_else(|| Error::InvalidInput("OTLP resourceMetrics item must be an object".into()))?;
259    summary.resources = summary.resources.saturating_add(1);
260    summary.attributes = summary
261        .attributes
262        .saturating_add(attribute_count(object.get("resource")));
263    let service = service_name(object.get("resource"));
264    let scopes = object
265        .get("scopeMetrics")
266        .and_then(Value::as_array)
267        .ok_or_else(|| {
268            Error::InvalidInput("OTLP resourceMetrics item requires scopeMetrics array".into())
269        })?;
270    for scope in scopes {
271        let scope_object = scope.as_object().ok_or_else(|| {
272            Error::InvalidInput("OTLP scopeMetrics item must be an object".into())
273        })?;
274        let metrics = scope_object
275            .get("metrics")
276            .and_then(Value::as_array)
277            .ok_or_else(|| {
278                Error::InvalidInput("OTLP scopeMetrics item requires metrics array".into())
279            })?;
280        let data_points = metrics.iter().map(metric_data_points).sum::<usize>();
281        summary.metrics = summary.metrics.saturating_add(metrics.len());
282        summary.data_points = summary.data_points.saturating_add(data_points);
283        summary.attributes = summary
284            .attributes
285            .saturating_add(attribute_count(scope_object.get("scope")));
286        push_row(
287            summary,
288            vec![
289                "metrics".into(),
290                service.clone(),
291                scope_name(scope_object.get("scope")),
292                metrics.len().to_string(),
293                format!("data points {data_points} · attrs omitted"),
294            ],
295        )?;
296    }
297    Ok(())
298}
299
300fn summarize_logs(resource_log: &Value, summary: &mut Summary) -> Result<()> {
301    let object = resource_log
302        .as_object()
303        .ok_or_else(|| Error::InvalidInput("OTLP resourceLogs item must be an object".into()))?;
304    summary.resources = summary.resources.saturating_add(1);
305    summary.attributes = summary
306        .attributes
307        .saturating_add(attribute_count(object.get("resource")));
308    let service = service_name(object.get("resource"));
309    let scopes = object
310        .get("scopeLogs")
311        .and_then(Value::as_array)
312        .ok_or_else(|| {
313            Error::InvalidInput("OTLP resourceLogs item requires scopeLogs array".into())
314        })?;
315    for scope in scopes {
316        let scope_object = scope
317            .as_object()
318            .ok_or_else(|| Error::InvalidInput("OTLP scopeLogs item must be an object".into()))?;
319        let records = scope_object
320            .get("logRecords")
321            .and_then(Value::as_array)
322            .ok_or_else(|| {
323                Error::InvalidInput("OTLP scopeLogs item requires logRecords array".into())
324            })?;
325        summary.logs = summary.logs.saturating_add(records.len());
326        summary.attributes = summary
327            .attributes
328            .saturating_add(attribute_count(scope_object.get("scope")));
329        push_row(
330            summary,
331            vec![
332                "logs".into(),
333                service.clone(),
334                scope_name(scope_object.get("scope")),
335                records.len().to_string(),
336                "body omitted · attrs omitted".into(),
337            ],
338        )?;
339    }
340    Ok(())
341}
342
343fn summarize_profiles(resource_profile: &Value, summary: &mut Summary) -> Result<()> {
344    let object = resource_profile.as_object().ok_or_else(|| {
345        Error::InvalidInput("OTLP resourceProfiles item must be an object".into())
346    })?;
347    summary.resources = summary.resources.saturating_add(1);
348    let service = service_name(object.get("resource"));
349    let scopes = object
350        .get("scopeProfiles")
351        .and_then(Value::as_array)
352        .ok_or_else(|| {
353            Error::InvalidInput("OTLP resourceProfiles item requires scopeProfiles array".into())
354        })?;
355    for scope in scopes {
356        let scope_object = scope.as_object().ok_or_else(|| {
357            Error::InvalidInput("OTLP scopeProfiles item must be an object".into())
358        })?;
359        let profiles = scope_object
360            .get("profiles")
361            .and_then(Value::as_array)
362            .ok_or_else(|| {
363                Error::InvalidInput("OTLP scopeProfiles item requires profiles array".into())
364            })?;
365        summary.profiles = summary.profiles.saturating_add(profiles.len());
366        push_row(
367            summary,
368            vec![
369                "profiles".into(),
370                service.clone(),
371                scope_name(scope_object.get("scope")),
372                profiles.len().to_string(),
373                "profile payload omitted".into(),
374            ],
375        )?;
376    }
377    Ok(())
378}
379
380fn metric_data_points(metric: &Value) -> usize {
381    let Some(object) = metric.as_object() else {
382        return 0;
383    };
384    [
385        "gauge",
386        "sum",
387        "histogram",
388        "exponentialHistogram",
389        "summary",
390    ]
391    .iter()
392    .map(|key| {
393        object
394            .get(*key)
395            .and_then(Value::as_object)
396            .and_then(|value| value.get("dataPoints"))
397            .and_then(Value::as_array)
398            .map_or(0, Vec::len)
399    })
400    .sum()
401}
402
403fn attribute_count(value: Option<&Value>) -> usize {
404    value
405        .and_then(Value::as_object)
406        .and_then(|object| object.get("attributes"))
407        .and_then(Value::as_array)
408        .map_or(0, Vec::len)
409}
410
411fn service_name(value: Option<&Value>) -> String {
412    let Some(attributes) = value
413        .and_then(Value::as_object)
414        .and_then(|object| object.get("attributes"))
415        .and_then(Value::as_array)
416    else {
417        return "—".into();
418    };
419    for attribute in attributes {
420        let Some(attribute) = attribute.as_object() else {
421            continue;
422        };
423        if attribute.get("key").and_then(Value::as_str) != Some("service.name") {
424            continue;
425        }
426        if let Some(service) = attribute
427            .get("value")
428            .and_then(Value::as_object)
429            .and_then(|value| value.get("stringValue"))
430            .and_then(Value::as_str)
431        {
432            return truncate(service);
433        }
434        return "(set)".into();
435    }
436    "—".into()
437}
438
439fn scope_name(value: Option<&Value>) -> String {
440    let Some(object) = value.and_then(Value::as_object) else {
441        return "—".into();
442    };
443    let name = object.get("name").and_then(Value::as_str).unwrap_or("—");
444    let version = object.get("version").and_then(Value::as_str).unwrap_or("");
445    if version.is_empty() {
446        truncate(name)
447    } else {
448        truncate(&format!("{name} {version}"))
449    }
450}
451
452fn push_row(summary: &mut Summary, row: Vec<String>) -> Result<()> {
453    if summary.rows.len() >= MAX_OTLP_ROWS {
454        return Err(Error::LimitExceeded(format!(
455            "OTLP rows exceed {MAX_OTLP_ROWS}"
456        )));
457    }
458    summary.rows.push(row);
459    Ok(())
460}
461
462fn truncate(value: &str) -> String {
463    if value.len() <= MAX_OTLP_DISPLAY_BYTES {
464        return value.to_owned();
465    }
466    let mut end = MAX_OTLP_DISPLAY_BYTES;
467    while !value.is_char_boundary(end) {
468        end -= 1;
469    }
470    format!("{}…", &value[..end])
471}
472
473fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
474    if depth > MAX_OTLP_DEPTH {
475        return Err(Error::LimitExceeded(format!(
476            "OTLP JSON nesting exceeds {MAX_OTLP_DEPTH} levels"
477        )));
478    }
479    *count = count.saturating_add(1);
480    if *count > MAX_OTLP_VALUES {
481        return Err(Error::LimitExceeded(format!(
482            "OTLP JSON contains more than {MAX_OTLP_VALUES} values"
483        )));
484    }
485    match value {
486        Value::Array(values) => {
487            for item in values {
488                count_values(item, depth + 1, count)?;
489            }
490        }
491        Value::Object(map) => {
492            for item in map.values() {
493                count_values(item, depth + 1, count)?;
494            }
495        }
496        Value::String(value) if value.len() > MAX_OTLP_STRING_BYTES => {
497            return Err(Error::LimitExceeded(format!(
498                "OTLP JSON string exceeds {MAX_OTLP_STRING_BYTES} bytes"
499            )));
500        }
501        _ => {}
502    }
503    Ok(())
504}
505
506fn preflight_depth(text: &str) -> Result<()> {
507    let mut depth = 0usize;
508    let mut quoted = false;
509    let mut escaped = false;
510    for byte in text.bytes() {
511        if quoted {
512            if escaped {
513                escaped = false;
514            } else if byte == b'\\' {
515                escaped = true;
516            } else if byte == b'"' {
517                quoted = false;
518            }
519            continue;
520        }
521        match byte {
522            b'"' => quoted = true,
523            b'{' | b'[' => {
524                depth += 1;
525                if depth > MAX_OTLP_DEPTH {
526                    return Err(Error::LimitExceeded(format!(
527                        "OTLP JSON nesting exceeds {MAX_OTLP_DEPTH} levels"
528                    )));
529                }
530            }
531            b'}' | b']' => depth = depth.saturating_sub(1),
532            _ => {}
533        }
534    }
535    Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn recognizes_otlp_signals() {
544        assert!(looks_like_prefix(
545            br#"{"resourceSpans":[{"scopeSpans":[]}] }"#
546        ));
547        assert!(looks_like_prefix(
548            br#"{"resourceMetrics":[{"scopeMetrics":[]}] }"#
549        ));
550        assert!(!looks_like_prefix(br#"{"resourceSpans":[]}"#));
551    }
552
553    #[test]
554    fn summarizes_signals_without_attribute_or_body_values() {
555        let (table, metadata, warnings) = parse(
556            r#"{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"checkout"}},{"key":"token","value":{"stringValue":"secret"}}]},"scopeSpans":[{"scope":{"name":"demo","version":"1.0"},"spans":[{"events":[{"name":"private event"}],"links":[{}]}]}]}],"resourceLogs":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"logs"}}]},"scopeLogs":[{"scope":{"name":"logger"},"logRecords":[{"body":{"stringValue":"very-secret"}}]}]}]}"#,
557        )
558        .unwrap();
559        assert_eq!(table.rows.len(), 2);
560        assert!(metadata.contains("Spans: 1"));
561        assert!(metadata.contains("Log records: 1"));
562        assert!(table.rows.iter().flatten().any(|value| value == "checkout"));
563        assert!(
564            !table
565                .rows
566                .iter()
567                .flatten()
568                .any(|value| value.contains("secret"))
569        );
570        assert!(
571            warnings
572                .iter()
573                .any(|warning| warning.contains("attribute values"))
574        );
575    }
576}