Repository: denoland/deno
Pull request: feat(otel): support attributes on links and events

<!--
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.
-->

Fixes: https://github.com/denoland/deno/issues/28583


Changed files:
filename: ext/telemetry/lib.rs
patch: @@ -1388,12 +1388,7 @@ impl OtelSpan {
   }
 
   #[fast]
-  fn add_event(
-    &self,
-    #[string] name: String,
-    start_time: f64,
-    #[smi] dropped_attributes_count: u32,
-  ) {
+  fn add_event(&self, #[string] name: String, start_time: f64) {
     let start_time = if start_time.is_nan() {
       SystemTime::now()
     } else {
@@ -1405,12 +1400,10 @@ impl OtelSpan {
     let OtelSpanState::Recording(span) = &mut **state else {
       return;
     };
-    span.events.events.push(Event::new(
-      name,
-      start_time,
-      vec![],
-      dropped_attributes_count,
-    ));
+    span
+      .events
+      .events
+      .push(Event::new(name, start_time, vec![], 0));
   }
 
   #[fast]
@@ -1452,10 +1445,34 @@ impl OtelSpan {
   }
 }
 
+fn span_attributes(
+  span: &mut SpanData,
+  location: u32,
+) -> Option<(&mut Vec<KeyValue>, &mut u32)> {
+  match location {
+    // SELF
+    0 => Some((&mut span.attributes, &mut span.dropped_attributes_count)),
+    // LAST_EVENT
+    1 => span
+      .events
+      .events
+      .last_mut()
+      .map(|e| (&mut e.attributes, &mut e.dropped_attributes_count)),
+    // LAST_LINK
+    2 => span
+      .links
+      .links
+      .last_mut()
+      .map(|e| (&mut e.attributes, &mut e.dropped_attributes_count)),
+    _ => None,
+  }
+}
+
 #[op2(fast)]
 fn op_otel_span_attribute1<'s>(
   scope: &mut v8::HandleScope<'s>,
   span: v8::Local<'_, v8::Value>,
+  #[smi] location: u32,
   key: v8::Local<'s, v8::Value>,
   value: v8::Local<'s, v8::Value>,
 ) {
@@ -1466,14 +1483,20 @@ fn op_otel_span_attribute1<'s>(
   };
   let mut state = span.0.borrow_mut();
   if let OtelSpanState::Recording(span) = &mut **state {
-    attr!(scope, span.attributes => span.dropped_attributes_count, key, value);
+    let Some((attributes, dropped_attributes_count)) =
+      span_attributes(span, location)
+    else {
+      return;
+    };
+    attr!(scope, attributes => *dropped_attributes_count, key, value);
   }
 }
 
 #[op2(fast)]
 fn op_otel_span_attribute2<'s>(
   scope: &mut v8::HandleScope<'s>,
   span: v8::Local<'_, v8::Value>,
+  #[smi] location: u32,
   key1: v8::Local<'s, v8::Value>,
   value1: v8::Local<'s, v8::Value>,
   key2: v8::Local<'s, v8::Value>,
@@ -1486,8 +1509,13 @@ fn op_otel_span_attribute2<'s>(
   };
   let mut state = span.0.borrow_mut();
   if let OtelSpanState::Recording(span) = &mut **state {
-    attr!(scope, span.attributes => span.dropped_attributes_count, key1, value1);
-    attr!(scope, span.attributes => span.dropped_attributes_count, key2, value2);
+    let Some((attributes, dropped_attributes_count)) =
+      span_attributes(span, location)
+    else {
+      return;
+    };
+    attr!(scope, attributes => *dropped_attributes_count, key1, value1);
+    attr!(scope, attributes => *dropped_attributes_count, key2, value2);
   }
 }
 
@@ -1496,6 +1524,7 @@ fn op_otel_span_attribute2<'s>(
 fn op_otel_span_attribute3<'s>(
   scope: &mut v8::HandleScope<'s>,
   span: v8::Local<'_, v8::Value>,
+  #[smi] location: u32,
   key1: v8::Local<'s, v8::Value>,
   value1: v8::Local<'s, v8::Value>,
   key2: v8::Local<'s, v8::Value>,
@@ -1510,9 +1539,14 @@ fn op_otel_span_attribute3<'s>(
   };
   let mut state = span.0.borrow_mut();
   if let OtelSpanState::Recording(span) = &mut **state {
-    attr!(scope, span.attributes => span.dropped_attributes_count, key1, value1);
-    attr!(scope, span.attributes => span.dropped_attributes_count, key2, value2);
-    attr!(scope, span.attributes => span.dropped_attributes_count, key3, value3);
+    let Some((attributes, dropped_attributes_count)) =
+      span_attributes(span, location)
+    else {
+      return;
+    };
+    attr!(scope, attributes => *dropped_attributes_count, key1, value1);
+    attr!(scope, attributes => *dropped_attributes_count, key2, value2);
+    attr!(scope, attributes => *dropped_attributes_count, key3, value3);
   }
 }
 

