From 24795e9fb7130762c85061c0a03f410e31a70aab Mon Sep 17 00:00:00 2001 From: John Chrostek Date: Tue, 17 Mar 2026 09:50:29 -0400 Subject: [PATCH 1/5] fix(otlp): accept flexible timestamp formats in JSON payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opentelemetry-proto crate's serde deserializer only accepts string-encoded 64-bit timestamps (per proto3 JSON spec), but some OpenTelemetry SDKs send timestamps as integers or objects. This change adds a normalization layer that converts timestamps to strings before deserialization, supporting: - Strings (proto3 JSON spec compliant) - unchanged - Integers (common in some SDKs) - converted to strings - Objects {"low": n, "high": m} (buggy older JS SDKs) - reconstructed and converted Fixes errors like: - "invalid type: integer `[timestamp]`, expected a string" - DecodeError when JSON payloads parsed as protobuf 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bottlecap/src/otlp/processor.rs | 156 +++++++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/bottlecap/src/otlp/processor.rs b/bottlecap/src/otlp/processor.rs index 2a76bf9ef..a5eda49e7 100644 --- a/bottlecap/src/otlp/processor.rs +++ b/bottlecap/src/otlp/processor.rs @@ -1,10 +1,75 @@ use libdd_trace_protobuf::pb::Span as DatadogSpan; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use prost::Message; +use serde_json::Value; use std::{error::Error, sync::Arc}; use crate::{config::Config, otlp::transform::otel_resource_spans_to_dd_spans}; +/// Fields that contain 64-bit nanosecond timestamps and need flexible deserialization. +/// Per proto3 JSON spec, these should be string-encoded, but some SDKs send integers +/// or even objects like {"low": ..., "high": ...}. +const TIMESTAMP_FIELDS: &[&str] = &[ + "startTimeUnixNano", + "endTimeUnixNano", + "timeUnixNano", + "observedTimeUnixNano", +]; + +/// Recursively normalizes timestamp fields in a JSON value. +/// Converts integer timestamps to strings and handles the {"low": ..., "high": ...} +/// object format from older/buggy OpenTelemetry JS SDKs. +fn normalize_timestamps(value: &mut Value) { + match value { + Value::Object(map) => { + for (key, val) in map.iter_mut() { + if TIMESTAMP_FIELDS.contains(&key.as_str()) { + normalize_timestamp_value(val); + } else { + normalize_timestamps(val); + } + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + normalize_timestamps(item); + } + } + _ => {} + } +} + +/// Normalizes a single timestamp value to a string. +/// Handles: +/// - String: already correct, leave as-is +/// - Integer: convert to string +/// - Object {"low": n, "high": m}: reconstruct 64-bit value and convert to string +fn normalize_timestamp_value(value: &mut Value) { + match value { + Value::Number(n) => { + // Integer timestamp - convert to string + if let Some(i) = n.as_u64() { + *value = Value::String(i.to_string()); + } else if let Some(i) = n.as_i64() { + *value = Value::String(i.to_string()); + } + } + Value::Object(map) => { + // Handle {"low": n, "high": m} format from buggy JS SDKs + // This represents a 64-bit integer split into two 32-bit parts + let low_val = map.get("low").and_then(Value::as_u64); + let high_val = map.get("high").and_then(Value::as_u64); + if let (Some(low), Some(high)) = (low_val, high_val) { + // Reconstruct the 64-bit value: high << 32 | low + let timestamp = (high << 32) | (low & 0xFFFF_FFFF); + *value = Value::String(timestamp.to_string()); + } + } + // String or other types: nothing to do + _ => {} + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum OtlpEncoding { Protobuf, @@ -46,7 +111,16 @@ impl Processor { encoding: OtlpEncoding, ) -> Result>, Box> { let request = match encoding { - OtlpEncoding::Json => serde_json::from_slice::(body)?, + OtlpEncoding::Json => { + // Parse JSON, normalize timestamp fields, then deserialize. + // This handles various timestamp formats: + // - Strings (proto3 JSON spec compliant) + // - Integers (common in some SDKs) + // - Objects {"low": n, "high": m} (buggy older JS SDKs) + let mut json_value: Value = serde_json::from_slice(body)?; + normalize_timestamps(&mut json_value); + serde_json::from_value::(json_value)? + } OtlpEncoding::Protobuf => ExportTraceServiceRequest::decode(body)?, }; @@ -61,3 +135,83 @@ impl Processor { Ok(spans) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_normalize_timestamp_string_unchanged() { + let mut value = json!("1581452772000000321"); + normalize_timestamp_value(&mut value); + assert_eq!(value, json!("1581452772000000321")); + } + + #[test] + fn test_normalize_timestamp_integer_to_string() { + let mut value = json!(1_581_452_772_000_000_321_u64); + normalize_timestamp_value(&mut value); + assert_eq!(value, json!("1581452772000000321")); + } + + #[test] + fn test_normalize_timestamp_object_to_string() { + // {"low": 1029784000, "high": 395146000} represents a split 64-bit int + // high << 32 | low = 395146000 << 32 | 1029784000 = 1697551827029784000 + let mut value = json!({"low": 1_029_784_000_u64, "high": 395_146_000_u64}); + normalize_timestamp_value(&mut value); + let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; + assert_eq!(value, json!(expected.to_string())); + } + + #[test] + fn test_normalize_timestamps_nested_structure() { + let mut value = json!({ + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "name": "test-span", + "startTimeUnixNano": 1_581_452_772_000_000_321_u64, + "endTimeUnixNano": "1581452772000000999", + "events": [{ + "timeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64} + }] + }] + }] + }] + }); + + normalize_timestamps(&mut value); + + // Check startTimeUnixNano was converted from integer to string + let start_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["startTimeUnixNano"]; + assert_eq!(start_time, &json!("1581452772000000321")); + + // Check endTimeUnixNano was left as string + let end_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["endTimeUnixNano"]; + assert_eq!(end_time, &json!("1581452772000000999")); + + // Check event timeUnixNano was converted from object to string + let event_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["events"][0]["timeUnixNano"]; + let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; + assert_eq!(event_time, &json!(expected.to_string())); + } + + #[test] + fn test_normalize_timestamps_preserves_other_fields() { + let mut value = json!({ + "name": "test", + "kind": 1, + "attributes": [{"key": "foo", "value": {"intValue": 42}}], + "startTimeUnixNano": 12345_u64 + }); + + normalize_timestamps(&mut value); + + assert_eq!(value["name"], json!("test")); + assert_eq!(value["kind"], json!(1)); + assert_eq!(value["attributes"][0]["value"]["intValue"], json!(42)); + assert_eq!(value["startTimeUnixNano"], json!("12345")); + } +} From 2b37699f11aed1b0a10945a0b26a0a8be1c0f328 Mon Sep 17 00:00:00 2001 From: John Chrostek Date: Tue, 17 Mar 2026 09:52:04 -0400 Subject: [PATCH 2/5] style: fix cargo fmt formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bottlecap/src/otlp/processor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bottlecap/src/otlp/processor.rs b/bottlecap/src/otlp/processor.rs index a5eda49e7..7947bb105 100644 --- a/bottlecap/src/otlp/processor.rs +++ b/bottlecap/src/otlp/processor.rs @@ -185,7 +185,8 @@ mod tests { normalize_timestamps(&mut value); // Check startTimeUnixNano was converted from integer to string - let start_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["startTimeUnixNano"]; + let start_time = + &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["startTimeUnixNano"]; assert_eq!(start_time, &json!("1581452772000000321")); // Check endTimeUnixNano was left as string @@ -193,7 +194,8 @@ mod tests { assert_eq!(end_time, &json!("1581452772000000999")); // Check event timeUnixNano was converted from object to string - let event_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["events"][0]["timeUnixNano"]; + let event_time = + &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["events"][0]["timeUnixNano"]; let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; assert_eq!(event_time, &json!(expected.to_string())); } From 15502f810a3edcf7efa05c1ed4ab488fbeb7c78b Mon Sep 17 00:00:00 2001 From: John Chrostek Date: Tue, 17 Mar 2026 10:15:00 -0400 Subject: [PATCH 3/5] Remove redundant inline comment --- bottlecap/src/otlp/processor.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bottlecap/src/otlp/processor.rs b/bottlecap/src/otlp/processor.rs index 7947bb105..6eb20e139 100644 --- a/bottlecap/src/otlp/processor.rs +++ b/bottlecap/src/otlp/processor.rs @@ -112,11 +112,6 @@ impl Processor { ) -> Result>, Box> { let request = match encoding { OtlpEncoding::Json => { - // Parse JSON, normalize timestamp fields, then deserialize. - // This handles various timestamp formats: - // - Strings (proto3 JSON spec compliant) - // - Integers (common in some SDKs) - // - Objects {"low": n, "high": m} (buggy older JS SDKs) let mut json_value: Value = serde_json::from_slice(body)?; normalize_timestamps(&mut json_value); serde_json::from_value::(json_value)? From 4d7b58fef25b1354aae1b1303157f0fff1100587 Mon Sep 17 00:00:00 2001 From: John Chrostek Date: Tue, 17 Mar 2026 10:37:26 -0400 Subject: [PATCH 4/5] Improve unit tests for timestamp normalization --- bottlecap/src/otlp/processor.rs | 88 +++++++++++++-------------------- 1 file changed, 34 insertions(+), 54 deletions(-) diff --git a/bottlecap/src/otlp/processor.rs b/bottlecap/src/otlp/processor.rs index 6eb20e139..6a390b9ed 100644 --- a/bottlecap/src/otlp/processor.rs +++ b/bottlecap/src/otlp/processor.rs @@ -137,78 +137,58 @@ mod tests { use serde_json::json; #[test] - fn test_normalize_timestamp_string_unchanged() { - let mut value = json!("1581452772000000321"); - normalize_timestamp_value(&mut value); - assert_eq!(value, json!("1581452772000000321")); + fn test_integer_timestamp_converted_to_string() { + let mut value = json!({"startTimeUnixNano": 1_581_452_772_000_000_321_u64}); + normalize_timestamps(&mut value); + assert_eq!(value["startTimeUnixNano"], json!("1581452772000000321")); } #[test] - fn test_normalize_timestamp_integer_to_string() { - let mut value = json!(1_581_452_772_000_000_321_u64); - normalize_timestamp_value(&mut value); - assert_eq!(value, json!("1581452772000000321")); + fn test_split_object_timestamp_reconstructed() { + // Some old JS SDKs send 64-bit ints as {"low": u32, "high": u32} + let mut value = json!({"startTimeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64}}); + normalize_timestamps(&mut value); + let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; + assert_eq!(value["startTimeUnixNano"], json!(expected.to_string())); } #[test] - fn test_normalize_timestamp_object_to_string() { - // {"low": 1029784000, "high": 395146000} represents a split 64-bit int - // high << 32 | low = 395146000 << 32 | 1029784000 = 1697551827029784000 - let mut value = json!({"low": 1_029_784_000_u64, "high": 395_146_000_u64}); - normalize_timestamp_value(&mut value); - let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; - assert_eq!(value, json!(expected.to_string())); + fn test_non_timestamp_integers_unchanged() { + // Verify we only convert timestamp fields, not all integers + let mut value = json!({ + "kind": 1, + "droppedAttributesCount": 5, + "attributes": [{"value": {"intValue": 42}}], + "startTimeUnixNano": 12345_u64 + }); + normalize_timestamps(&mut value); + + // These should remain as integers + assert_eq!(value["kind"], json!(1)); + assert_eq!(value["droppedAttributesCount"], json!(5)); + assert_eq!(value["attributes"][0]["value"]["intValue"], json!(42)); + // Only this should be converted + assert_eq!(value["startTimeUnixNano"], json!("12345")); } #[test] - fn test_normalize_timestamps_nested_structure() { + fn test_nested_event_timestamps_normalized() { let mut value = json!({ "resourceSpans": [{ "scopeSpans": [{ "spans": [{ - "name": "test-span", - "startTimeUnixNano": 1_581_452_772_000_000_321_u64, - "endTimeUnixNano": "1581452772000000999", - "events": [{ - "timeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64} - }] + "startTimeUnixNano": 100_u64, + "endTimeUnixNano": "200", + "events": [{"timeUnixNano": 300_u64}] }] }] }] }); - - normalize_timestamps(&mut value); - - // Check startTimeUnixNano was converted from integer to string - let start_time = - &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["startTimeUnixNano"]; - assert_eq!(start_time, &json!("1581452772000000321")); - - // Check endTimeUnixNano was left as string - let end_time = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["endTimeUnixNano"]; - assert_eq!(end_time, &json!("1581452772000000999")); - - // Check event timeUnixNano was converted from object to string - let event_time = - &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["events"][0]["timeUnixNano"]; - let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; - assert_eq!(event_time, &json!(expected.to_string())); - } - - #[test] - fn test_normalize_timestamps_preserves_other_fields() { - let mut value = json!({ - "name": "test", - "kind": 1, - "attributes": [{"key": "foo", "value": {"intValue": 42}}], - "startTimeUnixNano": 12345_u64 - }); - normalize_timestamps(&mut value); - assert_eq!(value["name"], json!("test")); - assert_eq!(value["kind"], json!(1)); - assert_eq!(value["attributes"][0]["value"]["intValue"], json!(42)); - assert_eq!(value["startTimeUnixNano"], json!("12345")); + let span = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0]; + assert_eq!(span["startTimeUnixNano"], json!("100")); + assert_eq!(span["endTimeUnixNano"], json!("200")); // Already string + assert_eq!(span["events"][0]["timeUnixNano"], json!("300")); } } From 2c7ead2356833861ddda83c2e6b0f4650408f294 Mon Sep 17 00:00:00 2001 From: John Chrostek Date: Tue, 17 Mar 2026 10:41:09 -0400 Subject: [PATCH 5/5] style: cargo fmt --- bottlecap/src/otlp/processor.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bottlecap/src/otlp/processor.rs b/bottlecap/src/otlp/processor.rs index 6a390b9ed..cacf5202f 100644 --- a/bottlecap/src/otlp/processor.rs +++ b/bottlecap/src/otlp/processor.rs @@ -146,7 +146,8 @@ mod tests { #[test] fn test_split_object_timestamp_reconstructed() { // Some old JS SDKs send 64-bit ints as {"low": u32, "high": u32} - let mut value = json!({"startTimeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64}}); + let mut value = + json!({"startTimeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64}}); normalize_timestamps(&mut value); let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64; assert_eq!(value["startTimeUnixNano"], json!(expected.to_string()));