Skip to content

Commit ac5b40b

Browse files
fix: bugs around ingestion and OTel JSON (#1788)
* fix: bugs around ingestion and OTel JSON 1. remove restriction of dataset field count limit, default still remains 1000 2. infer timestamp to false in stats calculation 3. keep RFC 2822 value as Utf8 because arrow JSON decoder rejects and fails with error - `Request: Failed to create recordbatch due to JsonError("whilst decoding field 'zeek.sip.date': failed to parse \"Tue, 22 Sep 2026 08:04:47 GMT\" as Timestamp(ms): Parser error: Error parsing timestamp from 'Tue, 22 Sep 2026 08:04:47 GMT': error parsing date")` 4. try deserialize an OTLP/JSON payload and optionally normalise by omitting empty `values` fields in `ArrayValue` and `KeyValuelist` 5. gate this behaviour via env `P_NORMALISE_OTEL_PAYLOAD` default to false * size>0 check
1 parent ea3178f commit ac5b40b

6 files changed

Lines changed: 191 additions & 21 deletions

File tree

‎src/cli.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,14 @@ pub struct Options {
618618
)]
619619
pub max_event_payload_size: usize,
620620

621+
#[arg(
622+
long,
623+
env = "P_NORMALISE_OTEL_PAYLOAD",
624+
default_value = "false",
625+
help = "Normalise missing OTLP collection values and retry when JSON deserialization fails"
626+
)]
627+
pub normalise_otel_payload: bool,
628+
621629
// the duration during which local sync should be completed
622630
#[arg(
623631
long,

‎src/event/format/mod.rs‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use std::{
2424
};
2525

2626
use anyhow::{Error as AnyError, anyhow};
27+
use arrow::compute::kernels::cast_utils::string_to_datetime;
2728
use arrow_array::RecordBatch;
2829
use arrow_schema::{DataType, Field, Schema, TimeUnit};
2930
use chrono::{DateTime, Utc};
@@ -58,6 +59,15 @@ static TIME_FIELD_NAME_PARTS: [&str; 11] = [
5859
"dt",
5960
];
6061

62+
/// Returns whether Arrow's JSON timestamp decoder can parse this value.
63+
///
64+
/// Timestamp inference must use the same parser as decoding. Chrono accepts
65+
/// formats such as RFC 2822 that Arrow rejects, which otherwise produces a
66+
/// schema Arrow cannot use to decode the original JSON value.
67+
fn is_arrow_timestamp(value: &str) -> bool {
68+
string_to_datetime(&Utc, value).is_ok()
69+
}
70+
6171
type EventSchema = Vec<Arc<Field>>;
6272

