From 06bc8d9d7b1ed843a8000cd0c402b40c8a5cb1c2 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Wed, 29 Jul 2026 01:41:54 +0200 Subject: [PATCH 1/2] feat(ingester): derive event_name from the legacy event.name attribute (RFC 0043 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The materialisation boundary now falls back to a non-empty string event.name attribute when the wire event_name is unset-or-empty (proto3 empty == absent, the existing RFC0003.9 narrowing). The attribute is preserved verbatim — derivation, never correction — and both wire encodings funnel through this one seam, so they cannot diverge. Covers RFC0043.1/.2/.3/.4/.7; .5 (query end-to-end) and .6 (observable event-keyed templating) land in slice 2. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qtny6z6cA74xPZa4qRhk4F Signed-off-by: Jens Holdgaard Pedersen --- .../src/receiver/materialize.rs | 25 ++- crates/ourios-ingester/tests/it/main.rs | 1 + .../tests/it/rfc0043_event_name_derivation.rs | 161 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs diff --git a/crates/ourios-ingester/src/receiver/materialize.rs b/crates/ourios-ingester/src/receiver/materialize.rs index 9d4699ce0..5d53f0d71 100644 --- a/crates/ourios-ingester/src/receiver/materialize.rs +++ b/crates/ourios-ingester/src/receiver/materialize.rs @@ -13,6 +13,7 @@ //! `materialize_record` takes the resolved `tenant_id` as a parameter, //! so the fan-out slice supplies it. +use opentelemetry_proto::tonic::common::v1::any_value::Value; use opentelemetry_proto::tonic::common::v1::{InstrumentationScope, KeyValue}; use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs}; use ourios_core::otlp::{Body, OtlpLogRecord}; @@ -36,6 +37,11 @@ pub fn materialize_record( scope_schema_url: &str, tenant_id: TenantId, ) -> OtlpLogRecord { + // RFC 0043: the wire field wins when non-empty; otherwise derive from + // the legacy `event.name` attribute. Computed before the struct + // literal because the literal moves `record.attributes`. + let event_name = + nonempty(record.event_name).or_else(|| event_name_from_attributes(&record.attributes)); OtlpLogRecord { tenant_id, // Event time: `0` = unknown per the OTLP spec, kept as `0` @@ -66,7 +72,7 @@ pub fn materialize_record( trace_id: fixed_len(&record.trace_id), span_id: fixed_len(&record.span_id), flags: record.flags, - event_name: nonempty(record.event_name), + event_name, // `string_value` → mining path (`Body::String`), every other // variant → `Body::Structured` verbatim (RFC0003.7/.8); `None` // when the wire delivered no body. @@ -143,6 +149,23 @@ fn nonempty(s: String) -> Option { (!s.is_empty()).then_some(s) } +/// RFC 0043 §3 — derive `event_name` from the legacy `event.name` +/// attribute: a non-empty string value derives; anything else (absent, +/// non-string, empty) derives nothing. The attribute itself stays in +/// `attributes` verbatim — derivation, never correction — so the +/// fidelity rule holds by construction. Both wire encodings funnel +/// through this one seam (JSON decodes into the same proto structs), +/// which is what keeps them from diverging (RFC0043.3). +fn event_name_from_attributes(attributes: &[KeyValue]) -> Option { + attributes + .iter() + .find(|kv| kv.key == "event.name") + .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? { + Value::StringValue(s) if !s.is_empty() => Some(s.clone()), + _ => None, + }) +} + /// A proto `bytes` id (`trace_id` / `span_id`): exactly `N` bytes → /// `Some`; empty (absent) or any other length (malformed) → `None`. fn fixed_len(bytes: &[u8]) -> Option<[u8; N]> { diff --git a/crates/ourios-ingester/tests/it/main.rs b/crates/ourios-ingester/tests/it/main.rs index 9329ddfa1..d198b8c8a 100644 --- a/crates/ourios-ingester/tests/it/main.rs +++ b/crates/ourios-ingester/tests/it/main.rs @@ -48,3 +48,4 @@ mod rfc0035_2_sweep_crash; mod rfc0035_5_on_disk_equivalence; mod rfc0035_f2_miner_panic_salvage; mod rfc0038_2_ingest_batch_span; +mod rfc0043_event_name_derivation; diff --git a/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs b/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs new file mode 100644 index 000000000..204f577f6 --- /dev/null +++ b/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs @@ -0,0 +1,161 @@ +//! RFC0043.1/.2/.3/.4/.7 — `event_name` derived from the legacy +//! `event.name` attribute at the materialisation boundary. +//! +//! The wire field wins when non-empty; an unset-or-empty field derives +//! from a non-empty string `event.name` attribute; the attribute is +//! preserved verbatim either way (derivation, never correction); empty +//! is never a value in either encoding, and the protobuf and RFC0003.6 +//! JSON paths cannot diverge because both funnel through +//! `materialize_record`. + +use opentelemetry_proto::tonic::common::v1::any_value::Value; +use opentelemetry_proto::tonic::common::v1::{AnyValue, KeyValue}; +use opentelemetry_proto::tonic::logs::v1::LogRecord; +use ourios_core::otlp::OtlpLogRecord; +use ourios_core::tenant::TenantId; +use ourios_ingester::receiver::{decode_json, materialize_record}; + +fn attr(key: &str, value: Option) -> KeyValue { + KeyValue { + key: key.to_owned(), + value: Some(AnyValue { value }), + ..Default::default() + } +} + +fn materialize(record: LogRecord) -> OtlpLogRecord { + materialize_record(record, &[], "", None, "", TenantId::new("tenant-a")) +} + +/// Scenario RFC0043.1 — the wire field wins. +/// See `docs/rfcs/0043-event-name-attribute-ingest.md` §5. +#[test] +fn rfc0043_1_wire_field_wins_and_attribute_is_preserved() { + let record = LogRecord { + event_name: "from.the.wire".to_owned(), + attributes: vec![attr( + "event.name", + Some(Value::StringValue("from.the.attribute".to_owned())), + )], + ..Default::default() + }; + let materialized = materialize(record); + assert_eq!( + materialized.event_name.as_deref(), + Some("from.the.wire"), + "a non-empty wire event_name is never overridden", + ); + assert_eq!( + materialized.attributes[0], + attr( + "event.name", + Some(Value::StringValue("from.the.attribute".to_owned())) + ), + "the mismatching attribute is source telemetry, preserved unflagged", + ); +} + +/// Scenario RFC0043.2 — derivation from the attribute, attribute intact. +#[test] +fn rfc0043_2_derives_from_attribute_and_preserves_it() { + let record = LogRecord { + attributes: vec![attr( + "event.name", + Some(Value::StringValue("claude_code.api_request".to_owned())), + )], + ..Default::default() + }; + let materialized = materialize(record); + assert_eq!( + materialized.event_name.as_deref(), + Some("claude_code.api_request"), + ); + assert_eq!( + materialized.attributes[0], + attr( + "event.name", + Some(Value::StringValue("claude_code.api_request".to_owned())) + ), + "derivation, not a move: the attribute stays byte-identical", + ); +} + +/// Scenario RFC0043.3 — both encodings derive identically. The JSON +/// payload spells the record per RFC0003.6 (lowerCamelCase, no wire +/// `eventName`); decoding yields the same proto structs the protobuf +/// path produces, so one materialisation covers both. +#[test] +fn rfc0043_3_json_and_protobuf_paths_agree() { + let json = br#"{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"severityNumber":9,"body":{"stringValue":"api_request"},"attributes":[{"key":"event.name","value":{"stringValue":"claude_code.api_request"}}]}]}]}]}"#; + let (decoded, _) = decode_json(json).expect("RFC0003.6 payload decodes"); + let record = decoded.resource_logs[0].scope_logs[0].log_records[0].clone(); + let via_json = materialize(record); + + let via_proto = materialize(LogRecord { + severity_number: 9, + body: Some(AnyValue { + value: Some(Value::StringValue("api_request".to_owned())), + }), + attributes: vec![attr( + "event.name", + Some(Value::StringValue("claude_code.api_request".to_owned())), + )], + ..Default::default() + }); + + assert_eq!( + via_json.event_name.as_deref(), + Some("claude_code.api_request") + ); + assert_eq!(via_json.event_name, via_proto.event_name); + assert_eq!(via_json.attributes, via_proto.attributes); +} + +/// Scenario RFC0043.4 — a non-string `event.name` derives nothing. +#[test] +fn rfc0043_4_non_string_attribute_derives_nothing() { + let record = LogRecord { + attributes: vec![attr("event.name", Some(Value::IntValue(7)))], + ..Default::default() + }; + let materialized = materialize(record); + assert_eq!(materialized.event_name, None); + assert_eq!( + materialized.attributes[0], + attr("event.name", Some(Value::IntValue(7))), + "the non-string attribute is preserved verbatim", + ); +} + +/// Scenario RFC0043.7 — empty is never a value, in either position: +/// (a) an empty wire field is unset (proto3 cannot distinguish absent +/// from empty), so the attribute derives; (b) an empty-string attribute +/// derives nothing; (c) a JSON `null` value derives nothing. +#[test] +fn rfc0043_7_empty_is_never_a_value() { + // (a) empty wire field + attribute → derives. + let a = materialize(LogRecord { + event_name: String::new(), + attributes: vec![attr( + "event.name", + Some(Value::StringValue("derived.anyway".to_owned())), + )], + ..Default::default() + }); + assert_eq!(a.event_name.as_deref(), Some("derived.anyway")); + + // (b) empty-string attribute → nothing. + let b = materialize(LogRecord { + attributes: vec![attr("event.name", Some(Value::StringValue(String::new())))], + ..Default::default() + }); + assert_eq!(b.event_name, None); + + // (c) JSON null value (an unset AnyValue) → nothing, via the real + // JSON decoder so the encoding-equivalence claim is exercised. + let json = br#"{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"attributes":[{"key":"event.name","value":null}]}]}]}]}"#; + let (decoded, _) = decode_json(json).expect("null attribute value decodes"); + let record = decoded.resource_logs[0].scope_logs[0].log_records[0].clone(); + let c = materialize(record); + assert_eq!(c.event_name, None); +} From f151535ec14422ef22d5480d06d6f4e34080a5f8 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Wed, 29 Jul 2026 01:52:47 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test:=20add=20the=20=C2=A75=20criterion=20r?= =?UTF-8?q?eferences=20to=20every=20scenario=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qtny6z6cA74xPZa4qRhk4F Signed-off-by: Jens Holdgaard Pedersen --- .../ourios-ingester/tests/it/rfc0043_event_name_derivation.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs b/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs index 204f577f6..c323c7f3b 100644 --- a/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs +++ b/crates/ourios-ingester/tests/it/rfc0043_event_name_derivation.rs @@ -56,6 +56,7 @@ fn rfc0043_1_wire_field_wins_and_attribute_is_preserved() { } /// Scenario RFC0043.2 — derivation from the attribute, attribute intact. +/// See `docs/rfcs/0043-event-name-attribute-ingest.md` §5. #[test] fn rfc0043_2_derives_from_attribute_and_preserves_it() { let record = LogRecord { @@ -84,6 +85,7 @@ fn rfc0043_2_derives_from_attribute_and_preserves_it() { /// payload spells the record per RFC0003.6 (lowerCamelCase, no wire /// `eventName`); decoding yields the same proto structs the protobuf /// path produces, so one materialisation covers both. +/// See `docs/rfcs/0043-event-name-attribute-ingest.md` §5. #[test] fn rfc0043_3_json_and_protobuf_paths_agree() { let json = br#"{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"severityNumber":9,"body":{"stringValue":"api_request"},"attributes":[{"key":"event.name","value":{"stringValue":"claude_code.api_request"}}]}]}]}]}"#; @@ -112,6 +114,7 @@ fn rfc0043_3_json_and_protobuf_paths_agree() { } /// Scenario RFC0043.4 — a non-string `event.name` derives nothing. +/// See `docs/rfcs/0043-event-name-attribute-ingest.md` §5. #[test] fn rfc0043_4_non_string_attribute_derives_nothing() { let record = LogRecord { @@ -131,6 +134,7 @@ fn rfc0043_4_non_string_attribute_derives_nothing() { /// (a) an empty wire field is unset (proto3 cannot distinguish absent /// from empty), so the attribute derives; (b) an empty-string attribute /// derives nothing; (c) a JSON `null` value derives nothing. +/// See `docs/rfcs/0043-event-name-attribute-ingest.md` §5. #[test] fn rfc0043_7_empty_is_never_a_value() { // (a) empty wire field + attribute → derives.