Skip to main content

geneva_uploader/payload_encoder/
routing.rs

1// Copyright The OpenTelemetry Authors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared event-name routing for logs and spans.
5//!
6//! This module owns the public routing configuration types
7//! ([`LogsEventNameMapping`], [`SpanEventNameMapping`], and their routing keys)
8//! together with both resolution paths: one over the pdata `LogRecordView`
9//! traits (logs) and one over the tonic proto `Span` types (spans). Keeping the
10//! two implementations side by side makes the shared lookup semantics explicit
11//! and reduces the chance of the paths drifting.
12
13use std::borrow::Cow;
14use std::collections::HashMap;
15use tracing::debug;
16
17use opentelemetry_proto::tonic::common::v1::{
18    any_value::Value as ProtoAnyValue, InstrumentationScope, KeyValue,
19};
20use opentelemetry_proto::tonic::resource::v1::Resource;
21use opentelemetry_proto::tonic::trace::v1::Span;
22use otap_df_pdata_views::views::common::{
23    AnyValueView, AttributeView, InstrumentationScopeView, ValueType,
24};
25use otap_df_pdata_views::views::logs::LogRecordView;
26use otap_df_pdata_views::views::resource::ResourceView;
27
28/// Routing key used to resolve per-record destination event names for logs.
29#[derive(Clone, Debug)]
30pub enum LogsEventNameRoutingKey {
31    EventName,
32    ResourceAttribute(String),
33    ScopeAttribute(String),
34    LogRecordAttribute(String),
35}
36
37impl LogsEventNameRoutingKey {
38    /// Attribute/scope key name for attribute-based routing kinds; `None` for `EventName`.
39    fn attribute_key(&self) -> Option<&str> {
40        match self {
41            LogsEventNameRoutingKey::EventName => None,
42            LogsEventNameRoutingKey::ResourceAttribute(key)
43            | LogsEventNameRoutingKey::ScopeAttribute(key)
44            | LogsEventNameRoutingKey::LogRecordAttribute(key) => Some(key.as_str()),
45        }
46    }
47}
48
49/// Optional logs routing config. When configured, each log record can be routed
50/// to a destination event based on `routing_key` and `events` lookup.
51#[derive(Clone, Debug)]
52pub struct LogsEventNameMapping {
53    pub routing_key: LogsEventNameRoutingKey,
54    pub events: HashMap<String, Option<String>>,
55}
56
57impl LogsEventNameMapping {
58    /// Rejects mappings that can never route: empty `events`, blank source keys,
59    /// or a blank attribute routing-key name.
60    pub(crate) fn validate(&self) -> Result<(), String> {
61        validate_event_mapping(
62            "LogsConfig.event_name_mapping",
63            self.routing_key.attribute_key(),
64            &self.events,
65        )
66    }
67}
68
69/// Routing key used to resolve per-record destination event names for spans.
70#[derive(Clone, Debug)]
71pub enum SpanEventNameRoutingKey {
72    ResourceAttribute(String),
73    ScopeAttribute(String),
74    SpanAttribute(String),
75}
76
77impl SpanEventNameRoutingKey {
78    /// Attribute/scope key name for the (always attribute-based) span routing kinds.
79    fn attribute_key(&self) -> Option<&str> {
80        match self {
81            SpanEventNameRoutingKey::ResourceAttribute(key)
82            | SpanEventNameRoutingKey::ScopeAttribute(key)
83            | SpanEventNameRoutingKey::SpanAttribute(key) => Some(key.as_str()),
84        }
85    }
86}
87
88/// Optional span routing config. When configured, each span can be routed
89/// to a destination event based on `routing_key` and `events` lookup.
90#[derive(Clone, Debug)]
91pub struct SpanEventNameMapping {
92    pub routing_key: SpanEventNameRoutingKey,
93    pub events: HashMap<String, Option<String>>,
94}
95
96impl SpanEventNameMapping {
97    /// Rejects mappings that can never route: empty `events`, blank source keys,
98    /// or a blank attribute routing-key name.
99    pub(crate) fn validate(&self) -> Result<(), String> {
100        validate_event_mapping(
101            "TracesConfig.event_name_mapping",
102            self.routing_key.attribute_key(),
103            &self.events,
104        )
105    }
106}
107
108/// Shared validation for logs/spans routing mappings. `routing_key_name` is the
109/// attribute/scope key for attribute-based kinds, or `None` for event-name routing.
110fn validate_event_mapping(
111    ctx: &str,
112    routing_key_name: Option<&str>,
113    events: &HashMap<String, Option<String>>,
114) -> Result<(), String> {
115    if events.is_empty() {
116        return Err(format!(
117            "{ctx}.events must be non-empty when routing is configured"
118        ));
119    }
120    if let Some(name) = routing_key_name {
121        if name.trim().is_empty() {
122            return Err(format!(
123                "{ctx}.routing_key attribute name must not be blank"
124            ));
125        }
126    }
127    if events.keys().any(|key| key.trim().is_empty()) {
128        return Err(format!("{ctx}.events source keys must not be blank"));
129    }
130    Ok(())
131}
132
133fn non_blank_utf8(bytes: &[u8]) -> Option<&str> {
134    let s = std::str::from_utf8(bytes).ok()?;
135    (!s.trim().is_empty()).then_some(s)
136}
137
138pub(crate) fn normalized_event_name(record: &impl LogRecordView) -> Option<&str> {
139    record.event_name().and_then(non_blank_utf8)
140}
141
142/// Reserved routing key that selects the instrumentation scope name.
143pub(crate) const SCOPE_NAME_ROUTING_KEY: &str = "scope.name";
144/// Reserved routing key that selects the instrumentation scope version.
145pub(crate) const SCOPE_VERSION_ROUTING_KEY: &str = "scope.version";
146
147/// Resolves a routing `source_value` against a mapping's `events` table.
148///
149/// Returns:
150/// - `Some(destination)` when the source value has a non-empty destination
151///   (borrowed from the `events` table, so the common lookup allocates nothing),
152/// - `Some(source_value)` when the source value maps to `None`/empty (passthrough),
153/// - `None` when there is no entry for the source value (caller falls back to the
154///   configured default/table name).
155///
156/// Shared by logs and spans routing to keep the lookup semantics identical.
157pub(crate) fn resolve_mapped_destination<'a>(
158    events: &'a HashMap<String, Option<String>>,
159    source_value: &str,
160) -> Option<Cow<'a, str>> {
161    let mapped = events.get(source_value)?;
162    match mapped.as_deref() {
163        Some(destination) if !destination.trim().is_empty() => Some(Cow::Borrowed(destination)),
164        _ => Some(Cow::Owned(source_value.to_string())),
165    }
166}
167
168/// A primitive attribute value eligible for event-name routing. Non-primitive
169/// kinds (bytes/array/kvlist) are not routable.
170pub(crate) enum RoutingPrimitive<'a> {
171    Str(&'a str),
172    Int(i64),
173    Double(f64),
174    Bool(bool),
175}
176
177/// Stringify a routing primitive using the shared routing rules: string values
178/// are treated as absent when blank/whitespace and otherwise passed through;
179/// numeric/bool values are formatted via `to_string`.
180///
181/// Returns a [`Cow`] so string values are borrowed (zero allocation) while only
182/// numeric/bool values, which must be formatted, own a new `String`. Callers on
183/// the proto-span path retain the borrow through the map lookup; the pdata log
184/// path owns via [`Cow::into_owned`] (see `routing_value_from_attributes`).
185///
186/// Kept in one place so the logs path (over pdata-view traits) and the spans
187/// path (over proto types) can't drift on value handling.
188pub(crate) fn stringify_routing_primitive(value: RoutingPrimitive<'_>) -> Option<Cow<'_, str>> {
189    match value {
190        RoutingPrimitive::Str(s) => (!s.trim().is_empty()).then_some(Cow::Borrowed(s)),
191        RoutingPrimitive::Int(v) => Some(Cow::Owned(v.to_string())),
192        RoutingPrimitive::Double(v) => Some(Cow::Owned(v.to_string())),
193        RoutingPrimitive::Bool(v) => Some(Cow::Owned(v.to_string())),
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Log routing (over the pdata `LogRecordView` traits)
199// ---------------------------------------------------------------------------
200
201fn routing_value_from_attributes<'a, A, I>(attributes: I, key: &str) -> Option<String>
202where
203    A: AttributeView + 'a,
204    I: IntoIterator<Item = A>,
205{
206    for attr in attributes {
207        let Ok(attr_key) = std::str::from_utf8(attr.key()) else {
208            continue;
209        };
210        if attr_key != key {
211            continue;
212        }
213        let value = attr.value()?;
214
215        // The pdata view traits tie borrowed string bytes to `value`, a
216        // per-iteration temporary, so the routing value cannot outlive this
217        // function and must be owned here (unlike the proto-span path, which
218        // borrows from the input attributes).
219        return match value.value_type() {
220            ValueType::String => value
221                .as_string()
222                .and_then(|b| std::str::from_utf8(b).ok())
223                .and_then(|s| stringify_routing_primitive(RoutingPrimitive::Str(s)))
224                .map(Cow::into_owned),
225            ValueType::Int64 => value
226                .as_int64()
227                .and_then(|v| stringify_routing_primitive(RoutingPrimitive::Int(v)))
228                .map(Cow::into_owned),
229            ValueType::Double => value
230                .as_double()
231                .and_then(|v| stringify_routing_primitive(RoutingPrimitive::Double(v)))
232                .map(Cow::into_owned),
233            ValueType::Bool => value
234                .as_bool()
235                .and_then(|v| stringify_routing_primitive(RoutingPrimitive::Bool(v)))
236                .map(Cow::into_owned),
237            _ => None,
238        };
239    }
240    None
241}
242
243fn routing_value_from_scope<SV>(scope: &SV, key: &str) -> Option<String>
244where
245    SV: InstrumentationScopeView,
246{
247    match key {
248        SCOPE_NAME_ROUTING_KEY => scope
249            .name()
250            .and_then(|b| std::str::from_utf8(b).ok())
251            .and_then(|s| stringify_routing_primitive(RoutingPrimitive::Str(s)))
252            .map(Cow::into_owned),
253        SCOPE_VERSION_ROUTING_KEY => scope
254            .version()
255            .and_then(|b| std::str::from_utf8(b).ok())
256            .and_then(|s| stringify_routing_primitive(RoutingPrimitive::Str(s)))
257            .map(Cow::into_owned),
258        _ => routing_value_from_attributes(scope.attributes(), key),
259    }
260}
261
262/// Log routing resolved once per instrumentation scope.
263pub(crate) enum LogScopeRouting<'a> {
264    /// No routing mapping configured; records use the configured table name.
265    None,
266    /// The routed event name is constant for every record in the scope (a
267    /// resource-/scope-level routing key). Resolved once.
268    Fixed(String),
269    /// Routing depends on each record (`EventName` or `LogRecordAttribute`).
270    PerRecord(&'a LogsEventNameMapping),
271}
272
273/// Resolve a mapped source value to the final event name, falling back to the
274/// configured table name when the source is absent or unmapped.
275fn resolve_log_event_name_from_value<'a>(
276    mapping: &'a LogsEventNameMapping,
277    source_value: Option<&str>,
278    table_name: &'a str,
279) -> Cow<'a, str> {
280    if let Some(source_value) = source_value {
281        if let Some(mapped_event_name) = resolve_mapped_destination(&mapping.events, source_value) {
282            debug!(
283                name: "otlp_encoder.log_event_routing",
284                target: "geneva-uploader",
285                source_value = %source_value,
286                routed_event_name = %mapped_event_name,
287                "Resolved log event routing from mapping"
288            );
289            return mapped_event_name;
290        }
291
292        debug!(
293            name: "otlp_encoder.log_event_routing",
294            target: "geneva-uploader",
295            source_value = %source_value,
296            fallback_event_name = %table_name,
297            "No mapping entry for routing source; using fallback event name"
298        );
299        return Cow::Borrowed(table_name);
300    }
301
302    debug!(
303        name: "otlp_encoder.log_event_routing",
304        target: "geneva-uploader",
305        fallback_event_name = %table_name,
306        "Routing source value not found on log record; using fallback event name"
307    );
308    Cow::Borrowed(table_name)
309}
310
311/// Resolve the scope-invariant part of log routing exactly once per scope.
312///
313/// Resource-/scope-level routing keys resolve to a value that is constant across
314/// every record in the scope, so their destination event name is computed here
315/// and reused. `EventName`/`LogRecordAttribute` routing is deferred per record.
316pub(crate) fn resolve_log_scope_routing<'a, RV, SV>(
317    resource: Option<&RV>,
318    scope: Option<&SV>,
319    table_name: &str,
320    event_name_mapping: Option<&'a LogsEventNameMapping>,
321) -> LogScopeRouting<'a>
322where
323    RV: ResourceView,
324    SV: InstrumentationScopeView,
325{
326    let Some(mapping) = event_name_mapping else {
327        return LogScopeRouting::None;
328    };
329
330    match &mapping.routing_key {
331        LogsEventNameRoutingKey::ResourceAttribute(key) => {
332            let value = resource
333                .and_then(|res| routing_value_from_attributes(res.attributes(), key.as_str()));
334            LogScopeRouting::Fixed(
335                resolve_log_event_name_from_value(mapping, value.as_deref(), table_name)
336                    .into_owned(),
337            )
338        }
339        LogsEventNameRoutingKey::ScopeAttribute(key) => {
340            let value = scope.and_then(|scope| routing_value_from_scope(scope, key.as_str()));
341            LogScopeRouting::Fixed(
342                resolve_log_event_name_from_value(mapping, value.as_deref(), table_name)
343                    .into_owned(),
344            )
345        }
346        LogsEventNameRoutingKey::EventName | LogsEventNameRoutingKey::LogRecordAttribute(_) => {
347            LogScopeRouting::PerRecord(mapping)
348        }
349    }
350}
351
352/// Resolve the routed event name for a single record whose scope routing depends
353/// on per-record data (`EventName` or `LogRecordAttribute`).
354pub(crate) fn resolve_log_record_routing_event_name<'a, R>(
355    record: &R,
356    mapping: &'a LogsEventNameMapping,
357    table_name: &'a str,
358) -> Cow<'a, str>
359where
360    R: LogRecordView,
361{
362    let routing_value = match &mapping.routing_key {
363        LogsEventNameRoutingKey::EventName => normalized_event_name(record).map(str::to_owned),
364        LogsEventNameRoutingKey::LogRecordAttribute(key) => {
365            routing_value_from_attributes(record.attributes(), key.as_str())
366        }
367        // Resource-/scope-level keys are precomputed in `resolve_log_scope_routing`.
368        LogsEventNameRoutingKey::ResourceAttribute(_)
369        | LogsEventNameRoutingKey::ScopeAttribute(_) => None,
370    };
371
372    resolve_log_event_name_from_value(mapping, routing_value.as_deref(), table_name)
373}
374
375// ---------------------------------------------------------------------------
376// Span routing (over the tonic proto `Span` types)
377// ---------------------------------------------------------------------------
378
379pub(crate) fn span_routing_value_from_attributes<'a>(
380    attributes: &'a [KeyValue],
381    key: &str,
382) -> Option<Cow<'a, str>> {
383    for attr in attributes {
384        if attr.key != key {
385            continue;
386        }
387        let value = attr.value.as_ref()?.value.as_ref()?;
388        return match value {
389            ProtoAnyValue::StringValue(value) => {
390                stringify_routing_primitive(RoutingPrimitive::Str(value))
391            }
392            ProtoAnyValue::IntValue(value) => {
393                stringify_routing_primitive(RoutingPrimitive::Int(*value))
394            }
395            ProtoAnyValue::DoubleValue(value) => {
396                stringify_routing_primitive(RoutingPrimitive::Double(*value))
397            }
398            ProtoAnyValue::BoolValue(value) => {
399                stringify_routing_primitive(RoutingPrimitive::Bool(*value))
400            }
401            _ => None,
402        };
403    }
404    None
405}
406
407pub(crate) fn span_routing_value_from_scope<'a>(
408    scope: Option<&'a InstrumentationScope>,
409    key: &str,
410) -> Option<Cow<'a, str>> {
411    let scope = scope?;
412    match key {
413        SCOPE_NAME_ROUTING_KEY => stringify_routing_primitive(RoutingPrimitive::Str(&scope.name)),
414        SCOPE_VERSION_ROUTING_KEY => {
415            stringify_routing_primitive(RoutingPrimitive::Str(&scope.version))
416        }
417        _ => span_routing_value_from_attributes(&scope.attributes, key),
418    }
419}
420
421pub(crate) fn span_routing_value_from_resource<'a>(
422    resource: Option<&'a Resource>,
423    key: &str,
424) -> Option<Cow<'a, str>> {
425    resource.and_then(|resource| span_routing_value_from_attributes(&resource.attributes, key))
426}
427
428/// Span routing resolved once per instrumentation scope.
429pub(crate) enum SpanScopeRouting<'a> {
430    /// The routed event name is constant for every span in the scope (no mapping
431    /// configured, or a resource-/scope-level routing key). Resolved once.
432    Fixed(String),
433    /// Routing depends on each span's own attributes; only `SpanAttribute` keys
434    /// need per-span resolution.
435    PerSpan {
436        mapping: &'a SpanEventNameMapping,
437        key: &'a str,
438    },
439}
440
441/// Resolve the scope-invariant part of span routing exactly once per scope.
442///
443/// For resource-/scope-level routing keys the source value is constant across
444/// every span in the scope, so the destination event name is computed here and
445/// reused; only `SpanAttribute` routing is deferred to per-span resolution.
446pub(crate) fn resolve_span_scope_routing<'a>(
447    resource: Option<&Resource>,
448    scope: Option<&InstrumentationScope>,
449    table_name: &str,
450    event_name_mapping: Option<&'a SpanEventNameMapping>,
451) -> SpanScopeRouting<'a> {
452    let Some(mapping) = event_name_mapping else {
453        return SpanScopeRouting::Fixed(table_name.to_string());
454    };
455
456    match &mapping.routing_key {
457        SpanEventNameRoutingKey::ResourceAttribute(key) => SpanScopeRouting::Fixed(
458            span_routing_value_from_resource(resource, key)
459                .and_then(|value| resolve_mapped_destination(&mapping.events, &value))
460                .map(Cow::into_owned)
461                .unwrap_or_else(|| table_name.to_string()),
462        ),
463        SpanEventNameRoutingKey::ScopeAttribute(key) => SpanScopeRouting::Fixed(
464            span_routing_value_from_scope(scope, key)
465                .and_then(|value| resolve_mapped_destination(&mapping.events, &value))
466                .map(Cow::into_owned)
467                .unwrap_or_else(|| table_name.to_string()),
468        ),
469        SpanEventNameRoutingKey::SpanAttribute(key) => SpanScopeRouting::PerSpan { mapping, key },
470    }
471}
472
473/// Resolve the routed event name for a single span given its scope's routing.
474///
475/// Returns a [`Cow`] so the common paths (a resource-/scope-level `Fixed` name,
476/// or the no-mapping default) borrow the scope-invariant name instead of
477/// allocating per span; only a per-span mapping hit owns a new `String`.
478pub(crate) fn resolve_span_event_name_in_scope<'a>(
479    scope_routing: &'a SpanScopeRouting<'a>,
480    span: &Span,
481    table_name: &'a str,
482) -> Cow<'a, str> {
483    match scope_routing {
484        SpanScopeRouting::Fixed(name) => Cow::Borrowed(name.as_str()),
485        SpanScopeRouting::PerSpan { mapping, key } => {
486            match span_routing_value_from_attributes(&span.attributes, key)
487                .and_then(|value| resolve_mapped_destination(&mapping.events, &value))
488            {
489                Some(name) => name,
490                None => Cow::Borrowed(table_name),
491            }
492        }
493    }
494}
495
496/// Resolve span routing for a single span end-to-end. Retained for unit tests;
497/// the hot path precomputes scope routing via [`resolve_span_scope_routing`].
498#[cfg(test)]
499pub(crate) fn resolve_span_event_name(
500    resource: Option<&Resource>,
501    scope: Option<&InstrumentationScope>,
502    span: &Span,
503    table_name: &str,
504    event_name_mapping: Option<&SpanEventNameMapping>,
505) -> String {
506    let scope_routing = resolve_span_scope_routing(resource, scope, table_name, event_name_mapping);
507    resolve_span_event_name_in_scope(&scope_routing, span, table_name).into_owned()
508}