Repository: tokio-rs/tracing-opentelemetry
Pull request #201: feat: Add OpenTelemetrySpanExt methods for direct span event creation
Author: login: alessandrobologna
Created: 2025-04-26T17:55:10Z | Merged: 2025-05-02T21:38:01Z

This PR adds methods to `OpenTelemetrySpanExt` for directly adding OpenTelemetry span events, addressing the limitations of using `tracing::event!` for events with dynamic attributes.

Fixes #200

## Motivation
As discussed in issue #200, the current `tracing::event!` macro requires attribute keys to be known at compile time. This makes it difficult or impossible to record OpenTelemetry span events with dynamic attributes (e.g., from processing arbitrary JSON payloads) without resorting to lossy serialization, which hinders observability.

This change provides a way to directly add `opentelemetry::trace::Event` objects with dynamic `Vec<KeyValue>` attributes, similar to how `set_attribute` allows setting dynamic span attributes. This improves flexibility, makes porting code easier, and aligns better with the capabilities of the OpenTelemetry Span API.

## Solution
This PR extends the `OpenTelemetrySpanExt` trait with two new methods:

1.  `add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>) where T: Into<Cow<'static, str>>`: Adds an OpenTelemetry span event with the current timestamp.
2.  `add_otel_span_event_with_timestamp<T>(&self, name: T, timestamp: SystemTime, attributes: Vec<KeyValue>) where T: Into<Cow<'static, str>>`: Adds an OpenTelemetry span event with a specified timestamp.

The implementation uses the existing `with_subscriber` -> `downcast_ref::<WithContext>` -> `with_context` pattern found in other `OpenTelemetrySpanExt` methods (`set_attribute`, `set_status`) to access the internal `OtelData` builder and push the event.

The name `add_otel_span_event` was chosen over `add_event` to avoid potential confusion with the `tracing::event!` macro within the context of this crate.

This PR includes:
- The trait method definitions and implementations in `src/span_ext.rs`.
- Corresponding test cases in `tests/span_ext.rs`.
- Updates to `README.md` documentation.
- A new example file `examples/opentelemetry-span-event.rs`.
- Updated `CHANGELOG.md`.

Linked issue #200: Feature Request: Support for Adding OpenTelemetry Span Events with Dynamic Attributes
## Feature Request: Support for Adding OpenTelemetry Span Events with Dynamic Attributes

### Motivation

<!--
Please describe the use case(s) or other motivation for the new feature.
-->
Currently, the primary way to add span events within `tracing-opentelemetry` is via the `tracing::event!` macro. While this works well for statically defined attributes, it presents a limitation when needing to record span events where attribute keys are determined at runtime.

**Use Case:** Processing arbitrary structured data (e.g., JSON payloads for configuration updates, user interactions) where the specific fields within the payload are not known at compile time. The goal is to record these dynamic key-value pairs as distinct OpenTelemetry span event attributes so that they can be used for querying and filtering in the various observability backends.

**Problem:** The `tracing::event!` macro requires static field names. The only workaround for dynamic keys is to serialize the attributes (e.g., to JSON) into a single string field. This is often not optimal, as it may prevent or make it more difficult to do filtering, aggregation, or searching based on the actual dynamic keys within the backend system. It also makes it more difficult to port otel instrumented code from other languages to rust, or creating internal libraries that provide similar features across platforms.

### Proposal

<!--
How should the new feature be implemented, and why? Add any considered
drawbacks.
-->
I propose extending the `OpenTelemetrySpanExt` trait with methods to directly add OpenTelemetry span events, mirroring the flexibility of the underlying OpenTelemetry API:

```rust
trait OpenTelemetrySpanExt {
    // ... existing methods ...

    fn add_otel_span_event(&self, name: String, attributes: Vec<KeyValue>);
    fn add_otel_span_event_with_timestamp(&self, name: String, timestamp: SystemTime, attributes: Vec<KeyValue>);
}
```
The trait methods could be just called `add_event` for consistency with the otel Span trait, but maybe that could be confusing with tracing event, so I thought a more specific name may make more sense in this context.

