Skip to content

Commit 7225b58

Browse files
jchrostek-ddclaude
andauthored
fix(otlp): accept flexible timestamp formats in JSON payloads (#1108)
## Summary - Adds normalization layer to convert OTLP JSON timestamps to string format before deserialization - Supports string timestamps (proto3 JSON spec), integer timestamps (common in some SDKs), and object timestamps `{"low": n, "high": m}` (from buggy older JS SDKs) - Fixes errors like `"invalid type: integer, expected a string"` seen with serverless-self-monitoring ## Root Cause 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. **serverless-self-monitoring uses outdated SDK versions:** | Handler | Package | Version | |---------|---------|---------| | node18/20/22 | `@opentelemetry/exporter-trace-otlp-http` | **0.36.1** (~Feb 2023) | | python310 | `opentelemetry-exporter-otlp-proto-http` | **1.15.0** (~Feb 2023) | The extension's integration tests use **0.54.2** (current), which properly serializes timestamps as strings, so this issue wasn't caught. **Known upstream issue:** [opentelemetry-rust #1662](open-telemetry/opentelemetry-rust#1662) - The Rust `opentelemetry-proto` crate's serde implementation doesn't accept both string and integer formats for 64-bit integers, as the proto3 JSON spec recommends. **Related JS SDK issue:** [opentelemetry-js #4216](open-telemetry/opentelemetry-js#4216) - Older JS SDK versions sent timestamps as `{"low": n, "high": m}` objects instead of strings. ## Solution Instead of waiting for upstream fixes or requiring all senders to update their SDKs, we normalize JSON timestamps before deserialization: 1. Parse JSON into `serde_json::Value` 2. Recursively find timestamp fields (`startTimeUnixNano`, `endTimeUnixNano`, `timeUnixNano`, `observedTimeUnixNano`) 3. Convert integers → strings, objects `{"low": n, "high": m}` → reconstructed string 4. Deserialize normalized JSON with `opentelemetry-proto` ## Test plan - [x] Unit tests for timestamp normalization (string, integer, object formats) - [x] Unit tests for nested structure normalization - [x] Full test suite passes (506 tests) - [x] Clippy passes with `-D warnings` - [ ] Integration tests with serverless-self-monitoring 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0f77098 commit 7225b58

File tree

1 file changed

+133
-1
lines changed

1 file changed

+133
-1
lines changed

bottlecap/src/otlp/processor.rs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,75 @@
11
use libdd_trace_protobuf::pb::Span as DatadogSpan;
22
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
33
use prost::Message;
4+
use serde_json::Value;
45
use std::{error::Error, sync::Arc};
56

67
use crate::{config::Config, otlp::transform::otel_resource_spans_to_dd_spans};
78

9+
/// Fields that contain 64-bit nanosecond timestamps and need flexible deserialization.
10+
/// Per proto3 JSON spec, these should be string-encoded, but some SDKs send integers
11+
/// or even objects like {"low": ..., "high": ...}.
12+
const TIMESTAMP_FIELDS: &[&str] = &[
13+
"startTimeUnixNano",
14+
"endTimeUnixNano",
15+
"timeUnixNano",
16+
"observedTimeUnixNano",
17+
];
18+
19+
/// Recursively normalizes timestamp fields in a JSON value.
20+
/// Converts integer timestamps to strings and handles the {"low": ..., "high": ...}
21+
/// object format from older/buggy OpenTelemetry JS SDKs.
22+
fn normalize_timestamps(value: &mut Value) {
23+
match value {
24+
Value::Object(map) => {
25+
for (key, val) in map.iter_mut() {
26+
if TIMESTAMP_FIELDS.contains(&key.as_str()) {
27+
normalize_timestamp_value(val);
28+
} else {
29+
normalize_timestamps(val);
30+
}
31+
}
32+
}
33+
Value::Array(arr) => {
34+
for item in arr.iter_mut() {
35+
normalize_timestamps(item);
36+
}
37+
}
38+
_ => {}
39+
}
40+
}
41+
42+
/// Normalizes a single timestamp value to a string.
43+
/// Handles:
44+
/// - String: already correct, leave as-is
45+
/// - Integer: convert to string
46+
/// - Object {"low": n, "high": m}: reconstruct 64-bit value and convert to string
47+
fn normalize_timestamp_value(value: &mut Value) {
48+
match value {
49+
Value::Number(n) => {
50+
// Integer timestamp - convert to string
51+
if let Some(i) = n.as_u64() {
52+
*value = Value::String(i.to_string());
53+
} else if let Some(i) = n.as_i64() {
54+
*value = Value::String(i.to_string());
55+
}
56+
}
57+
Value::Object(map) => {
58+
// Handle {"low": n, "high": m} format from buggy JS SDKs
59+
// This represents a 64-bit integer split into two 32-bit parts
60+
let low_val = map.get("low").and_then(Value::as_u64);
61+
let high_val = map.get("high").and_then(Value::as_u64);
62+
if let (Some(low), Some(high)) = (low_val, high_val) {
63+
// Reconstruct the 64-bit value: high << 32 | low
64+
let timestamp = (high << 32) | (low & 0xFFFF_FFFF);
65+
*value = Value::String(timestamp.to_string());
66+
}
67+
}
68+
// String or other types: nothing to do
69+
_ => {}
70+
}
71+
}
72+
873
#[derive(Debug, Clone, Copy, PartialEq)]
974
pub enum OtlpEncoding {
1075
Protobuf,
@@ -46,7 +111,11 @@ impl Processor {
46111
encoding: OtlpEncoding,
47112
) -> Result<Vec<Vec<DatadogSpan>>, Box<dyn Error>> {
48113
let request = match encoding {
49-
OtlpEncoding::Json => serde_json::from_slice::<ExportTraceServiceRequest>(body)?,
114+
OtlpEncoding::Json => {
115+
let mut json_value: Value = serde_json::from_slice(body)?;
116+
normalize_timestamps(&mut json_value);
117+
serde_json::from_value::<ExportTraceServiceRequest>(json_value)?
118+
}
50119
OtlpEncoding::Protobuf => ExportTraceServiceRequest::decode(body)?,
51120
};
52121

@@ -61,3 +130,66 @@ impl Processor {
61130
Ok(spans)
62131
}
63132
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use super::*;
137+
use serde_json::json;
138+
139+
#[test]
140+
fn test_integer_timestamp_converted_to_string() {
141+
let mut value = json!({"startTimeUnixNano": 1_581_452_772_000_000_321_u64});
142+
normalize_timestamps(&mut value);
143+
assert_eq!(value["startTimeUnixNano"], json!("1581452772000000321"));
144+
}
145+
146+
#[test]
147+
fn test_split_object_timestamp_reconstructed() {
148+
// Some old JS SDKs send 64-bit ints as {"low": u32, "high": u32}
149+
let mut value =
150+
json!({"startTimeUnixNano": {"low": 1_029_784_000_u64, "high": 395_146_000_u64}});
151+
normalize_timestamps(&mut value);
152+
let expected = (395_146_000_u64 << 32) | 1_029_784_000_u64;
153+
assert_eq!(value["startTimeUnixNano"], json!(expected.to_string()));
154+
}
155+
156+
#[test]
157+
fn test_non_timestamp_integers_unchanged() {
158+
// Verify we only convert timestamp fields, not all integers
159+
let mut value = json!({
160+
"kind": 1,
161+
"droppedAttributesCount": 5,
162+
"attributes": [{"value": {"intValue": 42}}],
163+
"startTimeUnixNano": 12345_u64
164+
});
165+
normalize_timestamps(&mut value);
166+
167+
// These should remain as integers
168+
assert_eq!(value["kind"], json!(1));
169+
assert_eq!(value["droppedAttributesCount"], json!(5));
170+
assert_eq!(value["attributes"][0]["value"]["intValue"], json!(42));
171+
// Only this should be converted
172+
assert_eq!(value["startTimeUnixNano"], json!("12345"));
173+
}
174+
175+
#[test]
176+
fn test_nested_event_timestamps_normalized() {
177+
let mut value = json!({
178+
"resourceSpans": [{
179+
"scopeSpans": [{
180+
"spans": [{
181+
"startTimeUnixNano": 100_u64,
182+
"endTimeUnixNano": "200",
183+
"events": [{"timeUnixNano": 300_u64}]
184+
}]
185+
}]
186+
}]
187+
});
188+
normalize_timestamps(&mut value);
189+
190+
let span = &value["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
191+
assert_eq!(span["startTimeUnixNano"], json!("100"));
192+
assert_eq!(span["endTimeUnixNano"], json!("200")); // Already string
193+
assert_eq!(span["events"][0]["timeUnixNano"], json!("300"));
194+
}
195+
}

0 commit comments

Comments
 (0)