Skip to main content

geneva_uploader/
client.rs

1//! High-level GenevaClient for user code. Wraps config_service and ingestion_service.
2
3use crate::config_service::client::{AuthMethod, GenevaConfigClient, GenevaConfigClientConfig};
4// ManagedIdentitySelector removed; no re-export needed.
5use crate::ingestion_service::uploader::{
6    GenevaUploader, GenevaUploaderConfig, GenevaUploaderError,
7};
8use crate::payload_encoder::otlp_encoder::OtlpEncoder;
9use crate::payload_encoder::otlp_encoder::{lookup_obo_config, MetadataFields};
10pub use crate::payload_encoder::otlp_encoder::{OboEventConfig, OboEventMap};
11#[cfg(test)]
12use crate::payload_encoder::routing::{
13    resolve_span_event_name, span_routing_value_from_attributes, span_routing_value_from_resource,
14    span_routing_value_from_scope, SCOPE_NAME_ROUTING_KEY, SCOPE_VERSION_ROUTING_KEY,
15};
16use crate::payload_encoder::routing::{
17    resolve_span_event_name_in_scope, resolve_span_scope_routing,
18};
19pub use crate::payload_encoder::routing::{
20    LogsEventNameMapping, LogsEventNameRoutingKey, SpanEventNameMapping, SpanEventNameRoutingKey,
21};
22#[cfg(test)]
23use opentelemetry_proto::tonic::common::v1::{InstrumentationScope, KeyValue};
24#[cfg(test)]
25use opentelemetry_proto::tonic::resource::v1::Resource;
26use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, Span};
27use otap_df_pdata_views::views::logs::LogsDataView;
28use secrecy::{ExposeSecret, SecretString};
29use std::collections::HashMap;
30use std::fmt;
31use std::future::Future;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::time::Duration;
35use tracing::{debug, info};
36
37/// Public batch type (already LZ4 chunked compressed).
38/// Produced by `OtlpEncoder::encode_log_batch` and returned to callers.
39#[derive(Debug, Clone, PartialEq)]
40pub struct EncodedBatch {
41    pub event_name: String,
42    pub account_group: String,
43    pub(crate) data: Vec<u8>,
44    pub(crate) metadata: crate::payload_encoder::central_blob::BatchMetadata,
45    pub row_count: usize,
46}
47
48impl EncodedBatch {
49    /// Returns the size in bytes of the compressed payload uploaded to Geneva.
50    #[must_use]
51    pub fn compressed_size(&self) -> usize {
52        self.data.len()
53    }
54}
55
56/// Configuration for GenevaClient (user-facing)
57#[derive(Clone, Debug)]
58pub struct GenevaClientConfig {
59    pub endpoint: String,
60    pub environment: String,
61    pub account: String,
62    pub namespace: String,
63    pub account_routing: AccountRouting,
64    pub region: String,
65    pub config_major_version: u32,
66    pub auth_method: AuthMethod,
67    pub tenant: String,
68    pub role_name: String,
69    pub role_instance: String,
70    pub msi_resource: Option<String>, // Required for Managed Identity variants
71    pub logs: Option<LogsConfig>,
72    pub spans: Option<TracesConfig>,
73    pub obo_event_map: Option<OboEventMap>, // Per-event OBO config (None = no OBO)
74}
75
76/// Maps each final Geneva event name to one logical GCS account group.
77///
78/// Overrides are matched after event-name mapping, so `groups_by_event` keys
79/// must be destination event names rather than source names. Events without an
80/// exact override silently use `default_group`. Give streams that require
81/// different routing distinct destination event names so they cannot fall
82/// through to the default group unintentionally.
83#[derive(Clone, Debug)]
84pub struct AccountRouting {
85    default_group: String,
86    groups_by_event: HashMap<String, String>,
87}
88
89impl AccountRouting {
90    pub fn new(default_group: impl Into<String>) -> Self {
91        Self {
92            default_group: default_group.into(),
93            groups_by_event: HashMap::new(),
94        }
95    }
96
97    pub fn with_event_group(
98        mut self,
99        event_name: impl Into<String>,
100        account_group: impl Into<String>,
101    ) -> Self {
102        self.groups_by_event
103            .insert(event_name.into(), account_group.into());
104        self
105    }
106
107    pub fn with_event_groups(mut self, map: HashMap<String, String>) -> Self {
108        self.groups_by_event.extend(map);
109        self
110    }
111
112    fn validate(&self) -> Result<(), String> {
113        if self.default_group.trim().is_empty() {
114            return Err("account_routing.default_group must not be empty".to_string());
115        }
116        for (event, group) in &self.groups_by_event {
117            if event.trim().is_empty() || group.trim().is_empty() {
118                return Err("account_routing event and group names must not be empty".to_string());
119            }
120        }
121        Ok(())
122    }
123
124    fn group_for_event(&self, event_name: &str) -> &str {
125        self.groups_by_event
126            .get(event_name)
127            .map_or(self.default_group.as_str(), String::as_str)
128    }
129}
130
131#[derive(Clone, Debug)]
132pub struct LogsConfig {
133    pub default_event_name: Option<String>,
134    pub event_name_mapping: Option<LogsEventNameMapping>,
135}
136
137#[derive(Clone, Debug)]
138pub struct TracesConfig {
139    pub default_event_name: Option<String>,
140    pub event_name_mapping: Option<SpanEventNameMapping>,
141}
142
143/// Agent-fed credential source: the host agent resolves the
144/// GIG token + endpoint + moniker and supplies them here, so the uploader skips
145/// its own GCS config-service handshake. Queried per upload, so host token
146/// rotation is observed without reconstructing the client.
147pub trait AgentFedCredentialSource: Send + Sync + std::fmt::Debug {
148    /// The current credential, or `None` if the host has not provisioned one yet.
149    ///
150    /// Async so an agent-fed source can resolve the token through an awaitable
151    /// capability (e.g. otap's `bearer_token_provider`, whose `get_token` may
152    /// perform a credential call on a cache miss) by awaiting it, rather than
153    /// polling the future once and dropping it if it is not immediately ready.
154    /// Returning a boxed future (instead of `async fn`) keeps the trait
155    /// object-safe for `Arc<dyn AgentFedCredentialSource>`.
156    fn current(&self) -> AgentFedCredentialFuture<'_>;
157}
158
159/// Future returned by [`AgentFedCredentialSource::current`].
160pub type AgentFedCredentialFuture<'a> =
161    Pin<Box<dyn Future<Output = Option<AgentFedCredential>> + Send + 'a>>;
162
163/// A host-provided GIG credential snapshot (see [`AgentFedCredentialSource`]).
164#[derive(Clone)]
165pub struct AgentFedCredential {
166    /// GIG bearer token (sent as `Authorization: Bearer`).
167    token: SecretString,
168    /// GIG ingestion endpoint (base URL the upload POSTs to).
169    pub endpoint: String,
170    /// Primary physical moniker for every logical account group.
171    pub primary_monikers: HashMap<String, String>,
172}
173
174impl AgentFedCredential {
175    /// Creates an agent-fed credential with zeroizing token storage.
176    #[must_use]
177    pub fn new(
178        token: impl Into<SecretString>,
179        endpoint: impl Into<String>,
180        primary_monikers: HashMap<String, String>,
181    ) -> Self {
182        Self {
183            token: token.into(),
184            endpoint: endpoint.into(),
185            primary_monikers,
186        }
187    }
188
189    /// Exposes the bearer token for explicit credential processing.
190    #[must_use]
191    pub fn expose_token(&self) -> &str {
192        self.token.expose_secret()
193    }
194
195    pub(crate) fn into_parts(self) -> (SecretString, String, HashMap<String, String>) {
196        (self.token, self.endpoint, self.primary_monikers)
197    }
198}
199
200impl std::fmt::Debug for AgentFedCredential {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        // Redact the bearer token; expose only the non-secret routing fields.
203        f.debug_struct("AgentFedCredential")
204            .field("token", &"<redacted>")
205            .field("endpoint", &self.endpoint)
206            .field("account_groups", &self.primary_monikers.keys())
207            .finish()
208    }
209}
210
211/// Error type returned by [`GenevaClient::upload_batch`].
212///
213/// Provides enough information for callers to implement retry strategies:
214/// - [`HttpStatus`](UploadError::HttpStatus) carries the HTTP status code and
215///   an optional `Retry-After` duration so callers can distinguish retriable
216///   server errors (429, 5xx) from permanent client errors (4xx).
217/// - [`Transport`](UploadError::Transport) indicates a network-level failure
218///   (timeout, connection refused, DNS) that is typically retriable.
219/// - [`AccountGroupNotResolved`](UploadError::AccountGroupNotResolved) indicates
220///   a permanent routing configuration error.
221/// - [`Other`](UploadError::Other) covers config-service or internal errors.
222#[derive(Debug)]
223pub enum UploadError {
224    /// Server returned a non-202 HTTP status.
225    HttpStatus {
226        status: u16,
227        retry_after: Option<Duration>,
228        message: String,
229    },
230    /// Network/transport failure (timeout, connection refused, DNS, etc.)
231    Transport(String),
232    /// The configured logical account group was not present in the resolved
233    /// primary-moniker map. This is not retriable without a configuration or
234    /// credential update.
235    AccountGroupNotResolved {
236        requested: String,
237        available: Vec<String>,
238    },
239    /// Config service or other internal error.
240    Other(String),
241}
242
243impl fmt::Display for UploadError {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            Self::HttpStatus {
247                status, message, ..
248            } => {
249                write!(f, "upload failed with status {status}: {message}")
250            }
251            Self::Transport(msg) => write!(f, "transport error: {msg}"),
252            Self::AccountGroupNotResolved {
253                requested,
254                available,
255            } => write!(
256                f,
257                "account group '{requested}' was not resolved; available groups: {available:?}"
258            ),
259            Self::Other(msg) => write!(f, "{msg}"),
260        }
261    }
262}
263
264impl std::error::Error for UploadError {}
265
266/// Main user-facing client for Geneva ingestion.
267#[derive(Clone)]
268pub struct GenevaClient {
269    uploader: Arc<GenevaUploader>,
270    encoder: OtlpEncoder,
271    metadata_fields: MetadataFields,
272    log_table_name: Arc<str>,
273    log_event_name_mapping: Option<LogsEventNameMapping>,
274    span_table_name: Arc<str>,
275    span_event_name_mapping: Option<SpanEventNameMapping>,
276    obo_event_map: Option<OboEventMap>,
277    account_routing: AccountRouting,
278}
279
280/// Stored client pieces shared by every constructor (all of `Self` except the
281/// uploader and encoder), derived once so the constructors can't drift.
282struct ClientParts {
283    log_table_name: Arc<str>,
284    log_event_name_mapping: Option<LogsEventNameMapping>,
285    span_table_name: Arc<str>,
286    span_event_name_mapping: Option<SpanEventNameMapping>,
287    metadata_fields: MetadataFields,
288    obo_event_map: Option<OboEventMap>,
289    account_routing: AccountRouting,
290}
291
292impl GenevaClient {
293    /// Validate any configured event-name mappings before building the client.
294    fn validate_event_name_mappings(cfg: &GenevaClientConfig) -> Result<(), String> {
295        cfg.account_routing.validate()?;
296        if let Some(mapping) = cfg
297            .logs
298            .as_ref()
299            .and_then(|logs| logs.event_name_mapping.as_ref())
300        {
301            mapping.validate()?;
302        }
303        if let Some(mapping) = cfg
304            .spans
305            .as_ref()
306            .and_then(|spans| spans.event_name_mapping.as_ref())
307        {
308            mapping.validate()?;
309        }
310        Ok(())
311    }
312
313    /// Derive the shared uploader config + stored parts; constructors differ
314    /// only in how they build the `GenevaUploader` from the returned config.
315    fn derive_parts(cfg: GenevaClientConfig) -> (GenevaUploaderConfig, ClientParts) {
316        let GenevaClientConfig {
317            environment,
318            namespace,
319            config_major_version,
320            tenant,
321            role_name,
322            role_instance,
323            logs,
324            spans,
325            obo_event_map,
326            account_routing,
327            ..
328        } = cfg;
329
330        let spans_present = spans.is_some();
331        let logs_present = logs.is_some();
332        let (log_default_event_name, log_event_name_mapping) = match logs {
333            Some(logs) => (logs.default_event_name, logs.event_name_mapping),
334            None => (None, None),
335        };
336        let (span_default_event_name, span_event_name_mapping) = match spans {
337            Some(spans) => (spans.default_event_name, spans.event_name_mapping),
338            None => (None, None),
339        };
340
341        let log_table_name: Arc<str> = log_default_event_name.as_deref().unwrap_or("Log").into();
342        let span_table_name: Arc<str> = span_default_event_name.as_deref().unwrap_or("Span").into();
343
344        match log_event_name_mapping.as_ref() {
345            Some(mapping) => {
346                let routing_key_desc = match &mapping.routing_key {
347                    LogsEventNameRoutingKey::EventName => "event_name".to_string(),
348                    LogsEventNameRoutingKey::ResourceAttribute(attr) => {
349                        format!("resource_attribute({})", attr)
350                    }
351                    LogsEventNameRoutingKey::ScopeAttribute(attr) => {
352                        format!("scope_attribute({})", attr)
353                    }
354                    LogsEventNameRoutingKey::LogRecordAttribute(attr) => {
355                        format!("log_record_attribute({})", attr)
356                    }
357                };
358                info!(
359                    name: "client.new.logs_config",
360                    target: "geneva-uploader",
361                    default_event_name = %log_default_event_name.as_deref().unwrap_or("<none>"),
362                    routing_key = %routing_key_desc,
363                    event_mapping_count = mapping.events.len(),
364                    "Configured logs event name routing"
365                );
366            }
367            None if logs_present => {
368                info!(
369                    name: "client.new.logs_config",
370                    target: "geneva-uploader",
371                    default_event_name = %log_default_event_name.as_deref().unwrap_or("<none>"),
372                    "Configured logs event name routing"
373                );
374            }
375            None => {
376                info!(
377                    name: "client.new.logs_config",
378                    target: "geneva-uploader",
379                    "Logs config not initialized; using default values for log events"
380                );
381            }
382        }
383
384        match span_event_name_mapping.as_ref() {
385            Some(mapping) => {
386                let routing_key_desc = match &mapping.routing_key {
387                    SpanEventNameRoutingKey::ResourceAttribute(attr) => {
388                        format!("resource_attribute({})", attr)
389                    }
390                    SpanEventNameRoutingKey::ScopeAttribute(attr) => {
391                        format!("scope_attribute({})", attr)
392                    }
393                    SpanEventNameRoutingKey::SpanAttribute(attr) => {
394                        format!("span_attribute({})", attr)
395                    }
396                };
397                info!(
398                    name: "client.new.spans_config",
399                    target: "geneva-uploader",
400                    default_event_name = %span_default_event_name.as_deref().unwrap_or("<none>"),
401                    routing_key = %routing_key_desc,
402                    event_mapping_count = mapping.events.len(),
403                    "Configured spans event name routing"
404                );
405            }
406            None if spans_present => {
407                info!(
408                    name: "client.new.spans_config",
409                    target: "geneva-uploader",
410                    default_event_name = %span_default_event_name.as_deref().unwrap_or("<none>"),
411                    "Configured spans event name routing"
412                );
413            }
414            None => {
415                info!(
416                    name: "client.new.spans_config",
417                    target: "geneva-uploader",
418                    "Spans config not initialized; using default values for span events"
419                );
420            }
421        }
422
423        let source_identity = format!(
424            "Tenant={}/Role={}/RoleInstance={}",
425            tenant, role_name, role_instance
426        );
427        let config_version = format!("Ver{}v0", config_major_version);
428
429        // Common Schema metadata is independent of the Geneva transport
430        // environment and GCS configuration version.
431        let metadata_fields = MetadataFields::new(
432            tenant,
433            role_name,
434            role_instance,
435            namespace.clone(),
436            config_version.clone(),
437        );
438
439        let uploader_config = GenevaUploaderConfig {
440            namespace,
441            source_identity,
442            environment,
443            config_version,
444        };
445
446        (
447            uploader_config,
448            ClientParts {
449                log_table_name,
450                log_event_name_mapping,
451                span_table_name,
452                span_event_name_mapping,
453                metadata_fields,
454                obo_event_map,
455                account_routing,
456            },
457        )
458    }
459
460    /// Assemble the final client from a built uploader and the shared parts.
461    fn assemble(uploader: GenevaUploader, parts: ClientParts) -> Self {
462        Self {
463            uploader: Arc::new(uploader),
464            encoder: OtlpEncoder::new(),
465            metadata_fields: parts.metadata_fields,
466            log_table_name: parts.log_table_name,
467            log_event_name_mapping: parts.log_event_name_mapping,
468            span_table_name: parts.span_table_name,
469            span_event_name_mapping: parts.span_event_name_mapping,
470            obo_event_map: parts.obo_event_map,
471            account_routing: parts.account_routing,
472        }
473    }
474
475    pub fn new(cfg: GenevaClientConfig) -> Result<Self, String> {
476        info!(
477            name: "client.new",
478            target: "geneva-uploader",
479            endpoint = %cfg.endpoint,
480            namespace = %cfg.namespace,
481            account = %cfg.account,
482            "Initializing GenevaClient"
483        );
484
485        Self::validate_event_name_mappings(&cfg)?;
486
487        // Validate MSI resource presence for managed identity variants
488        match cfg.auth_method {
489            AuthMethod::SystemManagedIdentity
490            | AuthMethod::UserManagedIdentity { .. }
491            | AuthMethod::UserManagedIdentityByObjectId { .. }
492            | AuthMethod::UserManagedIdentityByResourceId { .. } => {
493                if cfg.msi_resource.is_none() {
494                    debug!(
495                        name: "client.new.validate_msi_resource",
496                        target: "geneva-uploader",
497                        "Validation failed: msi_resource must be provided for managed identity auth"
498                    );
499                    return Err(
500                        "msi_resource must be provided for managed identity auth".to_string()
501                    );
502                }
503            }
504            AuthMethod::Certificate { .. } => {}
505            AuthMethod::WorkloadIdentity { .. } => {}
506            #[cfg(feature = "mock_auth")]
507            AuthMethod::MockAuth => {}
508        }
509
510        let config_client_config = GenevaConfigClientConfig {
511            endpoint: cfg.endpoint.clone(),
512            environment: cfg.environment.clone(),
513            account: cfg.account.clone(),
514            namespace: cfg.namespace.clone(),
515            region: cfg.region.clone(),
516            config_major_version: cfg.config_major_version,
517            auth_method: cfg.auth_method.clone(),
518            msi_resource: cfg.msi_resource.clone(),
519            #[cfg(test)]
520            test_root_ca_pem: None,
521        };
522        let config_client =
523            Arc::new(GenevaConfigClient::new(config_client_config).map_err(|e| {
524                debug!(
525                    name: "client.new.config_client_init",
526                    target: "geneva-uploader",
527                    error = %e,
528                    "GenevaConfigClient init failed"
529                );
530                format!("GenevaConfigClient init failed: {e}")
531            })?);
532
533        let (uploader_config, parts) = Self::derive_parts(cfg);
534
535        let uploader =
536            GenevaUploader::from_config_client(config_client, uploader_config).map_err(|e| {
537                debug!(
538                    name: "client.new.uploader_init",
539                    target: "geneva-uploader",
540                    error = %e,
541                    "GenevaUploader init failed"
542                );
543                format!("GenevaUploader init failed: {e}")
544            })?;
545
546        info!(
547            name: "client.new.complete",
548            target: "geneva-uploader",
549            "GenevaClient initialized successfully"
550        );
551
552        Ok(Self::assemble(uploader, parts))
553    }
554
555    /// Agent-fed construction: the host supplies the GIG credential via
556    /// `source`, so the uploader skips the GCS config-service handshake entirely.
557    /// `cfg.auth_method` / `cfg.msi_resource` are ignored (no `GenevaConfigClient`
558    /// is created). The source is queried per upload, so host token rotation is
559    /// observed without rebuilding the client.
560    pub fn with_agent_fed_source(
561        cfg: GenevaClientConfig,
562        source: Arc<dyn AgentFedCredentialSource>,
563    ) -> Result<Self, String> {
564        info!(
565            name: "client.new.agent_fed",
566            target: "geneva-uploader",
567            namespace = %cfg.namespace,
568            account = %cfg.account,
569            "Initializing GenevaClient (agent-fed credential source)"
570        );
571
572        Self::validate_event_name_mappings(&cfg)?;
573
574        let (uploader_config, parts) = Self::derive_parts(cfg);
575
576        let uploader = GenevaUploader::from_agent_fed(source, uploader_config).map_err(|e| {
577            debug!(
578                name: "client.new.agent_fed.uploader_init",
579                target: "geneva-uploader",
580                error = %e,
581                "GenevaUploader (agent-fed) init failed"
582            );
583            format!("GenevaUploader init failed: {e}")
584        })?;
585
586        Ok(Self::assemble(uploader, parts))
587    }
588
589    /// Encode logs from any [`LogsDataView`] implementation into LZ4-chunked
590    /// compressed batches, grouped by event name.
591    ///
592    /// # What to implement
593    ///
594    /// Implement the following traits from `otap_df_pdata_views`:
595    ///
596    /// ```text
597    /// LogsDataView
598    /// └─ ResourceLogsView
599    ///    └─ ScopeLogsView
600    ///       └─ LogRecordView   ← one impl per log record type
601    ///          └─ AnyValueView  (for body / attributes)
602    ///          └─ AttributeView (for attributes)
603    /// ```
604    ///
605    /// The `event_name` field on each log record controls which Geneva event
606    /// table the record is routed to.  Records with no event name (or an
607    /// empty one) are routed to the `"Log"` table.
608    ///
609    /// # Usage pattern
610    ///
611    /// ```ignore
612    /// let batches = client.encode_and_compress_logs(&my_view)?;
613    /// for batch in &batches {
614    ///     client.upload_batch(batch).await?;
615    /// }
616    /// ```
617    ///
618    /// See `examples/view_basic.rs` for the common `RawLogsData` usage pattern
619    /// and `examples/view_advanced.rs` for a full custom `LogsDataView`
620    /// implementation.
621    pub fn encode_and_compress_logs<T: LogsDataView>(
622        &self,
623        view: &T,
624    ) -> Result<Vec<EncodedBatch>, String> {
625        debug!(
626            name: "client.encode_and_compress_logs",
627            target: "geneva-uploader",
628            "Encoding and compressing logs"
629        );
630
631        let mut batches = self
632            .encoder
633            .encode_logs_from_view(
634                view,
635                &self.metadata_fields,
636                self.log_table_name.as_ref(),
637                self.log_event_name_mapping.as_ref(),
638            )
639            .map_err(|e| {
640                debug!(
641                    name: "client.encode_and_compress_logs.error",
642                    target: "geneva-uploader",
643                    error = %e,
644                    "Logs compression failed"
645                );
646                format!("Compression failed: {e}")
647            })?;
648        self.assign_account_groups(&mut batches);
649        Ok(batches)
650    }
651
652    /// Encode OTLP spans into LZ4 chunked compressed batches.
653    pub fn encode_and_compress_spans(
654        &self,
655        spans: &[ResourceSpans],
656    ) -> Result<Vec<EncodedBatch>, String> {
657        debug!(
658            name: "client.encode_and_compress_spans",
659            target: "geneva-uploader",
660            resource_spans_count = spans.len(),
661            "Encoding and compressing resource spans"
662        );
663
664        // Routing is optional: when no span mapping is configured every span
665        // goes to the default table, so skip the grouping bookkeeping entirely
666        // and encode a single batch.
667        let Some(span_event_name_mapping) = self.span_event_name_mapping.as_ref() else {
668            let all_spans = spans
669                .iter()
670                .flat_map(|resource_span| &resource_span.scope_spans)
671                .flat_map(|scope_span| &scope_span.spans);
672            let mut batches = self
673                .encoder
674                .encode_span_batch(
675                    all_spans,
676                    &self.metadata_fields,
677                    self.span_table_name.as_ref(),
678                )
679                .map_err(|e| {
680                    debug!(
681                        name: "client.encode_and_compress_spans.error",
682                        target: "geneva-uploader",
683                        error = %e,
684                        event_name = %self.span_table_name,
685                        "Span compression failed"
686                    );
687                    format!("Compression failed: {e}")
688                })?;
689            self.assign_account_groups(&mut batches);
690            return Ok(batches);
691        };
692
693        let mut routed_groups: Vec<(String, Vec<&Span>)> = Vec::new();
694        let mut group_index: std::collections::HashMap<String, usize> =
695            std::collections::HashMap::new();
696
697        for resource_span in spans {
698            let resource = resource_span.resource.as_ref();
699            for scope_span in &resource_span.scope_spans {
700                let scope = scope_span.scope.as_ref();
701                let scope_routing = resolve_span_scope_routing(
702                    resource,
703                    scope,
704                    self.span_table_name.as_ref(),
705                    Some(span_event_name_mapping),
706                );
707                for span in &scope_span.spans {
708                    let event_name = resolve_span_event_name_in_scope(
709                        &scope_routing,
710                        span,
711                        self.span_table_name.as_ref(),
712                    );
713
714                    match group_index.get(event_name.as_ref()) {
715                        Some(&idx) => routed_groups[idx].1.push(span),
716                        None => {
717                            let event_name = event_name.into_owned();
718                            group_index.insert(event_name.clone(), routed_groups.len());
719                            routed_groups.push((event_name, vec![span]));
720                        }
721                    }
722                }
723            }
724        }
725
726        let mut batches = Vec::new();
727        for (event_name, group_spans) in routed_groups {
728            let encoded = self
729                .encoder
730                .encode_span_batch(group_spans, &self.metadata_fields, &event_name)
731                .map_err(|e| {
732                    debug!(
733                        name: "client.encode_and_compress_spans.error",
734                        target: "geneva-uploader",
735                        error = %e,
736                        event_name = %event_name,
737                        "Span compression failed"
738                    );
739                    format!("Compression failed: {e}")
740                })?;
741            batches.extend(encoded);
742        }
743
744        self.assign_account_groups(&mut batches);
745        Ok(batches)
746    }
747
748    /// Assigns logical account groups from final, post-mapping event names.
749    /// Events without an exact override silently fall back to the default group.
750    fn assign_account_groups(&self, batches: &mut [EncodedBatch]) {
751        for batch in batches {
752            batch.account_group = self
753                .account_routing
754                .group_for_event(&batch.event_name)
755                .to_string();
756        }
757    }
758
759    /// Upload a single compressed batch.
760    /// This allows for granular control over uploads, including custom retry logic for individual batches.
761    pub async fn upload_batch(&self, batch: &EncodedBatch) -> Result<(), UploadError> {
762        debug!(
763            name: "client.upload_batch",
764            target: "geneva-uploader",
765            event_name = %batch.event_name,
766            size = batch.data.len(),
767            "Uploading batch"
768        );
769
770        // Look up per-event OBO config for this batch's event name
771        let obo_config = lookup_obo_config(self.obo_event_map.as_ref(), &batch.event_name)
772            .filter(|c| c.is_active());
773
774        self.uploader
775            .upload(
776                batch.data.clone(),
777                &batch.event_name,
778                &batch.account_group,
779                &batch.metadata,
780                batch.row_count,
781                obo_config,
782            )
783            .await
784            .map(|_| {
785                debug!(
786                    name: "client.upload_batch.success",
787                    target: "geneva-uploader",
788                    event_name = %batch.event_name,
789                    "Successfully uploaded batch"
790                );
791            })
792            .map_err(|e| {
793                debug!(
794                    name: "client.upload_batch.error",
795                    target: "geneva-uploader",
796                    event_name = %batch.event_name,
797                    error = %e,
798                    "Geneva upload failed"
799                );
800                match e {
801                    GenevaUploaderError::UploadFailed {
802                        status,
803                        retry_after,
804                        message,
805                    } => UploadError::HttpStatus {
806                        status,
807                        retry_after,
808                        message,
809                    },
810                    GenevaUploaderError::Http(msg) => UploadError::Transport(msg),
811                    GenevaUploaderError::AccountGroupNotResolved {
812                        requested,
813                        available,
814                    } => UploadError::AccountGroupNotResolved {
815                        requested,
816                        available,
817                    },
818                    other => UploadError::Other(other.to_string()),
819                }
820            })
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
828    use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
829    use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span};
830    use otap_df_pdata::views::otlp::bytes::logs::RawLogsData;
831    use prost::Message as _;
832    use std::collections::HashMap;
833
834    fn build_config(logs: Option<&str>, spans: Option<&str>) -> GenevaClientConfig {
835        GenevaClientConfig {
836            endpoint: "https://example.test".to_string(),
837            environment: "Test".to_string(),
838            account: "acct".to_string(),
839            namespace: "ns".to_string(),
840            account_routing: AccountRouting::new("test-group"),
841            region: "eastus".to_string(),
842            config_major_version: 2,
843            auth_method: AuthMethod::WorkloadIdentity {
844                resource: "https://monitor.azure.com".to_string(),
845            },
846            tenant: "tenant".to_string(),
847            role_name: "role".to_string(),
848            role_instance: "instance".to_string(),
849            msi_resource: None,
850            logs: logs.map(|default_event_name| LogsConfig {
851                default_event_name: Some(default_event_name.to_owned()),
852                event_name_mapping: None,
853            }),
854            spans: spans.map(|default_event_name| TracesConfig {
855                default_event_name: Some(default_event_name.to_owned()),
856                event_name_mapping: None,
857            }),
858            obo_event_map: None,
859        }
860    }
861
862    fn build_client(logs: Option<&str>, spans: Option<&str>) -> GenevaClient {
863        GenevaClient::new(build_config(logs, spans)).expect("client should initialize")
864    }
865
866    /// Scenario: Geneva transport uses a GCS environment and configuration version.
867    /// Guarantees: Deriving payload metadata does not alter the uploader routing values.
868    #[test]
869    fn derive_parts_preserves_transport_environment_and_version() {
870        let (uploader, metadata) = GenevaClient::derive_parts(build_config(None, None));
871
872        assert_eq!(uploader.environment, "Test");
873        assert_eq!(uploader.config_version, "Ver2v0");
874        assert_eq!(metadata.metadata_fields.event_version, "Ver2v0");
875    }
876
877    fn build_span_client(
878        default_event_name: Option<&str>,
879        mapping: Option<SpanEventNameMapping>,
880    ) -> GenevaClient {
881        let mut cfg = build_config(None, default_event_name);
882        cfg.spans = Some(TracesConfig {
883            default_event_name: default_event_name.map(str::to_owned),
884            event_name_mapping: mapping,
885        });
886        GenevaClient::new(cfg).expect("span client should initialize")
887    }
888
889    fn make_span_event_name_mapping(
890        routing_key: SpanEventNameRoutingKey,
891        events: &[(&str, Option<&str>)],
892    ) -> SpanEventNameMapping {
893        let mut map = HashMap::new();
894        for (source, destination) in events {
895            map.insert((*source).to_string(), destination.map(str::to_string));
896        }
897        SpanEventNameMapping {
898            routing_key,
899            events: map,
900        }
901    }
902
903    fn string_attr(key: &str, value: &str) -> KeyValue {
904        KeyValue {
905            key: key.to_string(),
906            key_strindex: 0,
907            value: Some(opentelemetry_proto::tonic::common::v1::AnyValue {
908                value: Some(
909                    opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(
910                        value.to_string(),
911                    ),
912                ),
913            }),
914        }
915    }
916
917    fn int_attr(key: &str, value: i64) -> KeyValue {
918        KeyValue {
919            key: key.to_string(),
920            key_strindex: 0,
921            value: Some(opentelemetry_proto::tonic::common::v1::AnyValue {
922                value: Some(
923                    opentelemetry_proto::tonic::common::v1::any_value::Value::IntValue(value),
924                ),
925            }),
926        }
927    }
928
929    fn double_attr(key: &str, value: f64) -> KeyValue {
930        KeyValue {
931            key: key.to_string(),
932            key_strindex: 0,
933            value: Some(opentelemetry_proto::tonic::common::v1::AnyValue {
934                value: Some(
935                    opentelemetry_proto::tonic::common::v1::any_value::Value::DoubleValue(value),
936                ),
937            }),
938        }
939    }
940
941    fn bool_attr(key: &str, value: bool) -> KeyValue {
942        KeyValue {
943            key: key.to_string(),
944            key_strindex: 0,
945            value: Some(opentelemetry_proto::tonic::common::v1::AnyValue {
946                value: Some(
947                    opentelemetry_proto::tonic::common::v1::any_value::Value::BoolValue(value),
948                ),
949            }),
950        }
951    }
952
953    fn bytes_attr(key: &str, value: Vec<u8>) -> KeyValue {
954        KeyValue {
955            key: key.to_string(),
956            key_strindex: 0,
957            value: Some(opentelemetry_proto::tonic::common::v1::AnyValue {
958                value: Some(
959                    opentelemetry_proto::tonic::common::v1::any_value::Value::BytesValue(value),
960                ),
961            }),
962        }
963    }
964
965    fn build_span_request(
966        resource_attrs: Vec<KeyValue>,
967        scope_name: Option<&str>,
968        scope_attrs: Vec<KeyValue>,
969        span_attrs: Vec<KeyValue>,
970    ) -> Vec<ResourceSpans> {
971        vec![ResourceSpans {
972            resource: (!resource_attrs.is_empty()).then_some(Resource {
973                attributes: resource_attrs,
974                ..Default::default()
975            }),
976            scope_spans: vec![ScopeSpans {
977                scope: (scope_name.is_some() || !scope_attrs.is_empty()).then_some(
978                    InstrumentationScope {
979                        name: scope_name.unwrap_or_default().to_string(),
980                        attributes: scope_attrs,
981                        ..Default::default()
982                    },
983                ),
984                spans: vec![Span {
985                    attributes: span_attrs,
986                    ..Default::default()
987                }],
988                ..Default::default()
989            }],
990            ..Default::default()
991        }]
992    }
993
994    #[test]
995    fn new_rejects_logs_mapping_with_empty_events() {
996        let mut cfg = build_config(None, None);
997        cfg.logs = Some(LogsConfig {
998            default_event_name: None,
999            event_name_mapping: Some(LogsEventNameMapping {
1000                routing_key: LogsEventNameRoutingKey::EventName,
1001                events: HashMap::new(),
1002            }),
1003        });
1004
1005        let err = match GenevaClient::new(cfg) {
1006            Ok(_) => panic!("empty mapping events must be rejected"),
1007            Err(err) => err,
1008        };
1009        assert!(err.contains("events must be non-empty"));
1010    }
1011
1012    #[test]
1013    fn new_rejects_invalid_account_routing() {
1014        let mut cfg = build_config(None, None);
1015        cfg.account_routing = AccountRouting::new("   ");
1016
1017        let err = match GenevaClient::new(cfg) {
1018            Ok(_) => panic!("blank default account group must be rejected"),
1019            Err(err) => err,
1020        };
1021        assert!(err.contains("default_group must not be empty"));
1022
1023        let mut cfg = build_config(None, None);
1024        cfg.account_routing = AccountRouting::new("default").with_event_group("Log", "  ");
1025
1026        let err = match GenevaClient::new(cfg) {
1027            Ok(_) => panic!("blank mapped account group must be rejected"),
1028            Err(err) => err,
1029        };
1030        assert!(err.contains("event and group names must not be empty"));
1031    }
1032
1033    #[test]
1034    fn account_routing_bulk_builder_extends_event_groups() {
1035        let routing = AccountRouting::new("default").with_event_groups(HashMap::from([
1036            ("SecurityEvent".to_string(), "security".to_string()),
1037            ("AuditEvent".to_string(), "audit".to_string()),
1038        ]));
1039
1040        assert_eq!(routing.group_for_event("SecurityEvent"), "security");
1041        assert_eq!(routing.group_for_event("AuditEvent"), "audit");
1042        assert_eq!(routing.group_for_event("UnmappedEvent"), "default");
1043    }
1044
1045    #[test]
1046    fn encoded_batches_route_final_event_name_to_account_group() {
1047        let mut cfg = build_config(Some("SecurityEvent"), None);
1048        cfg.account_routing =
1049            AccountRouting::new("diagnostics").with_event_group("SecurityEvent", "security");
1050        let client = GenevaClient::new(cfg).expect("client should initialize");
1051
1052        let request = ExportLogsServiceRequest {
1053            resource_logs: vec![ResourceLogs {
1054                scope_logs: vec![ScopeLogs {
1055                    log_records: vec![LogRecord::default()],
1056                    ..Default::default()
1057                }],
1058                ..Default::default()
1059            }],
1060        };
1061        let bytes = request.encode_to_vec();
1062        let batches = client
1063            .encode_and_compress_logs(&RawLogsData::new(&bytes))
1064            .expect("log encoding should succeed");
1065
1066        assert_eq!(batches.len(), 1);
1067        assert_eq!(batches[0].event_name, "SecurityEvent");
1068        assert_eq!(batches[0].account_group, "security");
1069    }
1070
1071    #[test]
1072    fn new_rejects_spans_mapping_with_empty_events() {
1073        let mut cfg = build_config(None, None);
1074        cfg.spans = Some(TracesConfig {
1075            default_event_name: None,
1076            event_name_mapping: Some(SpanEventNameMapping {
1077                routing_key: SpanEventNameRoutingKey::SpanAttribute("cluster".to_string()),
1078                events: HashMap::new(),
1079            }),
1080        });
1081
1082        let err = match GenevaClient::new(cfg) {
1083            Ok(_) => panic!("empty span mapping events must be rejected"),
1084            Err(err) => err,
1085        };
1086        assert!(err.contains("events must be non-empty"));
1087    }
1088
1089    #[test]
1090    fn new_rejects_logs_mapping_with_blank_source_key() {
1091        let mut cfg = build_config(None, None);
1092        cfg.logs = Some(LogsConfig {
1093            default_event_name: None,
1094            event_name_mapping: Some(LogsEventNameMapping {
1095                routing_key: LogsEventNameRoutingKey::EventName,
1096                events: HashMap::from([("   ".to_string(), Some("TableA".to_string()))]),
1097            }),
1098        });
1099
1100        let err = match GenevaClient::new(cfg) {
1101            Ok(_) => panic!("blank source key must be rejected"),
1102            Err(err) => err,
1103        };
1104        assert!(err.contains("source keys must not be blank"));
1105    }
1106
1107    #[test]
1108    fn new_rejects_spans_mapping_with_blank_routing_key_name() {
1109        let mut cfg = build_config(None, None);
1110        cfg.spans = Some(TracesConfig {
1111            default_event_name: None,
1112            event_name_mapping: Some(SpanEventNameMapping {
1113                routing_key: SpanEventNameRoutingKey::SpanAttribute("  ".to_string()),
1114                events: HashMap::from([("cluster-a".to_string(), Some("TraceA".to_string()))]),
1115            }),
1116        });
1117
1118        let err = match GenevaClient::new(cfg) {
1119            Ok(_) => panic!("blank routing key name must be rejected"),
1120            Err(err) => err,
1121        };
1122        assert!(err.contains("attribute name must not be blank"));
1123    }
1124
1125    #[test]
1126    fn default_event_name_unwrap_or_prefers_override_and_falls_back() {
1127        let configured = maybe_event_name(true);
1128        let missing = maybe_event_name(false);
1129
1130        assert_eq!(configured.unwrap_or("Log"), "AppLog");
1131        assert_eq!(missing.unwrap_or("Log"), "Log");
1132    }
1133
1134    fn maybe_event_name(configured: bool) -> Option<&'static str> {
1135        if configured {
1136            Some("AppLog")
1137        } else {
1138            None
1139        }
1140    }
1141
1142    #[test]
1143    fn encode_and_compress_logs_uses_configured_default_event_name() {
1144        let client = build_client(Some("AppLog"), None);
1145
1146        let request = ExportLogsServiceRequest {
1147            resource_logs: vec![ResourceLogs {
1148                scope_logs: vec![ScopeLogs {
1149                    log_records: vec![LogRecord::default()],
1150                    ..Default::default()
1151                }],
1152                ..Default::default()
1153            }],
1154        };
1155
1156        let bytes = request.encode_to_vec();
1157        let view = RawLogsData::new(&bytes);
1158        let batches = client
1159            .encode_and_compress_logs(&view)
1160            .expect("log encoding should succeed");
1161
1162        assert_eq!(batches.len(), 1);
1163        assert_eq!(batches[0].event_name, "AppLog");
1164    }
1165
1166    #[test]
1167    fn encode_and_compress_spans_uses_configured_default_event_name() {
1168        let client = build_client(None, Some("AppTrace"));
1169
1170        let spans = vec![ResourceSpans {
1171            scope_spans: vec![ScopeSpans {
1172                spans: vec![Span {
1173                    name: "span-name".to_string(),
1174                    ..Default::default()
1175                }],
1176                ..Default::default()
1177            }],
1178            ..Default::default()
1179        }];
1180
1181        let batches = client
1182            .encode_and_compress_spans(&spans)
1183            .expect("span encoding should succeed");
1184
1185        assert_eq!(batches.len(), 1);
1186        assert_eq!(batches[0].event_name, "AppTrace");
1187    }
1188
1189    #[test]
1190    fn encode_and_compress_spans_no_mapping_shares_default_name_across_spans() {
1191        // No routing mapping: every span in the scope resolves to the default
1192        // table name via a borrowed Cow. Multiple spans must collapse into one
1193        // batch under that name without per-span ownership.
1194        let client = build_client(None, Some("AppTrace"));
1195
1196        let spans = vec![ResourceSpans {
1197            scope_spans: vec![ScopeSpans {
1198                spans: vec![
1199                    Span {
1200                        name: "span-1".to_string(),
1201                        ..Default::default()
1202                    },
1203                    Span {
1204                        name: "span-2".to_string(),
1205                        ..Default::default()
1206                    },
1207                    Span {
1208                        name: "span-3".to_string(),
1209                        ..Default::default()
1210                    },
1211                ],
1212                ..Default::default()
1213            }],
1214            ..Default::default()
1215        }];
1216
1217        let batches = client
1218            .encode_and_compress_spans(&spans)
1219            .expect("span encoding should succeed");
1220
1221        assert_eq!(batches.len(), 1);
1222        assert_eq!(batches[0].event_name, "AppTrace");
1223        assert_eq!(batches[0].row_count, 3);
1224    }
1225
1226    #[test]
1227    fn encode_and_compress_spans_routes_by_resource_scope_and_span_attributes() {
1228        let client = build_span_client(
1229            Some("AppTrace"),
1230            Some(make_span_event_name_mapping(
1231                SpanEventNameRoutingKey::ResourceAttribute("cluster".to_string()),
1232                &[("cluster-a", Some("ResourceTrace"))],
1233            )),
1234        );
1235
1236        let resource_spans = build_span_request(
1237            vec![string_attr("cluster", "cluster-a")],
1238            Some("scope-a"),
1239            Vec::new(),
1240            Vec::new(),
1241        );
1242
1243        let batches = client
1244            .encode_and_compress_spans(&resource_spans)
1245            .expect("span encoding should succeed");
1246
1247        assert_eq!(batches.len(), 1);
1248        assert_eq!(batches[0].event_name, "ResourceTrace");
1249    }
1250
1251    #[test]
1252    fn encode_and_compress_spans_resource_route_shared_across_spans_in_scope() {
1253        // The resource-level routing key is scope-invariant, so it is resolved
1254        // once per scope and reused for every span; multiple spans in one scope
1255        // must all land in a single batch under the resolved event name.
1256        let client = build_span_client(
1257            Some("AppTrace"),
1258            Some(make_span_event_name_mapping(
1259                SpanEventNameRoutingKey::ResourceAttribute("cluster".to_string()),
1260                &[("cluster-a", Some("ResourceTrace"))],
1261            )),
1262        );
1263
1264        let resource_spans = vec![ResourceSpans {
1265            resource: Some(Resource {
1266                attributes: vec![string_attr("cluster", "cluster-a")],
1267                ..Default::default()
1268            }),
1269            scope_spans: vec![ScopeSpans {
1270                scope: Some(InstrumentationScope {
1271                    name: "scope-a".to_string(),
1272                    ..Default::default()
1273                }),
1274                spans: vec![
1275                    Span {
1276                        name: "span-1".to_string(),
1277                        ..Default::default()
1278                    },
1279                    Span {
1280                        name: "span-2".to_string(),
1281                        ..Default::default()
1282                    },
1283                ],
1284                ..Default::default()
1285            }],
1286            ..Default::default()
1287        }];
1288
1289        let batches = client
1290            .encode_and_compress_spans(&resource_spans)
1291            .expect("span encoding should succeed");
1292
1293        assert_eq!(batches.len(), 1);
1294        assert_eq!(batches[0].event_name, "ResourceTrace");
1295        assert_eq!(batches[0].row_count, 2);
1296    }
1297
1298    #[test]
1299    fn encode_and_compress_spans_routes_by_scope_attribute() {
1300        let client = build_span_client(
1301            Some("AppTrace"),
1302            Some(make_span_event_name_mapping(
1303                SpanEventNameRoutingKey::ScopeAttribute("cluster".to_string()),
1304                &[("scope-a", Some("ScopeTrace"))],
1305            )),
1306        );
1307
1308        let resource_spans = build_span_request(
1309            Vec::new(),
1310            Some("scope-a"),
1311            vec![string_attr("cluster", "scope-a")],
1312            Vec::new(),
1313        );
1314
1315        let batches = client
1316            .encode_and_compress_spans(&resource_spans)
1317            .expect("span encoding should succeed");
1318
1319        assert_eq!(batches.len(), 1);
1320        assert_eq!(batches[0].event_name, "ScopeTrace");
1321    }
1322
1323    #[test]
1324    fn encode_and_compress_spans_routes_by_scope_name_with_passthrough_destination() {
1325        let client = build_span_client(
1326            Some("AppTrace"),
1327            Some(make_span_event_name_mapping(
1328                SpanEventNameRoutingKey::ScopeAttribute("scope.name".to_string()),
1329                &[("scope-a", Some(""))],
1330            )),
1331        );
1332
1333        let resource_spans =
1334            build_span_request(Vec::new(), Some("scope-a"), Vec::new(), Vec::new());
1335
1336        let batches = client
1337            .encode_and_compress_spans(&resource_spans)
1338            .expect("span encoding should succeed");
1339
1340        assert_eq!(batches.len(), 1);
1341        assert_eq!(batches[0].event_name, "scope-a");
1342    }
1343
1344    #[test]
1345    fn encode_and_compress_spans_routes_by_span_attribute_and_splits_batches() {
1346        let client = build_span_client(
1347            Some("AppTrace"),
1348            Some(make_span_event_name_mapping(
1349                SpanEventNameRoutingKey::SpanAttribute("cluster".to_string()),
1350                &[
1351                    ("cluster-a", Some("SpanTraceA")),
1352                    ("cluster-b", Some("SpanTraceB")),
1353                ],
1354            )),
1355        );
1356
1357        let resource_spans = vec![ResourceSpans {
1358            scope_spans: vec![ScopeSpans {
1359                scope: Some(InstrumentationScope {
1360                    name: "scope-a".to_string(),
1361                    ..Default::default()
1362                }),
1363                spans: vec![
1364                    Span {
1365                        attributes: vec![string_attr("cluster", "cluster-a")],
1366                        ..Default::default()
1367                    },
1368                    Span {
1369                        attributes: vec![string_attr("cluster", "cluster-b")],
1370                        ..Default::default()
1371                    },
1372                ],
1373                ..Default::default()
1374            }],
1375            ..Default::default()
1376        }];
1377
1378        let batches = client
1379            .encode_and_compress_spans(&resource_spans)
1380            .expect("span encoding should succeed");
1381
1382        let mut event_names: Vec<String> = batches
1383            .iter()
1384            .map(|batch| batch.event_name.clone())
1385            .collect();
1386        event_names.sort();
1387        assert_eq!(
1388            event_names,
1389            vec!["SpanTraceA".to_string(), "SpanTraceB".to_string()]
1390        );
1391    }
1392
1393    #[test]
1394    fn encode_and_compress_spans_missing_mapping_key_falls_back_to_default_event_name() {
1395        let client = build_span_client(
1396            Some("AppTrace"),
1397            Some(make_span_event_name_mapping(
1398                SpanEventNameRoutingKey::SpanAttribute("cluster".to_string()),
1399                &[("known", Some("SpanTrace"))],
1400            )),
1401        );
1402
1403        let resource_spans = build_span_request(
1404            Vec::new(),
1405            Some("scope-a"),
1406            Vec::new(),
1407            vec![string_attr("cluster", "unknown")],
1408        );
1409
1410        let batches = client
1411            .encode_and_compress_spans(&resource_spans)
1412            .expect("span encoding should succeed");
1413
1414        assert_eq!(batches.len(), 1);
1415        assert_eq!(batches[0].event_name, "AppTrace");
1416    }
1417
1418    #[test]
1419    fn encode_and_compress_spans_routes_by_non_string_attribute_value() {
1420        // Non-string span attribute values are stringified before the mapping lookup.
1421        let client = build_span_client(
1422            Some("AppTrace"),
1423            Some(make_span_event_name_mapping(
1424                SpanEventNameRoutingKey::SpanAttribute("code".to_string()),
1425                &[("42", Some("IntTrace"))],
1426            )),
1427        );
1428
1429        let resource_spans = build_span_request(
1430            Vec::new(),
1431            Some("scope-a"),
1432            Vec::new(),
1433            vec![int_attr("code", 42)],
1434        );
1435
1436        let batches = client
1437            .encode_and_compress_spans(&resource_spans)
1438            .expect("span encoding should succeed");
1439
1440        assert_eq!(batches.len(), 1);
1441        assert_eq!(batches[0].event_name, "IntTrace");
1442    }
1443
1444    #[test]
1445    fn span_routing_value_from_attributes_handles_all_value_types() {
1446        let attrs = vec![
1447            string_attr("s", "sv"),
1448            int_attr("i", 7),
1449            double_attr("d", 2.5),
1450            bool_attr("b", true),
1451            bytes_attr("raw", b"xyz".to_vec()),
1452        ];
1453
1454        assert_eq!(
1455            span_routing_value_from_attributes(&attrs, "s").as_deref(),
1456            Some("sv")
1457        );
1458        assert_eq!(
1459            span_routing_value_from_attributes(&attrs, "i").as_deref(),
1460            Some("7")
1461        );
1462        assert_eq!(
1463            span_routing_value_from_attributes(&attrs, "d").as_deref(),
1464            Some("2.5")
1465        );
1466        assert_eq!(
1467            span_routing_value_from_attributes(&attrs, "b").as_deref(),
1468            Some("true")
1469        );
1470        // Unsupported value types (bytes/array/kvlist) and missing keys yield None.
1471        assert_eq!(span_routing_value_from_attributes(&attrs, "raw"), None);
1472        assert_eq!(span_routing_value_from_attributes(&attrs, "absent"), None);
1473        // Blank/whitespace string values are treated as absent.
1474        let blank = vec![string_attr("s", "   ")];
1475        assert_eq!(span_routing_value_from_attributes(&blank, "s"), None);
1476    }
1477
1478    #[test]
1479    fn span_routing_value_from_scope_reads_name_version_and_attributes() {
1480        let scope = InstrumentationScope {
1481            name: "scope-a".to_string(),
1482            version: "1.2.3".to_string(),
1483            attributes: vec![string_attr("cluster", "clusterA")],
1484            ..Default::default()
1485        };
1486
1487        assert_eq!(
1488            span_routing_value_from_scope(Some(&scope), SCOPE_NAME_ROUTING_KEY).as_deref(),
1489            Some("scope-a")
1490        );
1491        assert_eq!(
1492            span_routing_value_from_scope(Some(&scope), SCOPE_VERSION_ROUTING_KEY).as_deref(),
1493            Some("1.2.3")
1494        );
1495        assert_eq!(
1496            span_routing_value_from_scope(Some(&scope), "cluster").as_deref(),
1497            Some("clusterA")
1498        );
1499        assert_eq!(span_routing_value_from_scope(Some(&scope), "absent"), None);
1500        // A missing scope yields None.
1501        assert_eq!(
1502            span_routing_value_from_scope(None, SCOPE_NAME_ROUTING_KEY),
1503            None
1504        );
1505        // Blank scope name/version are treated as absent.
1506        let blank = InstrumentationScope {
1507            name: "  ".to_string(),
1508            version: String::new(),
1509            ..Default::default()
1510        };
1511        assert_eq!(
1512            span_routing_value_from_scope(Some(&blank), SCOPE_NAME_ROUTING_KEY),
1513            None
1514        );
1515        assert_eq!(
1516            span_routing_value_from_scope(Some(&blank), SCOPE_VERSION_ROUTING_KEY),
1517            None
1518        );
1519    }
1520
1521    #[test]
1522    fn span_routing_value_from_resource_reads_attributes_or_none() {
1523        let resource = Resource {
1524            attributes: vec![string_attr("region", "eastus")],
1525            ..Default::default()
1526        };
1527
1528        assert_eq!(
1529            span_routing_value_from_resource(Some(&resource), "region").as_deref(),
1530            Some("eastus")
1531        );
1532        assert_eq!(
1533            span_routing_value_from_resource(Some(&resource), "absent"),
1534            None
1535        );
1536        // A missing resource yields None.
1537        assert_eq!(span_routing_value_from_resource(None, "region"), None);
1538    }
1539
1540    #[test]
1541    fn resolve_span_event_name_falls_back_and_passes_through() {
1542        let span = Span {
1543            attributes: vec![string_attr("cluster", "clusterA")],
1544            ..Default::default()
1545        };
1546
1547        // No mapping configured -> default table name.
1548        assert_eq!(
1549            resolve_span_event_name(None, None, &span, "Span", None),
1550            "Span"
1551        );
1552
1553        let mapping = make_span_event_name_mapping(
1554            SpanEventNameRoutingKey::SpanAttribute("cluster".to_string()),
1555            &[("clusterA", Some("")), ("clusterB", Some("Premium"))],
1556        );
1557
1558        // An empty destination passes the source value through unchanged.
1559        assert_eq!(
1560            resolve_span_event_name(None, None, &span, "Span", Some(&mapping)),
1561            "clusterA"
1562        );
1563
1564        // The routing attribute is absent -> fall back to the default table name.
1565        let other = Span {
1566            attributes: vec![string_attr("region", "eastus")],
1567            ..Default::default()
1568        };
1569        assert_eq!(
1570            resolve_span_event_name(None, None, &other, "Span", Some(&mapping)),
1571            "Span"
1572        );
1573    }
1574
1575    #[test]
1576    fn new_logs_client_accepts_all_attribute_routing_kinds() {
1577        // Exercises GenevaClient::new's logs-mapping description/validation for each
1578        // attribute routing kind (resource/scope/log-record).
1579        for routing_key in [
1580            LogsEventNameRoutingKey::ResourceAttribute("res".to_string()),
1581            LogsEventNameRoutingKey::ScopeAttribute("scope".to_string()),
1582            LogsEventNameRoutingKey::LogRecordAttribute("rec".to_string()),
1583        ] {
1584            let mut cfg = build_config(Some("AppLog"), None);
1585            cfg.logs = Some(LogsConfig {
1586                default_event_name: Some("AppLog".to_string()),
1587                event_name_mapping: Some(LogsEventNameMapping {
1588                    routing_key,
1589                    events: HashMap::from([("src".to_string(), Some("Dest".to_string()))]),
1590                }),
1591            });
1592            GenevaClient::new(cfg).expect("logs client with attribute routing should initialize");
1593        }
1594    }
1595
1596    #[test]
1597    fn encode_and_compress_spans_groups_multiple_spans_with_same_route() {
1598        // Two spans resolving to the same destination share a single grouped batch,
1599        // exercising the "append to existing group" path.
1600        let client = build_span_client(
1601            Some("AppTrace"),
1602            Some(make_span_event_name_mapping(
1603                SpanEventNameRoutingKey::SpanAttribute("cluster".to_string()),
1604                &[("clusterA", Some("TraceA"))],
1605            )),
1606        );
1607
1608        let resource_spans = vec![ResourceSpans {
1609            resource: None,
1610            scope_spans: vec![ScopeSpans {
1611                scope: Some(InstrumentationScope {
1612                    name: "s".to_string(),
1613                    ..Default::default()
1614                }),
1615                spans: vec![
1616                    Span {
1617                        attributes: vec![string_attr("cluster", "clusterA")],
1618                        ..Default::default()
1619                    },
1620                    Span {
1621                        attributes: vec![string_attr("cluster", "clusterA")],
1622                        ..Default::default()
1623                    },
1624                ],
1625                ..Default::default()
1626            }],
1627            ..Default::default()
1628        }];
1629
1630        let batches = client
1631            .encode_and_compress_spans(&resource_spans)
1632            .expect("span encoding should succeed");
1633
1634        assert_eq!(batches.len(), 1);
1635        assert_eq!(batches[0].event_name, "TraceA");
1636        assert_eq!(batches[0].row_count, 2);
1637    }
1638
1639    #[test]
1640    fn encode_and_compress_logs_uses_default_table_name_when_logs_config_absent() {
1641        let client = GenevaClient::new(GenevaClientConfig {
1642            endpoint: "https://example.test".to_string(),
1643            environment: "Test".to_string(),
1644            account: "acct".to_string(),
1645            namespace: "ns".to_string(),
1646            account_routing: AccountRouting::new("test-group"),
1647            region: "eastus".to_string(),
1648            config_major_version: 2,
1649            auth_method: AuthMethod::WorkloadIdentity {
1650                resource: "https://monitor.azure.com".to_string(),
1651            },
1652            tenant: "tenant".to_string(),
1653            role_name: "role".to_string(),
1654            role_instance: "instance".to_string(),
1655            msi_resource: None,
1656            logs: None,
1657            spans: None,
1658            obo_event_map: None,
1659        })
1660        .expect("client should initialize without optional log config");
1661
1662        let request = ExportLogsServiceRequest {
1663            resource_logs: vec![ResourceLogs {
1664                scope_logs: vec![ScopeLogs {
1665                    log_records: vec![LogRecord::default()],
1666                    ..Default::default()
1667                }],
1668                ..Default::default()
1669            }],
1670        };
1671        let bytes = request.encode_to_vec();
1672        let view = RawLogsData::new(&bytes);
1673        let batches = client
1674            .encode_and_compress_logs(&view)
1675            .expect("log encoding should succeed");
1676
1677        assert_eq!(batches.len(), 1);
1678        assert_eq!(batches[0].event_name, "Log");
1679    }
1680
1681    #[test]
1682    fn encode_and_compress_spans_uses_default_table_name_when_spans_config_absent() {
1683        let client = GenevaClient::new(GenevaClientConfig {
1684            endpoint: "https://example.test".to_string(),
1685            environment: "Test".to_string(),
1686            account: "acct".to_string(),
1687            namespace: "ns".to_string(),
1688            account_routing: AccountRouting::new("test-group"),
1689            region: "eastus".to_string(),
1690            config_major_version: 2,
1691            auth_method: AuthMethod::WorkloadIdentity {
1692                resource: "https://monitor.azure.com".to_string(),
1693            },
1694            tenant: "tenant".to_string(),
1695            role_name: "role".to_string(),
1696            role_instance: "instance".to_string(),
1697            msi_resource: None,
1698            logs: None,
1699            spans: None,
1700            obo_event_map: None,
1701        })
1702        .expect("client should initialize without optional span config");
1703
1704        let spans = vec![ResourceSpans {
1705            scope_spans: vec![ScopeSpans {
1706                spans: vec![Span {
1707                    name: "span-name".to_string(),
1708                    ..Default::default()
1709                }],
1710                ..Default::default()
1711            }],
1712            ..Default::default()
1713        }];
1714
1715        let batches = client
1716            .encode_and_compress_spans(&spans)
1717            .expect("span encoding should succeed");
1718
1719        assert_eq!(batches.len(), 1);
1720        assert_eq!(batches[0].event_name, "Span");
1721    }
1722
1723    /// Scenario: An agent-fed source initializes a Geneva client with a protected token.
1724    /// Guarantees: Source implementations can construct credentials through the hardened API.
1725    #[test]
1726    fn with_agent_fed_source_builds_usable_client() {
1727        #[derive(Debug)]
1728        struct StubSource;
1729        impl AgentFedCredentialSource for StubSource {
1730            fn current(&self) -> AgentFedCredentialFuture<'_> {
1731                Box::pin(async {
1732                    Some(AgentFedCredential::new(
1733                        "secret-token",
1734                        "https://ingest.example",
1735                        HashMap::from([("test-group".to_string(), "moniker".to_string())]),
1736                    ))
1737                })
1738            }
1739        }
1740
1741        let client = GenevaClient::with_agent_fed_source(
1742            build_config(Some("AppLog"), None),
1743            Arc::new(StubSource),
1744        )
1745        .expect("agent-fed client should initialize");
1746
1747        // Prove the agent-fed client is usable end-to-end for encoding, just
1748        // like a config-service client.
1749        let request = ExportLogsServiceRequest {
1750            resource_logs: vec![ResourceLogs {
1751                scope_logs: vec![ScopeLogs {
1752                    log_records: vec![LogRecord::default()],
1753                    ..Default::default()
1754                }],
1755                ..Default::default()
1756            }],
1757        };
1758        let bytes = request.encode_to_vec();
1759        let view = RawLogsData::new(&bytes);
1760        let batches = client
1761            .encode_and_compress_logs(&view)
1762            .expect("log encoding should succeed");
1763        assert_eq!(batches.len(), 1);
1764        assert_eq!(batches[0].event_name, "AppLog");
1765    }
1766
1767    /// Scenario: An agent-fed credential is rendered with Debug.
1768    /// Guarantees: The bearer token is redacted while routing remains inspectable.
1769    #[test]
1770    fn agent_fed_credential_debug_redacts_token() {
1771        let cred = AgentFedCredential::new(
1772            "top-secret",
1773            "https://ingest.example",
1774            HashMap::from([("test-group".to_string(), "moniker".to_string())]),
1775        );
1776        assert_eq!(cred.expose_token(), "top-secret");
1777        let rendered = format!("{cred:?}");
1778        assert!(
1779            !rendered.contains("top-secret"),
1780            "token must be redacted: {rendered}"
1781        );
1782        assert!(rendered.contains("<redacted>"));
1783        assert!(rendered.contains("test-group"));
1784    }
1785}