**Implementation:** These methods would leverage the existing `with_subscriber` -> `downcast_ref::<WithContext>` -> `with_context` pattern (similar to `set_attribute` and `set_status`) to access the internal `OtelData` for the span and append a constructed `opentelemetry::trace::Event` to its `builder.events` list.

**Why:** This provides a direct, idiomatic way within the `tracing-opentelemetry` ecosystem to handle span events with dynamic attributes, avoiding lossy serialization. It aligns with the precedent set by `set_attribute` for handling dynamic span data.

**Drawbacks:** Minimal drawbacks anticipated. It adds two methods to the trait surface area. It relies on the `with_subscriber` mechanism working correctly in the user's setup.


### Alternatives

<!--
Are there other ways to solve this problem that you've considered? What are
their potential drawbacks? Why was the proposed solution chosen over these
alternatives?
-->

1.  **`tracing::event!` + Serialization:**
    *   *Drawback:* As mentioned, serializing dynamic attributes into a string significantly harms observability and querying capabilities.
2.  **Manual OpenTelemetry Context Management:**
    *   *Drawback:* Managing OpenTelemetry context manually alongside `tracing` context is complex, error-prone, and would certainly defeat much of the purpose of using `tracing-opentelemetry`.

I think the addition to `OpenTelemetrySpanExt` is preferred because it directly addresses the limitation for dynamic attributes within the existing crate structure, is consistent with other extension methods like `set_attribute`, or `add_link` and avoids the major drawbacks of the alternatives.

