1use 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}