6373
/// Normalizes a field name by replacing leading '@' with '_'.
@@ -388,7 +398,8 @@ pub fn override_data_type(
388398
.any(|part| field_name.to_lowercase().contains(part))
389399
&& field.data_type() == &DataType::Utf8
390400
&& (DateTime::parse_from_rfc3339(s).is_ok()
391-
|| DateTime::parse_from_rfc2822(s).is_ok()) =>
401+
|| DateTime::parse_from_rfc2822(s).is_ok())
402+
&& is_arrow_timestamp(s) =>
392403
{
393404
Field::new(
394405
field_name,
@@ -456,12 +467,13 @@ fn value_compatible_with_type(
456467
// Timestamps can accept strings that parse as datetime or numbers
457468
match value {
458469
Value::String(s) => {
459-
chrono::DateTime::parse_from_rfc3339(s).is_ok()
470+
(chrono::DateTime::parse_from_rfc3339(s).is_ok()
460471
|| chrono::DateTime::parse_from_rfc2822(s).is_ok()
461472
|| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f").is_ok()
462473
|| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").is_ok()
463474
|| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f").is_ok()
464-
|| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").is_ok()
475+
|| chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").is_ok())
476+
&& is_arrow_timestamp(s)
465477
}
466478
// Arrow JSON decoder with coerce_primitive(false) cannot decode
467479
// a JSON number into a Timestamp field — it expects a string.
@@ -941,6 +953,31 @@ mod tests {
941953
assert!(conflicts.is_empty());
942954
}
943955

956+
#[test]
957+
fn test_detect_schema_conflicts_rfc2822_timestamp() {
958+
let mut existing_schema: HashMap<String, Arc<Field>> = HashMap::new();
959+
existing_schema.insert(
960+
"date".to_string(),
961+
Arc::new(Field::new(
962+
"date",
963+
DataType::Timestamp(TimeUnit::Millisecond, None),
964+
true,
965+
)),
966+
);
967+
968+
let inferred_schema = Schema::new(vec![Field::new("date", DataType::Utf8, true)]);
969+
let values = vec![json!({"date": "Tue, 22 Sep 2026 08:04:47 GMT"})];
970+
971+
let conflicts = detect_schema_conflicts(
972+
&inferred_schema,
973+
&existing_schema,
974+
&values,
975+
SchemaVersion::V1,
976+
);
977+
978+
assert_eq!(conflicts.get("date"), Some(&"date_utf8".to_string()));
979+
}
980+
944981
#[test]
945982
fn test_detect_schema_conflicts_number_to_utf8() {
946983
// Existing schema has request_body as Utf8
@@ -1062,6 +1099,16 @@ mod tests {
10621099
assert_eq!(updated.field(0).data_type(), &DataType::Utf8);
10631100
}
10641101

1102+
#[test]
1103+
fn override_data_type_keeps_rfc2822_as_utf8() {
1104+
let inferred = Arc::new(Schema::new(vec![Field::new("date", DataType::Utf8, true)]));
1105+
let log = json!({"date": "Tue, 22 Sep 2026 08:04:47 GMT"});
1106+
1107+
let updated = override_data_type(inferred, log, SchemaVersion::V1, true);
1108+
1109+
assert_eq!(updated.field(0).data_type(), &DataType::Utf8);
1110+
}
1111+
10651112
#[test]
10661113
fn override_data_type_still_casts_numbers_when_infer_disabled() {
10671114
// The flag must only affect string-to-timestamp inference, not

‎src/handlers/http/modal/utils/ingest_utils.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ use crate::{
4141
kinesis::{Message, flatten_kinesis_logs},
4242
},
4343
},
44-
otel::{logs::flatten_otel_logs, metrics::flatten_otel_metrics, traces::flatten_otel_traces},
44+
otel::{
45+
logs::flatten_otel_logs, metrics::flatten_otel_metrics, otel_utils::deserialize_otel_json,
46+
traces::flatten_otel_traces,
47+
},
4548
parseable::{DEFAULT_TENANT, PARSEABLE},
4649
storage::StreamType,
4750
utils::{
@@ -223,7 +226,8 @@ pub fn flatten_and_push_logs(
223226
)?;
224227
}
225228
LogSource::OtelLogs => {
226-
let logs: LogsData = serde_json::from_value(json)?;
229+
let logs: LogsData =
230+
deserialize_otel_json(json, PARSEABLE.options.normalise_otel_payload)?;
227231
let records = flatten_otel_logs(&logs, tenant_str);
228232
if !records.is_empty() {
229233
push_logs(
@@ -238,7 +242,8 @@ pub fn flatten_and_push_logs(
238242
}
239243
}
240244
LogSource::OtelTraces => {
241-
let traces: TracesData = serde_json::from_value(json)?;
245+
let traces: TracesData =
246+
deserialize_otel_json(json, PARSEABLE.options.normalise_otel_payload)?;
242247
let records = flatten_otel_traces(&traces, tenant_str);
243248
if !records.is_empty() {
244249
push_logs(
@@ -253,7 +258,8 @@ pub fn flatten_and_push_logs(
253258
}
254259
}
255260
LogSource::OtelMetrics => {
256-
let metrics: MetricsData = serde_json::from_value(json)?;
261+
let metrics: MetricsData =
262+
deserialize_otel_json(json, PARSEABLE.options.normalise_otel_payload)?;
257263
let records = flatten_otel_metrics(metrics, tenant_str);
258264
if !records.is_empty() {
259265
push_logs(

‎src/option.rs‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,14 @@ impl From<Compression> for parquet::basic::Compression {
8787
}
8888

8989
pub mod validation {
90+
use super::{Compression, Mode};
91+
use path_clean::PathClean;
9092
use std::{
9193
env, io,
9294
net::ToSocketAddrs,
9395
path::{Path, PathBuf},
9496
};
9597

96-
use super::{Compression, Mode};
97-
use crate::cli::DATASET_FIELD_COUNT_LIMIT;
98-
use path_clean::PathClean;
99-
10098
pub fn file_path(s: &str) -> Result<PathBuf, String> {
10199
if s.is_empty() {
102100
return Err("empty path".to_owned());
@@ -196,16 +194,15 @@ pub mod validation {
196194
}
197195
}
198196
pub fn validate_dataset_fields_allowed_limit(s: &str) -> Result<usize, String> {
199-
if let Ok(size) = s.parse::<usize>() {
200-
if (1..=DATASET_FIELD_COUNT_LIMIT).contains(&size) {
201-
Ok(size)
202-
} else {
203-
Err(format!(
204-
"Invalid value for P_DATASET_FIELD_COUNT_LIMIT. It should be between 1 and {DATASET_FIELD_COUNT_LIMIT}"
205-
))
206-
}
197+
if let Ok(size) = s.parse::<usize>()
198+
&& size > 0
199+
{
200+
Ok(size)
207201
} else {
208-
Err("Invalid value for P_DATASET_FIELD_COUNT_LIMIT. It should be given as integer value".to_string())
202+
Err(
203+
"Invalid value for P_DATASET_FIELD_COUNT_LIMIT. It should be a positive integer"
204+
.to_string(),
205+
)
209206
}
210207
}
211208

‎src/otel/otel_utils.rs‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,65 @@ use chrono::DateTime;
2020
use opentelemetry_proto::tonic::common::v1::{
2121
AnyValue, ArrayValue, KeyValue, KeyValueList, any_value::Value as OtelValue,
2222
};
23+
use serde::de::DeserializeOwned;
2324
use serde_json::{Map, Value};
2425

26+
/// Deserializes an OTLP/JSON payload and optionally retries after restoring
27+
/// omitted empty `values` fields in `ArrayValue` and `KeyValueList` messages.
28+
pub fn deserialize_otel_json<T>(
29+
mut json: Value,
30+
normalise_on_failure: bool,
31+
) -> Result<T, serde_json::Error>
32+
where
33+
T: DeserializeOwned,
34+
{
35+
if !normalise_on_failure {
36+
return serde_json::from_value(json);
37+
}
38+
39+
match T::deserialize(&json) {
40+
Ok(value) => Ok(value),
41+
Err(original_error) => {
42+
if !normalise_missing_otel_collection_values(&mut json) {
43+
return Err(original_error);
44+
}
45+
46+
serde_json::from_value(json)
47+
}
48+
}
49+
}
50+
51+
fn normalise_missing_otel_collection_values(value: &mut Value) -> bool {
52+
match value {
53+
Value::Object(map) => {
54+
let mut changed = false;
55+
56+
for collection_key in ["arrayValue", "kvlistValue"] {
57+
if let Some(Value::Object(collection)) = map.get_mut(collection_key)
58+
&& !collection.contains_key("values")
59+
{
60+
collection.insert("values".to_string(), Value::Array(Vec::new()));
61+
changed = true;
62+
}
63+
}
64+
65+
for child in map.values_mut() {
66+
changed |= normalise_missing_otel_collection_values(child);
67+
}
68+
69+
changed
70+
}
71+
Value::Array(values) => {
72+
let mut changed = false;
73+
for child in values {
74+
changed |= normalise_missing_otel_collection_values(child);
75+
}
76+
changed
77+
}
78+
_ => false,
79+
}
80+
}
81+
2582
// Value can be one of types - String, Bool, Int, Double, ArrayValue, AnyValue, KeyValueList, Byte
2683
pub fn collect_json_from_value(key: &str, value: OtelValue) -> Map<String, Value> {
2784
let mut value_json: Map<String, Value> = Map::new();
@@ -216,6 +273,7 @@ pub fn convert_epoch_nano_to_timestamp(epoch_ns: i64) -> String {
216273
#[cfg(test)]
217274
mod tests {
218275
use super::*;
276+
use opentelemetry_proto::tonic::logs::v1::LogsData;
219277

220278
#[test]
221279
fn zero_epoch_uses_current_utc_time() {
@@ -237,4 +295,58 @@ mod tests {
237295
"1970-01-01T00:00:01.000000000Z"
238296
);
239297
}
298+
299+
fn logs_with_omitted_empty_collection_values() -> Value {
300+
serde_json::json!({
301+
"resourceLogs": [{
302+
"scopeLogs": [{
303+
"logRecords": [{
304+
"attributes": [
305+
{
306+
"key": "input",
307+
"value": { "kvlistValue": {} }
308+
},
309+
{
310+
"key": "tags",
311+
"value": { "arrayValue": {} }
312+
}
313+
]
314+
}]
315+
}]
316+
}]
317+
})
318+
}
319+
320+
#[test]
321+
fn otel_payload_normalisation_is_disabled_by_default() {
322+
let error =
323+
deserialize_otel_json::<LogsData>(logs_with_omitted_empty_collection_values(), false)
324+
.unwrap_err();
325+
326+
assert!(error.to_string().contains("missing field"));
327+
assert!(error.to_string().contains("values"));
328+
}
329+
330+
#[test]
331+
fn otel_payload_normalisation_retries_failed_deserialization() {
332+
let logs =
333+
deserialize_otel_json::<LogsData>(logs_with_omitted_empty_collection_values(), true)
334+
.unwrap();
335+
336+
let attributes = &logs.resource_logs[0].scope_logs[0].log_records[0].attributes;
337+
assert!(matches!(
338+
attributes[0]
339+
.value
340+
.as_ref()
341+
.and_then(|value| value.value.as_ref()),
342+
Some(OtelValue::KvlistValue(KeyValueList { values })) if values.is_empty()
343+
));
344+
assert!(matches!(
345+
attributes[1]
346+
.value
347+
.as_ref()
348+
.and_then(|value| value.value.as_ref()),
349+
Some(OtelValue::ArrayValue(ArrayValue { values })) if values.is_empty()
350+
));
351+
}
240352
}

‎src/storage/field_stats.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ async fn calculate_field_stats_inner(
203203
&p_custom_fields,
204204
TelemetryType::Logs,
205205
tenant_id,
206-
true,
206+
false,
207207
)?
208208
.process()?;
209209
}

0 commit comments

Comments
 (0)