Skip to content
Merged
9 changes: 9 additions & 0 deletions opentelemetry-etw-logs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.10.0

- Bump opentelemetry and opentelemetry_sdk versions to 0.31
Expand Down
101 changes: 93 additions & 8 deletions opentelemetry-etw-logs/src/exporter/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashSet;
use std::fmt::Debug;
use std::pin::Pin;
use std::sync::Arc;

use tracelogging_dynamic as tld;

use opentelemetry::logs::Severity;
use opentelemetry::Key;
use opentelemetry::{logs::AnyValue, otel_debug, Key, Value};
use opentelemetry_sdk::error::{OTelSdkError, OTelSdkResult};

pub(crate) mod common;
Expand All @@ -26,12 +28,14 @@ thread_local! {
struct Resource {
pub cloud_role: Option<String>,
pub cloud_role_instance: Option<String>,
pub attributes_from_resource: Vec<(Key, AnyValue)>,
}

pub(crate) struct ETWExporter {
provider: Pin<Arc<tld::Provider>>,
resource: Resource,
options: Options,
resource_attribute_keys: HashSet<Cow<'static, str>>,
}

fn enabled_callback_noop(
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -150,12 +157,26 @@ 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)));
} else {
// Other attributes are ignored
otel_debug!(name: "UserEvents.ResourceAttributeIgnored", key = key.as_str(), message = "To include this attribute, add it via with_resource_attributes() method in the processor builder.");
}
}
}

fn shutdown(&self) -> OTelSdkResult {
Expand All @@ -169,6 +190,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;
Expand Down Expand Up @@ -224,6 +255,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<String, String> = 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();
Expand Down
18 changes: 18 additions & 0 deletions opentelemetry-etw-logs/src/exporter/options.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use opentelemetry_sdk::logs::SdkLogRecord;
use std::borrow::Cow;
use std::collections::HashSet;
use std::error::Error;

type BoxedEventNameCallback = Box<dyn EventNameCallback>;
Expand All @@ -8,13 +9,15 @@ type BoxedEventNameCallback = Box<dyn EventNameCallback>;
pub(crate) struct Options {
provider_name: Cow<'static, str>,
event_name_callback: Option<BoxedEventNameCallback>,
resource_attribute_keys: HashSet<Cow<'static, str>>,
}

impl Options {
pub(crate) fn new(provider_name: impl Into<Cow<'static, str>>) -> Options {
Options {
provider_name: provider_name.into(),
event_name_callback: None,
resource_attribute_keys: HashSet::new(),
}
}

Expand All @@ -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<Cow<'static, str>> {
&self.resource_attribute_keys
}

/// Sets the resource attributes for the exporter.
pub(crate) fn with_resource_attributes<I, S>(mut self, attributes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'static, str>>,
{
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"
Expand Down
11 changes: 10 additions & 1 deletion opentelemetry-etw-logs/src/exporter/part_c.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> {
//populate CS PartC
Expand All @@ -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() {
Expand Down
59 changes: 59 additions & 0 deletions opentelemetry-etw-logs/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)]

Expand Down
Loading