diff --git a/opentelemetry-etw-logs/CHANGELOG.md b/opentelemetry-etw-logs/CHANGELOG.md index 01b99c1e1..c6b00b56c 100644 --- a/opentelemetry-etw-logs/CHANGELOG.md +++ b/opentelemetry-etw-logs/CHANGELOG.md @@ -2,6 +2,15 @@ ## vNext +- Added a `with_resource_attributes` method to the processor builder, allowing + users to specify which resource attribute keys are exported with each log + record. + - By default, the Resource attributes `"service.name"` and + `"service.instance.id"` continue to be exported as `cloud.roleName` and + `cloud.roleInstance`. + - This feature enables exporting additional resource attributes beyond the + defaults. + ## v0.9.1 - Added `Processor::builder_etw_compat_only()` method that builds a processor using a provider name that is fully compatible with ETW requirements (dropping UserEvents provider name compatibility) by allowing hyphens (`-`). diff --git a/opentelemetry-etw-logs/src/exporter/mod.rs b/opentelemetry-etw-logs/src/exporter/mod.rs index 92b82b609..c34828a95 100644 --- a/opentelemetry-etw-logs/src/exporter/mod.rs +++ b/opentelemetry-etw-logs/src/exporter/mod.rs @@ -1,4 +1,6 @@ +use std::borrow::Cow; use std::cell::RefCell; +use std::collections::HashSet; use std::fmt::Debug; use std::pin::Pin; use std::sync::Arc; @@ -6,7 +8,7 @@ use std::sync::Arc; use tracelogging_dynamic as tld; use opentelemetry::logs::Severity; -use opentelemetry::Key; +use opentelemetry::{logs::AnyValue, Key, Value}; use opentelemetry_sdk::error::{OTelSdkError, OTelSdkResult}; pub(crate) mod common; @@ -26,12 +28,14 @@ thread_local! { struct Resource { pub cloud_role: Option, pub cloud_role_instance: Option, + pub attributes_from_resource: Vec<(Key, AnyValue)>, } pub(crate) struct ETWExporter { provider: Pin>, resource: Resource, options: Options, + resource_attribute_keys: HashSet>, } fn enabled_callback_noop( @@ -65,9 +69,12 @@ impl ETWExporter { provider.as_ref().register(); } + let resource_attribute_keys = options.resource_attribute_keys().clone(); + ETWExporter { provider, resource: Default::default(), + resource_attribute_keys, options, } } @@ -110,7 +117,7 @@ impl ETWExporter { part_a::populate_part_a(event, &self.resource, log_record, field_tag); - let event_id = part_c::populate_part_c(event, log_record, field_tag); + let event_id = part_c::populate_part_c(event, log_record, &self.resource, field_tag); part_b::populate_part_b(event, log_record, otel_level, event_id); @@ -150,12 +157,24 @@ impl opentelemetry_sdk::logs::LogExporter for ETWExporter { } fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) { - self.resource.cloud_role = resource - .get(&Key::from_static_str("service.name")) - .map(|v| v.to_string()); - self.resource.cloud_role_instance = resource - .get(&Key::from_static_str("service.instance.id")) - .map(|v| v.to_string()); + // Clear previous resource attributes + self.resource.attributes_from_resource.clear(); + + // Process resource attributes + for (key, value) in resource.iter() { + // Special handling for cloud role and instance + // as they are used in PartA of the Common Schema format. + if key.as_str() == "service.name" { + self.resource.cloud_role = Some(value.to_string()); + } else if key.as_str() == "service.instance.id" { + self.resource.cloud_role_instance = Some(value.to_string()); + } else if self.resource_attribute_keys.contains(key.as_str()) { + self.resource + .attributes_from_resource + .push((key.clone(), val_to_any_value(value))); + } + // Other attributes are ignored + } } fn shutdown(&self) -> OTelSdkResult { @@ -169,6 +188,16 @@ impl opentelemetry_sdk::logs::LogExporter for ETWExporter { } } +fn val_to_any_value(val: &Value) -> AnyValue { + match val { + Value::Bool(b) => AnyValue::Boolean(*b), + Value::I64(i) => AnyValue::Int(*i), + Value::F64(f) => AnyValue::Double(*f), + Value::String(s) => AnyValue::String(s.clone()), + _ => AnyValue::String("".into()), + } +} + #[cfg(test)] mod tests { use opentelemetry_sdk::logs::LogExporter; @@ -224,6 +253,60 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_event_resources_with_custom_attributes() { + use opentelemetry::logs::LogRecord; + use opentelemetry::KeyValue; + + let mut log_record = common::test_utils::new_sdk_log_record(); + log_record.set_event_name("event-name"); + + // Create exporter with custom resource attributes + let options = Options::new("test_provider") + .with_resource_attributes(vec!["custom_attribute1", "custom_attribute2"]); + + let mut exporter = ETWExporter::new(options); + + exporter.set_resource( + &opentelemetry_sdk::Resource::builder() + .with_attributes([ + KeyValue::new("service.name", "test-service"), + KeyValue::new("service.instance.id", "test-instance"), + KeyValue::new("custom_attribute1", "value1"), + KeyValue::new("custom_attribute2", "value2"), + KeyValue::new("custom_attribute3", "value3"), // This should be ignored + ]) + .build(), + ); + + // Verify that only the configured attributes are stored + assert_eq!( + exporter.resource.cloud_role, + Some("test-service".to_string()) + ); + assert_eq!( + exporter.resource.cloud_role_instance, + Some("test-instance".to_string()) + ); + assert_eq!(exporter.resource.attributes_from_resource.len(), 2); + + // Check that the correct attributes are stored + let attrs: std::collections::HashMap = exporter + .resource + .attributes_from_resource + .iter() + .map(|(k, v)| (k.as_str().to_string(), format!("{:?}", v))) + .collect(); + assert!(attrs.contains_key("custom_attribute1")); + assert!(attrs.contains_key("custom_attribute2")); + assert!(!attrs.contains_key("custom_attribute3")); + + let instrumentation = common::test_utils::new_instrumentation_scope(); + let result = exporter.export_log_data(&log_record, &instrumentation); + + assert!(result.is_ok()); + } + #[test] fn test_debug() { let exporter = common::test_utils::new_etw_exporter(); diff --git a/opentelemetry-etw-logs/src/exporter/options.rs b/opentelemetry-etw-logs/src/exporter/options.rs index 55d5f0d9f..dad00ffea 100644 --- a/opentelemetry-etw-logs/src/exporter/options.rs +++ b/opentelemetry-etw-logs/src/exporter/options.rs @@ -1,5 +1,6 @@ use opentelemetry_sdk::logs::SdkLogRecord; use std::borrow::Cow; +use std::collections::HashSet; use std::error::Error; type BoxedEventNameCallback = Box; @@ -8,6 +9,7 @@ type BoxedEventNameCallback = Box; pub(crate) struct Options { provider_name: Cow<'static, str>, event_name_callback: Option, + resource_attribute_keys: HashSet>, } impl Options { @@ -15,6 +17,7 @@ impl Options { Options { provider_name: provider_name.into(), event_name_callback: None, + resource_attribute_keys: HashSet::new(), } } @@ -23,6 +26,21 @@ impl Options { &self.provider_name } + /// Returns the resource attribute keys that will be exported with each log record. + pub(crate) fn resource_attribute_keys(&self) -> &HashSet> { + &self.resource_attribute_keys + } + + /// Sets the resource attributes for the exporter. + pub(crate) fn with_resource_attributes(mut self, attributes: I) -> Self + where + I: IntoIterator, + S: Into>, + { + self.resource_attribute_keys = attributes.into_iter().map(|s| s.into()).collect(); + self + } + /// Returns the default event name that will be used for the ETW events. pub(crate) fn default_event_name(&self) -> &str { "Log" diff --git a/opentelemetry-etw-logs/src/exporter/part_c.rs b/opentelemetry-etw-logs/src/exporter/part_c.rs index ccbdb5c4b..d82cd88eb 100644 --- a/opentelemetry-etw-logs/src/exporter/part_c.rs +++ b/opentelemetry-etw-logs/src/exporter/part_c.rs @@ -6,6 +6,7 @@ pub(crate) const EVENT_ID: &str = "event_id"; pub(crate) fn populate_part_c( event: &mut tld::EventBuilder, log_record: &opentelemetry_sdk::logs::SdkLogRecord, + resource: &super::Resource, field_tag: u32, ) -> Option { //populate CS PartC @@ -25,9 +26,17 @@ pub(crate) fn populate_part_c( } } + // Count resource attributes + cs_c_count += resource.attributes_from_resource.len(); + // If there are additional PartC attributes, add them to the event if cs_c_count > 0 { - event.add_struct("PartC", cs_c_count, field_tag); + event.add_struct("PartC", cs_c_count.try_into().unwrap_or(u8::MAX), field_tag); + + // Add resource attributes first + for (key, value) in &resource.attributes_from_resource { + super::common::add_attribute_to_event(event, key, value); + } // TODO: This 2nd iteration is not optimal, and can be optimized for (key, value) in log_record.attributes_iter() { diff --git a/opentelemetry-etw-logs/src/lib.rs b/opentelemetry-etw-logs/src/lib.rs index 2fc6c3c0f..87a6d0269 100644 --- a/opentelemetry-etw-logs/src/lib.rs +++ b/opentelemetry-etw-logs/src/lib.rs @@ -1,5 +1,64 @@ //! The ETW exporter will enable applications to use OpenTelemetry API //! to capture the telemetry events, and write them to the ETW subsystem. +//! +//! ## Resource Attribute Handling +//! +//! **Important**: By default, resource attributes are NOT exported with log records. +//! The ETW exporter only automatically exports these specific resource attributes: +//! +//! - **`service.name`** → Exported as `cloud.roleName` in PartA of Common Schema +//! - **`service.instance.id`** → Exported as `cloud.roleInstance` in PartA of Common Schema +//! +//! All other resource attributes are ignored unless explicitly specified. +//! +//! ### Opting in to Additional Resource Attributes +//! +//! To export additional resource attributes, use the `with_resource_attributes()` method: +//! +//! ```rust +//! use opentelemetry_sdk::logs::SdkLoggerProvider; +//! use opentelemetry_sdk::Resource; +//! use opentelemetry_etw_logs::Processor; +//! use opentelemetry::KeyValue; +//! +//! let etw_processor = Processor::builder("myprovider") +//! // Only export specific resource attributes +//! .with_resource_attributes(["custom_attribute1", "custom_attribute2"]) +//! .build() +//! .unwrap(); +//! +//! let provider = SdkLoggerProvider::builder() +//! .with_resource( +//! Resource::builder_empty() +//! .with_service_name("example") +//! .with_attribute(KeyValue::new("custom_attribute1", "value1")) +//! .with_attribute(KeyValue::new("custom_attribute2", "value2")) +//! .with_attribute(KeyValue::new("custom_attribute3", "value3")) // This won't be exported +//! .build(), +//! ) +//! .with_log_processor(etw_processor) +//! .build(); +//! ``` +//! +//! ### Performance Considerations for ETW +//! +//! **Warning**: Each specified resource attribute will be serialized and sent +//! with EVERY log record. This is different from OTLP exporters where resource +//! attributes are serialized once per batch. Consider the performance impact +//! when selecting which attributes to export. +//! +//! **Recommendation**: Be selective about which resource attributes to export. +//! Since ETW writes to a local kernel buffer and requires a local +//! listener/agent, the agent can often deduce many resource attributes without +//! requiring them to be sent with each log: +//! +//! - **Infrastructure attributes** (datacenter, region, availability zone) can +//! be determined by the local agent. +//! - **Host attributes** (hostname, IP address, OS version) are available locally. +//! - **Deployment attributes** (environment, cluster) may be known to the agent. +//! +//! Focus on attributes that are truly specific to your application instance +//! and cannot be easily determined by the local agent. #![warn(missing_debug_implementations, missing_docs)] diff --git a/opentelemetry-etw-logs/src/processor.rs b/opentelemetry-etw-logs/src/processor.rs index 1311ef351..444b225fa 100644 --- a/opentelemetry-etw-logs/src/processor.rs +++ b/opentelemetry-etw-logs/src/processor.rs @@ -2,6 +2,7 @@ use opentelemetry::InstrumentationScope; use opentelemetry_sdk::error::OTelSdkResult; use opentelemetry_sdk::logs::{LogBatch, LogExporter, SdkLogRecord}; use opentelemetry_sdk::Resource; +use std::borrow::Cow; use std::error::Error; use std::fmt::Debug; @@ -153,6 +154,44 @@ impl ProcessorBuilder { self } + /// Sets the resource attributes for the processor. + /// + /// This specifies which resource attributes should be exported with each log record. + /// + /// # Performance Considerations + /// + /// **Warning**: Each specified resource attribute will be serialized and sent + /// with EVERY log record. This is different from OTLP exporters where resource + /// attributes are serialized once per batch. Consider the performance impact + /// when selecting which attributes to export. + /// + /// # Best Practices for ETW + /// + /// **Recommendation**: Be selective about which resource attributes to export. + /// Since ETW writes to a local kernel buffer and requires a local + /// listener/agent, the agent can often deduce many resource attributes without + /// requiring them to be sent with each log: + /// + /// - **Infrastructure attributes** (datacenter, region, availability zone) can + /// be determined by the local agent. + /// - **Host attributes** (hostname, IP address, OS version) are available locally. + /// - **Deployment attributes** (environment, cluster) may be known to the agent. + /// + /// Focus on attributes that are truly specific to your application instance + /// and cannot be easily determined by the local agent. + /// + /// Nevertheless, if there are attributes that are fixed and must be emitted + /// with every log, modeling them as Resource attributes and using this method + /// is much more efficient than emitting them explicitly with every log. + pub fn with_resource_attributes(mut self, attributes: I) -> Self + where + I: IntoIterator, + S: Into>, + { + self.options = self.options.with_resource_attributes(attributes); + self + } + /// Builds the processor with given options, returning `Error` if it fails. pub fn build(self) -> Result> { self.validate()?; @@ -310,6 +349,42 @@ mod tests { }); } + #[test] + fn tracing_integration_test_with_resource_attributes() { + use opentelemetry::KeyValue; + use opentelemetry_appender_tracing::layer; + use opentelemetry_sdk::Resource; + use tracing::error; + use tracing_subscriber::prelude::*; + + let processor = Processor::builder("provider_name") + .with_resource_attributes(["custom_attribute1", "custom_attribute2"]) + .build() + .unwrap(); + + let logger_provider = SdkLoggerProvider::builder() + .with_resource( + Resource::builder() + .with_service_name("test-service") + .with_attribute(KeyValue::new("custom_attribute1", "value1")) + .with_attribute(KeyValue::new("custom_attribute2", "value2")) + .with_attribute(KeyValue::new("custom_attribute3", "value3")) // Should be ignored + .build(), + ) + .with_log_processor(processor) + .build(); + + let layer = layer::OpenTelemetryTracingBridge::new(&logger_provider); + let _guard = tracing_subscriber::registry().with(layer).set_default(); + + error!( + name: "event-name", + event_id = 20, + user_name = "otel user", + user_email = "otel@opentelemetry.io" + ); + } + #[test] fn test_validate_empty_name() { assert_eq!( @@ -427,4 +502,34 @@ mod tests { ); assert!(result.is_ok()); } + + #[test] + fn test_with_resource_attributes() { + use opentelemetry::KeyValue; + use opentelemetry_sdk::logs::LogProcessor; + use opentelemetry_sdk::Resource; + + let processor = Processor::builder("test_provider") + .with_resource_attributes(vec!["custom_attribute1", "custom_attribute2"]) + .build() + .unwrap(); + + let mut processor = processor; // Make mutable for set_resource + + let resource = Resource::builder() + .with_attributes([ + KeyValue::new("service.name", "test-service"), + KeyValue::new("service.instance.id", "test-instance"), + KeyValue::new("custom_attribute1", "value1"), + KeyValue::new("custom_attribute2", "value2"), + KeyValue::new("custom_attribute3", "value3"), // This should be ignored + ]) + .build(); + + processor.set_resource(&resource); + + // Test that the processor was created successfully + // The actual resource attributes will be tested in the exporter tests + assert!(processor.force_flush().is_ok()); + } }