*(I have a working implementation of this in a local fork and would be happy to open a PR if this feature request is accepted.)*
Linked issue comments: user: login: djc
created_at: 2025-04-25T09:58:10Z
body: The tracing-opentelemetry crate is currently pretty low on maintenance resources. While I'm sympathetic to your use case and wouldn't mind reviewing your PR if it's not too large, I'm also aware of some efforts in the opentelemetry-rust community to work on a better integration between tracing and the opentelemetry crates. If you intend to build something future proof (rather than a short-term solution), it might make sense to figure out whether your solution fits in with their vision.
author_association: COLLABORATOR
user: login: alessandrobologna
created_at: 2025-04-25T13:48:53Z
body: Completely understand, and yes, I am following closely what's happening in the opentelemetry-rust community, but as of today I don't think there's a replacement for what this crate does (integrating the existing Tokio tracing instrumented crates with OTel). There's [something for logs](https://github.com/open-telemetry/opentelemetry-rust/tree/main/opentelemetry-appender-tracing), but not for span and traces. And there are, as of last count, [236 other crates](https://crates.io/crates/tracing-opentelemetry/reverse_dependencies) that take advantage of this integration.
I am going to push a PR later today, it's a small one.
author_association: CONTRIBUTOR

Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18c2159..ccbb453 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Unreleased
 
+### Added
+
+- Add `OpenTelemetrySpanExt::add_event` and `OpenTelemetrySpanExt::add_event_with_timestamp` 
+  functions to allow adding OpenTelemetry events directly to a `tracing::Span`, enabling the use of dynamic attribute keys 
+  and custom event timestamps.
+
 # 0.30.0 (March 23, 2025)
 
 ### Breaking Changes
diff --git a/README.md b/README.md
index ef46f69..c66549b 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,10 @@ The crate provides the following types:
 
 * [`OpenTelemetryLayer`] adds OpenTelemetry context to all `tracing` [span]s.
 * [`OpenTelemetrySpanExt`] allows OpenTelemetry parent trace information to be
-  injected and extracted from a `tracing` [span].
+  injected and extracted from a `tracing` [span]. It also provides methods
+  to directly set span attributes (`set_attribute`), span status (`set_status`),
+  and add OpenTelemetry events with dynamic attributes using the current time
+  (`add_event`) or a specific timestamp (`add_event_with_timestamp`).
 
 [`OpenTelemetryLayer`]: https://docs.rs/tracing-opentelemetry/latest/tracing_opentelemetry/struct.OpenTelemetryLayer.html
 [`OpenTelemetrySpanExt`]: https://docs.rs/tracing-opentelemetry/latest/tracing_opentelemetry/trait.OpenTelemetrySpanExt.html
diff --git a/src/lib.rs b/src/lib.rs
index 897fcb4..d835331 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,8 +20,8 @@
 //! special fields are:
 //!
 //! * `otel.name`: Override the span name sent to OpenTelemetry exporters.
-//!    Setting this field is useful if you want to display non-static information
-//!    in your span name.
+//!   Setting this field is useful if you want to display non-static information
+//!   in your span name.
 //! * `otel.kind`: Set the span kind to one of the supported OpenTelemetry [span kinds].
 //! * `otel.status_code`: Set the span status code to one of the supported OpenTelemetry [span status codes].
 //! * `otel.status_message`: Set the span status message.
diff --git a/src/span_ext.rs b/src/span_ext.rs
index 3dddd64..cb3de10 100644
--- a/src/span_ext.rs
+++ b/src/span_ext.rs
@@ -1,5 +1,10 @@
 use crate::layer::WithContext;
-use opentelemetry::{trace::SpanContext, trace::Status, Context, Key, KeyValue, Value};
+use opentelemetry::{
+    time,
+    trace::{SpanContext, Status},
+    Context, Key, KeyValue, Value,
+};
+use std::{borrow::Cow, time::SystemTime};
 
 /// Utility functions to allow tracing [`Span`]s to accept and return
 /// [OpenTelemetry] [`Context`]s.
@@ -152,20 +157,76 @@ pub trait OpenTelemetrySpanExt {
     /// app_root.set_status(Status::Ok);
     /// ```            
     fn set_status(&self, status: Status);
+
+    /// Adds an OpenTelemetry event directly to this span, bypassing `tracing::event!`.
+    /// This allows for adding events with dynamic attribute keys, similar to `set_attribute` for span attributes.
+    /// Events are added with the current timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "processing_request");
+    ///
+    /// let dynamic_attrs = vec![
+    ///     KeyValue::new("job_id", "job-123"),
+    ///     KeyValue::new("user.id", "user-xyz"),
+    /// ];
+    ///
+    /// // Add event using the extension method
+    /// app_root.add_event("job_started".to_string(), dynamic_attrs);
+    ///
+    /// // ... perform work ...
+    ///
+    /// app_root.add_event("job_completed", vec![KeyValue::new("status", "success")]);
+    /// ```
+    fn add_event(&self, name: impl Into<Cow<'static, str>>, attributes: Vec<KeyValue>);
+
+    /// Adds an OpenTelemetry event with a specific timestamp directly to this span.
+    /// Similar to `add_event`, but allows overriding the event timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    /// use std::time::{Duration, SystemTime};
+    /// use std::borrow::Cow;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "historical_event_processing");
+    ///
+    /// let event_time = SystemTime::now() - Duration::from_secs(60);
+    /// let event_attrs = vec![KeyValue::new("record_id", "rec-456")];
+    /// let event_name: Cow<'static, str> = "event_from_past".into();
+    ///
+    /// app_root.add_event_with_timestamp(event_name, event_time, event_attrs);
+    /// ```
+    fn add_event_with_timestamp(
+        &self,
+        name: impl Into<Cow<'static, str>>,
+        timestamp: SystemTime,
+        attributes: Vec<KeyValue>,
+    );
 }
 
 impl OpenTelemetrySpanExt for tracing::Span {
     fn set_parent(&self, cx: Context) {
         let mut cx = Some(cx);
         self.with_subscriber(move |(id, subscriber)| {
-            if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
-                get_context.with_context(subscriber, id, move |data, _tracer| {
-                    if let Some(cx) = cx.take() {
-                        data.parent_cx = cx;
-                        data.builder.sampling_result = None;
-                    }
-                });
-            }
+            let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                return;
+            };
+            get_context.with_context(subscriber, id, move |data, _tracer| {
+                let Some(cx) = cx.take() else {
+                    return;
+                };
+                data.parent_cx = cx;
+                data.builder.sampling_result = None;
+            });
         });
     }
 
@@ -178,18 +239,20 @@ impl OpenTelemetrySpanExt for tracing::Span {
             let mut cx = Some(cx);
             let mut att = Some(attributes);
             self.with_subscriber(move |(id, subscriber)| {
-                if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
-                    get_context.with_context(subscriber, id, move |data, _tracer| {
-                        if let Some(cx) = cx.take() {
-                            let attr = att.take().unwrap_or_default();
-                            let follows_link = opentelemetry::trace::Link::new(cx, attr, 0);
-                            data.builder
-                                .links
-                                .get_or_insert_with(|| Vec::with_capacity(1))
-                                .push(follows_link);
-                        }
-                    });
-                }
+                let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                    return;
+                };
+                get_context.with_context(subscriber, id, move |data, _tracer| {
+                    let Some(cx) = cx.take() else {
+                        return;
+                    };
+                    let attr = att.take().unwrap_or_default();
+                    let follows_link = opentelemetry::trace::Link::new(cx, attr, 0);
+                    data.builder
+                        .links
+                        .get_or_insert_with(|| Vec::with_capacity(1))
+                        .push(follows_link);
+                });
             });
         }
     }
