Repository: denoland/deno
Pull request: feat(otel): basic event recording

<!--
Before submitting a PR, please read https://docs.deno.com/runtime/manual/references/contributing

1. Give the PR a descriptive title.

  Examples of good title:
    - fix(std/http): Fix race condition in server
    - docs(console): Update docstrings
    - feat(doc): Handle nested reexports

  Examples of bad title:
    - fix #7123
    - update docs
    - fix bugs

2. Ensure there is a related issue and it is referenced in the PR text.
3. Ensure there are tests that cover the changes.
4. Ensure `cargo test` passes.
5. Ensure `./tools/format.js` passes without changing files.
6. Ensure `./tools/lint.js` passes.
7. Open as a draft PR if your work is still in progress. The CI won't run
   all steps, but you can add '[ci]' to a commit message to force it to.
8. If you would like to run the benchmarks on the CI, add the 'ci-bench' label.
-->

Partially fixes https://github.com/denoland/deno/issues/27917: there is no attribute support, they are silently dropped and counted, not much worse that current links support.

Changed files:
filename: ext/telemetry/lib.rs
patch: @@ -45,6 +45,7 @@ pub use opentelemetry::metrics::MeterProvider;
 pub use opentelemetry::metrics::UpDownCounter;
 use opentelemetry::otel_debug;
 use opentelemetry::otel_error;
+use opentelemetry::trace::Event;
 use opentelemetry::trace::Link;
 use opentelemetry::trace::SpanContext;
 use opentelemetry::trace::SpanId;
@@ -1381,6 +1382,32 @@ impl OtelSpan {
     Ok(())
   }
 