filename: ext/telemetry/telemetry.ts
patch: @@ -129,6 +129,13 @@ interface Attributes {
 
 type SpanAttributes = Attributes;
 
+interface Exception {
+  code?: string | number;
+  message?: string;
+  name?: string;
+  stack?: string;
+}
+
 type TimeInput = [number, number] | number | Date;
 
 interface SpanOptions {
@@ -240,12 +247,59 @@ interface OtelSpan {
   addEvent(
     name: string,
     startTime: number,
-    droppedAttributeCount: number,
   ): void;
   dropEvent(): void;
   end(endTime: number): void;
 }
 
+enum SpanAttributesLocation {
+  SELF = 0,
+  LAST_EVENT = 1,
+  LAST_LINK = 2,
+}
+
+function spanAddAttributes(
+  span: OtelSpan,
+  attributesLocation: SpanAttributesLocation,
+  attributes: Attributes,
+) {
+  const attributeKvs = ObjectEntries(attributes);
+  let i = 0;
+  while (i < attributeKvs.length) {
+    if (i + 2 < attributeKvs.length) {
+      op_otel_span_attribute3(
+        span,
+        attributesLocation,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+        attributeKvs[i + 1][0],
+        attributeKvs[i + 1][1],
+        attributeKvs[i + 2][0],
+        attributeKvs[i + 2][1],
+      );
+      i += 3;
+    } else if (i + 1 < attributeKvs.length) {
+      op_otel_span_attribute2(
+        span,
+        attributesLocation,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+        attributeKvs[i + 1][0],
+        attributeKvs[i + 1][1],
+      );
+      i += 2;
+    } else {
+      op_otel_span_attribute1(
+        span,
+        attributesLocation,
+        attributeKvs[i][0],
+        attributeKvs[i][1],
+      );
+      i += 1;
+    }
+  }
+}
+
 interface TracerOptions {
   schemaUrl?: string;
 }
@@ -404,31 +458,46 @@ class Span {
     attributesOrStartTime?: Attributes | TimeInput,
     startTime?: TimeInput,
   ): this {
+    if (!this.#otelSpan) return this;
+    let attributes: Attributes | undefined;
     if (isTimeInput(attributesOrStartTime)) {
       startTime = attributesOrStartTime;
-      attributesOrStartTime = undefined;
+    } else {
+      attributes = attributesOrStartTime;
     }
     const startTimeMs = timeInputToMs(startTime);
 
-    this.#otelSpan?.addEvent(
+    this.#otelSpan.addEvent(
       name,
       startTimeMs ?? NaN,
-      countAttributes(attributesOrStartTime),
     );
+    if (attributes) {
+      spanAddAttributes(
+        this.#otelSpan,
+        SpanAttributesLocation.LAST_EVENT,
+        attributes,
+      );
+    }
     return this;
   }
 
   addLink(link: Link): this {
-    const droppedAttributeCount = (link.droppedAttributesCount ?? 0) +
-      countAttributes(link.attributes);
+    if (!this.#otelSpan) return this;
     const valid = op_otel_span_add_link(
       this.#otelSpan,
       link.context.traceId,
       link.context.spanId,
       link.context.traceFlags,
       link.context.isRemote ?? false,
-      droppedAttributeCount,
+      link.droppedAttributesCount ?? 0,
     );
+    if (link.attributes) {
+      spanAddAttributes(
+        this.#otelSpan,
+        SpanAttributesLocation.LAST_LINK,
+        link.attributes,
+      );
+    }
     if (!valid) return this;
     return this;
   }
@@ -448,51 +517,49 @@ class Span {
     return this.#otelSpan !== undefined;
   }
 
-  // deno-lint-ignore no-explicit-any
-  recordException(_exception: any, _time?: TimeInput): void {
-    this.#otelSpan?.dropEvent();
+  recordException(exception: string | Exception, time?: TimeInput): void {
+    if (typeof exception === "string") {
+      this.addEvent("exception", {
+        "exception.message": exception,
+      }, time);
+      return;
+    }
+    const attributes: Attributes = {};
+
+    if (exception.code) {
+      if (typeof exception.code === "number") {
+        attributes["exception.type"] = NumberPrototypeToString(exception.code);
+      } else {
+        attributes["exception.type"] = exception.code;
+      }
+    } else if (exception.name) {
+      attributes["exception.type"] = exception.name;
+    }
+
+    if (exception.message) {
+      attributes["exception.message"] = exception.message;
+    }
+    if (exception.stack) {
+      attributes["exception.stacktrace"] = exception.stack;
+    }
+
+    this.addEvent("exception", attributes, time);
   }
 
   setAttribute(key: string, value: AttributeValue): this {
     if (!this.#otelSpan) return this;
-    op_otel_span_attribute1(this.#otelSpan, key, value);
+    op_otel_span_attribute1(
+      this.#otelSpan,
+      SpanAttributesLocation.SELF,
+      key,
+      value,
+    );
     return this;
   }
 
   setAttributes(attributes: Attributes): this {
     if (!this.#otelSpan) return this;
-    const attributeKvs = ObjectEntries(attributes);
-    let i = 0;
-    while (i < attributeKvs.length) {
-      if (i + 2 < attributeKvs.length) {
-        op_otel_span_attribute3(
-          this.#otelSpan,
-          attributeKvs[i][0],
-          attributeKvs[i][1],
-          attributeKvs[i + 1][0],
-          attributeKvs[i + 1][1],
-          attributeKvs[i + 2][0],
-          attributeKvs[i + 2][1],
-        );
-        i += 3;
-      } else if (i + 1 < attributeKvs.length) {
-        op_otel_span_attribute2(
-          this.#otelSpan,
-          attributeKvs[i][0],
-          attributeKvs[i][1],
-          attributeKvs[i + 1][0],
-          attributeKvs[i + 1][1],
-        );
-        i += 2;
-      } else {
-        op_otel_span_attribute1(
-          this.#otelSpan,
-          attributeKvs[i][0],
-          attributeKvs[i][1],
-        );
-        i += 1;
-      }
-    }
+    spanAddAttributes(this.#otelSpan, SpanAttributesLocation.SELF, attributes);
     return this;
   }
 

filename: tests/specs/cli/otel_basic/events.out
patch: @@ -44,8 +44,15 @@
         {
           "timeUnixNano": "[WILDCARD]",
           "name": "example event",
-          "attributes": [],
-          "droppedAttributesCount": 1
+          "attributes": [
+            {
+              "key": "key",
+              "value": {
+                "stringValue": "value"
+              }
+            }
+          ],
+          "droppedAttributesCount": 0
         }
       ],
       "droppedEventsCount": 0,
@@ -72,8 +79,62 @@
         {
           "timeUnixNano": "[WILDCARD]",
           "name": "example event",
-          "attributes": [],
-          "droppedAttributesCount": 1
+          "attributes": [
+            {
+              "key": "key",
+              "value": {
+                "stringValue": "value"
+              }
+            }
+          ],
+          "droppedAttributesCount": 0
+        }
+      ],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000004",
+      "spanId": "0000000000000004",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "example span",
+      "kind": 1,
+      "startTimeUnixNano": "[WILDCARD]",
+      "endTimeUnixNano": "[WILDCARD]",
+      "attributes": [],
+      "droppedAttributesCount": 0,
+      "events": [
+        {
+          "timeUnixNano": "[WILDCARD]",
+          "name": "exception",
+          "attributes": [
+            {
+              "key": "exception.type",
+              "value": {
+                "stringValue": "Error"
+              }
+            },
+            {
+              "key": "exception.message",
+              "value": {
+                "stringValue": "error message"
+              }
+            },
+            {
+              "key": "exception.stacktrace",
+              "value": {
+                "stringValue": "Error: error message\n    at file://[WILDCARD]"
+              }
+            }
+          ],
+          "droppedAttributesCount": 0
         }
       ],
       "droppedEventsCount": 0,

filename: tests/specs/cli/otel_basic/events.ts
patch: @@ -19,3 +19,7 @@ span3.addEvent("example event", {
   key: "value",
 }, new Date());
 span3.end();
+
+const span4 = tracer.startSpan("example span");
+span4.recordException(new Error("error message"));
+span4.end();

filename: tests/specs/cli/otel_basic/links.out
patch: @@ -79,8 +79,15 @@
           "traceId": "1234567890abcdef1234567890abcdef",
           "spanId": "1234567890abcdef",
           "traceState": "",
-          "attributes": [],
-          "droppedAttributesCount": 2,
+          "attributes": [
+            {
+              "key": "key",
+              "value": {
+                "stringValue": "value"
+              }
+            }
+          ],
+          "droppedAttributesCount": 1,
           "flags": 1
         }
       ],