@@ -197,11 +260,12 @@ impl OpenTelemetrySpanExt for tracing::Span {
     fn context(&self) -> Context {
         let mut cx = None;
         self.with_subscriber(|(id, subscriber)| {
-            if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
-                get_context.with_context(subscriber, id, |builder, tracer| {
-                    cx = Some(tracer.sampled_context(builder));
-                })
-            }
+            let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                return;
+            };
+            get_context.with_context(subscriber, id, |builder, tracer| {
+                cx = Some(tracer.sampled_context(builder));
+            })
         });
 
         cx.unwrap_or_default()
@@ -209,32 +273,60 @@ impl OpenTelemetrySpanExt for tracing::Span {
 
     fn set_attribute(&self, key: impl Into<Key>, value: impl Into<Value>) {
         self.with_subscriber(move |(id, subscriber)| {
-            if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
-                let mut key = Some(key.into());
-                let mut value = Some(value.into());
-                get_context.with_context(subscriber, id, move |builder, _| {
-                    if builder.builder.attributes.is_none() {
-                        builder.builder.attributes = Some(Default::default());
-                    }
-                    builder
-                        .builder
-                        .attributes
-                        .as_mut()
-                        .unwrap()
-                        .push(KeyValue::new(key.take().unwrap(), value.take().unwrap()));
-                })
-            }
+            let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                return;
+            };
+            let mut key = Some(key.into());
+            let mut value = Some(value.into());
+            get_context.with_context(subscriber, id, move |builder, _| {
+                if builder.builder.attributes.is_none() {
+                    builder.builder.attributes = Some(Default::default());
+                }
+                builder
+                    .builder
+                    .attributes
+                    .as_mut()
+                    .unwrap()
+                    .push(KeyValue::new(key.take().unwrap(), value.take().unwrap()));
+            })
         });
     }
 
     fn set_status(&self, status: Status) {
         self.with_subscriber(move |(id, subscriber)| {
             let mut status = Some(status);
-            if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
-                get_context.with_context(subscriber, id, move |builder, _| {
-                    builder.builder.status = status.take().unwrap();
-                });
-            }
+            let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                return;
+            };
+            get_context.with_context(subscriber, id, move |builder, _| {
+                builder.builder.status = status.take().unwrap();
+            });
+        });
+    }
+
+    fn add_event(&self, name: impl Into<Cow<'static, str>>, attributes: Vec<KeyValue>) {
+        self.add_event_with_timestamp(name, time::now(), attributes);
+    }
+
+    fn add_event_with_timestamp(
+        &self,
+        name: impl Into<Cow<'static, str>>,
+        timestamp: SystemTime,
+        attributes: Vec<KeyValue>,
+    ) {
+        self.with_subscriber(move |(id, subscriber)| {
+            let mut event = Some(opentelemetry::trace::Event::new(
+                name, timestamp, attributes, 0,
+            ));
+            let Some(get_context) = subscriber.downcast_ref::<WithContext>() else {
+                return;
+            };
+            get_context.with_context(subscriber, id, move |data, _tracer| {
+                let Some(event) = event.take() else {
+                    return;
+                };
+                data.builder.events.get_or_insert_with(Vec::new).push(event);
+            });
         });
     }
 }
diff --git a/tests/metrics_publishing.rs b/tests/metrics_publishing.rs
index 496bf85..c37f46a 100644
--- a/tests/metrics_publishing.rs
+++ b/tests/metrics_publishing.rs
@@ -625,7 +625,7 @@ where
                                 .data_points
                                 .iter()
                                 .map(|data_point| data_point.value)