+  #[fast]
+  fn add_event(
+    &self,
+    #[string] name: String,
+    start_time: f64,
+    #[smi] dropped_attributes_count: u32,
+  ) {
+    let start_time = if start_time.is_nan() {
+      SystemTime::now()
+    } else {
+      SystemTime::UNIX_EPOCH
+        .checked_add(Duration::from_secs_f64(start_time / 1000.0))
+        .unwrap()
+    };
+    let mut state = self.0.borrow_mut();
+    let OtelSpanState::Recording(span) = &mut **state else {
+      return;
+    };
+    span.events.events.push(Event::new(
+      name,
+      start_time,
+      vec![],
+      dropped_attributes_count,
+    ));
+  }
+
   #[fast]
   fn drop_event(&self) {
     let mut state = self.0.borrow_mut();

filename: ext/telemetry/telemetry.ts
patch: @@ -169,6 +169,25 @@ function hrToMs(hr: [number, number]): number {
   return (hr[0] * 1e3 + hr[1] / 1e6);
 }
 
+function isTimeInput(input: unknown): input is TimeInput {
+  return typeof input === "number" ||
+    (input && (ArrayIsArray(input) || isDate(input)));
+}
+
+function timeInputToMs(input?: TimeInput): number | undefined {
+  if (input === undefined) return;
+  if (ArrayIsArray(input)) {
+    return hrToMs(input);
+  } else if (isDate(input)) {
+    return DatePrototypeGetTime(input);
+  }
+  return input;
+}
+
+function countAttributes(attributes?: Attributes): number {
+  return attributes ? ObjectKeys(attributes).length : 0;
+}
+
 interface AsyncContextSnapshot {
   __brand: "AsyncContextSnapshot";
 }
@@ -183,7 +202,7 @@ export const currentSnapshot = getAsyncContext;
 export const restoreSnapshot = setAsyncContext;
 
 function isDate(value: unknown): value is Date {
-  return ObjectPrototypeIsPrototypeOf(value, DatePrototype);
+  return ObjectPrototypeIsPrototypeOf(DatePrototype, value);
 }
 
 interface OtelTracer {
@@ -215,6 +234,11 @@ interface OtelSpan {
 
   spanContext(): SpanContext;
   setStatus(status: SpanStatusCode, errorDescription: string): void;
+  addEvent(
+    name: string,
+    startTime: number,
+    droppedAttributeCount: number,
+  ): void;
   dropEvent(): void;
   end(endTime: number): void;
 }
@@ -303,20 +327,13 @@ class Tracer {
       context = context ?? CURRENT.get();
     }
 
-    let startTime = options?.startTime;
-    if (startTime && ArrayIsArray(startTime)) {
-      startTime = hrToMs(startTime);
-    } else if (startTime && isDate(startTime)) {
-      startTime = DatePrototypeGetTime(startTime);
-    }
+    const startTime = timeInputToMs(options?.startTime);
 
     const parentSpan = context?.getValue(SPAN_KEY) as
       | Span
       | { spanContext(): SpanContext }
       | undefined;
-    const attributesCount = options?.attributes
-      ? ObjectKeys(options.attributes).length
-      : 0;
+    const attributesCount = countAttributes(options?.attributes);
     const parentOtelSpan: OtelSpan | null | undefined = parentSpan !== undefined
       ? getOtelSpan(parentSpan) ?? undefined
       : undefined;
@@ -380,17 +397,27 @@ class Span {
   }
 
   addEvent(
-    _name: string,
-    _attributesOrStartTime?: Attributes | TimeInput,
-    _startTime?: TimeInput,
+    name: string,
+    attributesOrStartTime?: Attributes | TimeInput,
+    startTime?: TimeInput,
   ): this {
-    this.#otelSpan?.dropEvent();
+    if (isTimeInput(attributesOrStartTime)) {
+      startTime = attributesOrStartTime;
+      attributesOrStartTime = undefined;
+    }
+    const startTimeMs = timeInputToMs(startTime);
+
+    this.#otelSpan?.addEvent(
+      name,
+      startTimeMs ?? NaN,
+      countAttributes(attributesOrStartTime),
+    );
     return this;
   }
 
   addLink(link: Link): this {
     const droppedAttributeCount = (link.droppedAttributesCount ?? 0) +
-      (link.attributes ? ObjectKeys(link.attributes).length : 0);
+      countAttributes(link.attributes);
     const valid = op_otel_span_add_link(
       this.#otelSpan,
       link.context.traceId,
@@ -411,12 +438,7 @@ class Span {
   }
 
   end(endTime?: TimeInput): void {
-    if (endTime && ArrayIsArray(endTime)) {
-      endTime = hrToMs(endTime);
-    } else if (endTime && isDate(endTime)) {
-      endTime = DatePrototypeGetTime(endTime);
-    }
-    this.#otelSpan?.end(endTime || NaN);
+    this.#otelSpan?.end(timeInputToMs(endTime) || NaN);
   }
 
   isRecording(): boolean {

filename: tests/specs/cli/otel_basic/__test__.jsonc
patch: @@ -56,6 +56,10 @@
     "node_http_request": {
       "args": "run -A main.ts node_http_request.ts",
       "output": "node_http_request.out"
+    },
+    "events": {
+      "args": "run -A main.ts events.ts",
+      "output": "events.out"
     }
   }
 }

filename: tests/specs/cli/otel_basic/events.out
patch: @@ -0,0 +1,90 @@
+{
+  "spans": [
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000001",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "example span",
+      "kind": 1,
+      "startTimeUnixNano": "[WILDCARD]",
+      "endTimeUnixNano": "[WILDCARD]",
+      "attributes": [],
+      "droppedAttributesCount": 0,
+      "events": [
+        {
+          "timeUnixNano": "[WILDCARD]",
+          "name": "example event",
+          "attributes": [],
+          "droppedAttributesCount": 0
+        }
+      ],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000002",
+      "spanId": "0000000000000002",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "example span",
+      "kind": 1,
+      "startTimeUnixNano": "[WILDCARD]",
+      "endTimeUnixNano": "[WILDCARD]",
+      "attributes": [],
+      "droppedAttributesCount": 0,
+      "events": [
+        {
+          "timeUnixNano": "[WILDCARD]",
+          "name": "example event",
+          "attributes": [],
+          "droppedAttributesCount": 1
+        }
+      ],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000003",
+      "spanId": "0000000000000003",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "example span",
+      "kind": 1,
+      "startTimeUnixNano": "[WILDCARD]",
+      "endTimeUnixNano": "[WILDCARD]",
+      "attributes": [],
+      "droppedAttributesCount": 0,
+      "events": [
+        {
+          "timeUnixNano": "[WILDCARD]",
+          "name": "example event",
+          "attributes": [],
+          "droppedAttributesCount": 1
+        }
+      ],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    }
+  ],
+  "logs": [],
+  "metrics": []
+}

filename: tests/specs/cli/otel_basic/events.ts
patch: @@ -0,0 +1,21 @@
+// Copyright 2018-2025 the Deno authors. MIT license.
+
+import { trace } from "npm:@opentelemetry/api@1.9.0";
+
+const tracer = trace.getTracer("example-tracer");
+
+const span1 = tracer.startSpan("example span");
+span1.addEvent("example event");
+span1.end();
+
+const span2 = tracer.startSpan("example span");
+span2.addEvent("example event", {
+  key: "value",
+});
+span2.end();
+
+const span3 = tracer.startSpan("example span");
+span3.addEvent("example event", {
+  key: "value",
+}, new Date());
+span3.end();