-                                .last()
+                                .next_back()
                                 .unwrap()
                         );
 
diff --git a/tests/span_ext.rs b/tests/span_ext.rs
index 8ba8bfc..4c9db4c 100644
--- a/tests/span_ext.rs
+++ b/tests/span_ext.rs
@@ -71,3 +71,97 @@ fn set_status_helper(status: Status) -> SpanData {
 
     spans.iter().find(|s| s.name == "root").unwrap().clone()
 }
+
+#[test]
+fn test_add_event() {
+    let (_tracer, provider, exporter, subscriber) = test_tracer();
+
+    let event_name = "my_event";
+    let event_attrs = vec![
+        opentelemetry::KeyValue::new("event_key_1", "event_value_1"),
+        opentelemetry::KeyValue::new("event_key_2", 123),
+    ];
+
+    tracing::subscriber::with_default(subscriber, || {
+        let root = tracing::debug_span!("root");
+        let _enter = root.enter(); // Enter span to make it current for the event addition
+
+        // Add the event using the new extension method
+        root.add_event(event_name, event_attrs.clone());
+    });
+
+    drop(provider); // flush all spans
+    let spans = exporter.0.lock().unwrap();
+
+    assert_eq!(spans.len(), 1, "Should have exported exactly one span.");
+    let root_span_data = spans.first().unwrap();
+
+    assert_eq!(
+        root_span_data.events.len(),
+        1,
+        "Span should have one event."
+    );
+    let event_data = root_span_data.events.first().unwrap();
+
+    assert_eq!(event_data.name, event_name, "Event name mismatch.");
+    assert_eq!(
+        event_data.attributes, event_attrs,
+        "Event attributes mismatch."
+    );
+}
+
+#[test]
+fn test_add_event_with_timestamp() {
+    use std::time::{Duration, SystemTime};
+
+    let (_tracer, provider, exporter, subscriber) = test_tracer();
+
+    let event_name = "my_specific_time_event";
+    let event_attrs = vec![opentelemetry::KeyValue::new("event_key_a", "value_a")];
+    // Define a specific timestamp (e.g., 10 seconds ago)
+    let specific_timestamp = SystemTime::now() - Duration::from_secs(10);
+
+    tracing::subscriber::with_default(subscriber, || {
+        let root = tracing::debug_span!("root_with_timestamped_event");
+        let _enter = root.enter();
+
+        // Add the event using the new extension method with the specific timestamp
+        root.add_event_with_timestamp(event_name, specific_timestamp, event_attrs.clone());
+    });
+
+    drop(provider); // flush all spans
+    let spans = exporter.0.lock().unwrap();
+
+    assert_eq!(spans.len(), 1, "Should have exported exactly one span.");
+    let root_span_data = spans.first().unwrap();
+
+    assert_eq!(
+        root_span_data.events.len(),
+        1,
+        "Span should have one event."
+    );
+    let event_data = root_span_data.events.first().unwrap();
+
+    assert_eq!(event_data.name, event_name, "Event name mismatch.");
+    assert_eq!(
+        event_data.attributes, event_attrs,
+        "Event attributes mismatch."
+    );
+
+    // Assert the timestamp matches the one we provided
+    // Allow for a small tolerance due to potential precision differences during conversion
+    let timestamp_diff = event_data
+        .timestamp
+        .duration_since(specific_timestamp)
+        .unwrap_or_else(|_| {
+            specific_timestamp
+                .duration_since(event_data.timestamp)
+                .unwrap_or_default()
+        });
+    assert!(
+        timestamp_diff < Duration::from_millis(1),
+        "Timestamp mismatch. Expected: {:?}, Got: {:?}",
+        specific_timestamp,
+        event_data.timestamp
+    );
+}


Reviews:
user: login: djc
body: This is mostly looking good, thanks!

Please keep the clippy commits separate and squash any additional changes into your original commit (might be easier to reorder them so that the clippy commits come first).
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T10:06:28Z
user: login: alessandrobologna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T12:46:26Z
user: login: alessandrobologna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:02:11Z
user: login: alessandrobologna
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:02:28Z
user: login: djc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T13:42:56Z
user: login: djc
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-02T21:37:55Z

Review comments:
diff_hunk: @@ -152,6 +157,63 @@ pub trait OpenTelemetrySpanExt {
     /// app_root.set_status(Status::Ok);
     /// ```            
     fn set_status(&self, status: Status);
+
+    /// Adds an OpenTelemetry event directly to this span, bypassing `tracing::event!`.
+    /// This allows for adding events with dynamic attribute keys, similar to `set_attribute` for span attributes.
+    /// Events are added with the current timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "processing_request");
+    ///
+    /// let dynamic_attrs = vec![
+    ///     KeyValue::new("job_id", "job-123"),
+    ///     KeyValue::new("user.id", "user-xyz"),
+    /// ];
+    ///
+    /// // Add event using the extension method
+    /// app_root.add_otel_span_event("job_started".to_string(), dynamic_attrs);
+    ///
+    /// // ... perform work ...
+    ///
+    /// app_root.add_otel_span_event("job_completed", vec![KeyValue::new("status", "success")]);
+    /// ```
+    fn add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>)
path: src/span_ext.rs
user: login: djc
body: Are there other kinds of events in OTel? The name is a little verbose, so would be nice if we can get away with something a bit shorter like `add_otel_event()`.
created_at: 2025-05-01T10:02:00Z
author_association: COLLABORATOR
diff_hunk: @@ -152,6 +157,63 @@ pub trait OpenTelemetrySpanExt {
     /// app_root.set_status(Status::Ok);
     /// ```            
     fn set_status(&self, status: Status);
+
+    /// Adds an OpenTelemetry event directly to this span, bypassing `tracing::event!`.
+    /// This allows for adding events with dynamic attribute keys, similar to `set_attribute` for span attributes.
+    /// Events are added with the current timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "processing_request");
+    ///
+    /// let dynamic_attrs = vec![
+    ///     KeyValue::new("job_id", "job-123"),
+    ///     KeyValue::new("user.id", "user-xyz"),
+    /// ];
+    ///
+    /// // Add event using the extension method
+    /// app_root.add_otel_span_event("job_started".to_string(), dynamic_attrs);
+    ///
+    /// // ... perform work ...
+    ///
+    /// app_root.add_otel_span_event("job_completed", vec![KeyValue::new("status", "success")]);
+    /// ```
+    fn add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>)
path: src/span_ext.rs
user: login: djc
body: Nit: please use impl trait in arguments consistently where possible, so this should have `name: impl Into<Cow<'static, str>>` (everywhere).
created_at: 2025-05-01T10:02:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,65 @@
+use opentelemetry::trace::TracerProvider as _;
path: examples/opentelemetry-span-event.rs
user: login: djc
body: This seems a little niche so I'd prefer to avoid maintaining an example for it.
created_at: 2025-05-01T10:02:57Z
author_association: COLLABORATOR
diff_hunk: @@ -237,4 +299,33 @@ impl OpenTelemetrySpanExt for tracing::Span {
             }
         });
     }
+
+    fn add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>)
+    where
+        T: Into<Cow<'static, str>>,
+    {
+        self.add_otel_span_event_with_timestamp(name, time::now(), attributes);
+    }
+
+    fn add_otel_span_event_with_timestamp<T>(
+        &self,
+        name: T,
+        timestamp: SystemTime,
+        attributes: Vec<KeyValue>,
+    ) where
+        T: Into<Cow<'static, str>>,
+    {
+        self.with_subscriber(move |(id, subscriber)| {
+            let mut event = Some(opentelemetry::trace::Event::new(
+                name, timestamp, attributes, 0,
+            ));
+            if let Some(get_context) = subscriber.downcast_ref::<WithContext>() {
+                get_context.with_context(subscriber, id, move |data, _tracer| {
+                    if let Some(event) = event.take() {
+                        data.builder.events.get_or_insert_with(Vec::new).push(event);
+                    }
+                });
+            }
path: src/span_ext.rs
user: login: djc
body: Nit: I'm aware this is mostly copy/pasting, but please use let-else to reduce rightward drift where possible. (If you want to fix up the original function too, that would be nice, but please keep it in a separate commit.)
created_at: 2025-05-01T10:04:25Z
author_association: COLLABORATOR
diff_hunk: @@ -1,5 +1,13 @@
 # Unreleased
 
+### Added
+
+- Add `OpenTelemetrySpanExt::add_otel_span_event` function to allow adding OpenTelemetry
+  events directly to a `tracing::Span`, enabling the use of dynamic attribute keys.
+- Add `OpenTelemetrySpanExt::add_otel_span_event_with_timestamp` function, similar to
+  `add_otel_span_event`, but allowing a specific `SystemTime` to be provided for the event.
path: CHANGELOG.md
user: login: djc
body: Nit: I think these changes belong in a single bullet point.
created_at: 2025-05-01T10:06:08Z
author_association: COLLABORATOR
diff_hunk: @@ -1,5 +1,13 @@
 # Unreleased
 
+### Added
+
+- Add `OpenTelemetrySpanExt::add_otel_span_event` function to allow adding OpenTelemetry
+  events directly to a `tracing::Span`, enabling the use of dynamic attribute keys.
+- Add `OpenTelemetrySpanExt::add_otel_span_event_with_timestamp` function, similar to
+  `add_otel_span_event`, but allowing a specific `SystemTime` to be provided for the event.
+- Add `examples/opentelemetry-span-event.rs` demonstrating the usage of the new span event methods.
path: CHANGELOG.md
user: login: djc
body: Don't forget to remove this when you remove the example.
created_at: 2025-05-01T10:06:20Z
author_association: COLLABORATOR
diff_hunk: @@ -152,6 +157,63 @@ pub trait OpenTelemetrySpanExt {
     /// app_root.set_status(Status::Ok);
     /// ```            
     fn set_status(&self, status: Status);
+
+    /// Adds an OpenTelemetry event directly to this span, bypassing `tracing::event!`.
+    /// This allows for adding events with dynamic attribute keys, similar to `set_attribute` for span attributes.
+    /// Events are added with the current timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "processing_request");
+    ///
+    /// let dynamic_attrs = vec![
+    ///     KeyValue::new("job_id", "job-123"),
+    ///     KeyValue::new("user.id", "user-xyz"),
+    /// ];
+    ///
+    /// // Add event using the extension method
+    /// app_root.add_otel_span_event("job_started".to_string(), dynamic_attrs);
+    ///
+    /// // ... perform work ...
+    ///
+    /// app_root.add_otel_span_event("job_completed", vec![KeyValue::new("status", "success")]);
+    /// ```
+    fn add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>)
path: src/span_ext.rs
user: login: alessandrobologna
body: Yes, there's a new [WIP spec for OTel Events](https://opentelemetry.io/docs/specs/semconv/general/events/), as a new signal type. More background about it in here: 

- https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/0202-events-and-logs-api.md
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/0265-event-vision.md
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/4430-span-event-api-deprecation-plan.md

I think though that it's still far away from being widely implemented and adopted, so I am happy with the span events API for now (and I don't necessarily agree that it was a good idea to introduce a new signal type, but that's digressing).

I agree that the name is verbose, but `add_otel_event` would then be a little ambiguous when those specs are more widely adopted and known. Alternatively, we could use `add_event` (as in the OTel Span trait, in the same way that you have `add_link`) and since this is a method of a trait that is meant to "wrap" an OTel span, maybe it would be clear that is not a Tracing event. Let me know your preference and I will update the PR. 

created_at: 2025-05-01T12:46:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,65 @@
+use opentelemetry::trace::TracerProvider as _;
path: examples/opentelemetry-span-event.rs
user: login: alessandrobologna
body: Ok I can remove it
created_at: 2025-05-01T13:02:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,13 @@
 # Unreleased
 
+### Added
+
+- Add `OpenTelemetrySpanExt::add_otel_span_event` function to allow adding OpenTelemetry
+  events directly to a `tracing::Span`, enabling the use of dynamic attribute keys.
+- Add `OpenTelemetrySpanExt::add_otel_span_event_with_timestamp` function, similar to
+  `add_otel_span_event`, but allowing a specific `SystemTime` to be provided for the event.
+- Add `examples/opentelemetry-span-event.rs` demonstrating the usage of the new span event methods.
path: CHANGELOG.md
user: login: alessandrobologna
body: will do
created_at: 2025-05-01T13:02:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,6 +157,63 @@ pub trait OpenTelemetrySpanExt {
     /// app_root.set_status(Status::Ok);
     /// ```            
     fn set_status(&self, status: Status);
+
+    /// Adds an OpenTelemetry event directly to this span, bypassing `tracing::event!`.
+    /// This allows for adding events with dynamic attribute keys, similar to `set_attribute` for span attributes.
+    /// Events are added with the current timestamp.
+    ///
+    /// # Examples
+    ///
+    /// ```rust
+    /// use opentelemetry::{KeyValue};
+    /// use tracing_opentelemetry::OpenTelemetrySpanExt;
+    /// use tracing::Span;
+    ///
+    /// let app_root = tracing::span!(tracing::Level::INFO, "processing_request");
+    ///
+    /// let dynamic_attrs = vec![
+    ///     KeyValue::new("job_id", "job-123"),
+    ///     KeyValue::new("user.id", "user-xyz"),
+    /// ];
+    ///
+    /// // Add event using the extension method
+    /// app_root.add_otel_span_event("job_started".to_string(), dynamic_attrs);
+    ///
+    /// // ... perform work ...
+    ///
+    /// app_root.add_otel_span_event("job_completed", vec![KeyValue::new("status", "success")]);
+    /// ```
+    fn add_otel_span_event<T>(&self, name: T, attributes: Vec<KeyValue>)
path: src/span_ext.rs
user: login: djc
body: Yeah, let's do `add_event()` for now -- it seems there will still be sufficient opportunities to make breaking changes should the need arise.
created_at: 2025-05-01T13:42:55Z
author_association: COLLABORATOR

PR comments:
user: login: alessandrobologna
created_at: 2025-04-26T18:39:49Z
body: Hi @djc (and other maintainers),
Just wanted to add a couple of notes about this PR:
Clippy Fix Commit: You'll notice the last commit (c54ba96) addresses a Clippy lint (`double_ended_iterator_last` in `tests/metrics_publishing.rs`). This warning was surfaced by the CI checks (-D warnings) and appeared unrelated to the core feature addition, so I kept it as a separate commit for clarity.
Versioning & Changelog: I haven't bumped the crate version in Cargo.toml, and the changelog entries for the new feature and example are currently under the ## Unreleased section in CHANGELOG.md.

I'm happy to handle the version bump and finalize the changelog entry if after your review this PR is ready to merge. I wasn't sure about the appropriate version target for this addition, since it's adding new API surface (non-breaking) to a 0.x crate, would you typically target a minor version bump (e.g., 0.31.0) or a patch release (e.g., 0.30.1)? 
Happy to follow whatever the project's policy is.
Thanks for taking a look!
author_association: CONTRIBUTOR
user: login: alessandrobologna
created_at: 2025-05-01T14:54:13Z
body: @djc I think I have addressed all the changes requested. Let me know if you spot something else.
author_association: CONTRIBUTOR
user: login: djc
created_at: 2025-05-02T08:23:11Z
body: As mentioned:

> squash any additional changes into your original commit

Please squash the last two commits ("rename span event methods" and "use impl trait consistently"), which AFAICT only touch new code you have added, into your original commit ("add OpenTelemetrySpanExt methods").
author_association: COLLABORATOR
user: login: alessandrobologna
created_at: 2025-05-02T12:48:56Z
body: Hi @djc, thanks! I think I've addressed the feedback:

* Kept the clippy fixes separate and moved them first.
* Squashed the feature addition and subsequent refactorings into the final commit (78e8247).

The commit history should now match your suggestion. Let me know if there's something else!

author_association: CONTRIBUTOR
user: login: djc
created_at: 2025-05-02T21:37:20Z
body: You actually didn't address my feedback exactly, because you also squashed the refactoring that changed other pre-existing code that you didn't add, so that's a little yucky from a reviewing perspective. However, I don't think it's worth doing another round trip on this, so I'll just merge this.
author_association: COLLABORATOR
user: login: alessandrobologna
created_at: 2025-05-02T21:52:27Z
body: Apologies, I misunderstood, I thought you just wanted the clippy fixes separated. I see your point now.
author_association: CONTRIBUTOR