Repository: denoland/deno
Pull request: feat(unstable): add basic support for otel trace links

Currently only links with no attributes.

<!--
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.
-->


Changed files:
filename: ext/telemetry/lib.rs
patch: @@ -42,6 +42,7 @@ use opentelemetry::metrics::InstrumentBuilder;
 use opentelemetry::metrics::MeterProvider as _;
 use opentelemetry::otel_debug;
 use opentelemetry::otel_error;
+use opentelemetry::trace::Link;
 use opentelemetry::trace::SpanContext;
 use opentelemetry::trace::SpanId;
 use opentelemetry::trace::SpanKind;
@@ -94,6 +95,7 @@ deno_core::extension!(
     op_otel_span_attribute1,
     op_otel_span_attribute2,
     op_otel_span_attribute3,
+    op_otel_span_add_link,
     op_otel_span_update_name,
     op_otel_metric_attribute3,
     op_otel_metric_record0,
@@ -1324,17 +1326,6 @@ impl OtelSpan {
     }
   }
 
-  #[fast]
-  fn drop_link(&self) {
-    let mut state = self.0.borrow_mut();
-    match &mut **state {
-      OtelSpanState::Recording(span) => {
-        span.links.dropped_count += 1;
-      }
-      OtelSpanState::Done(_) => {}
-    }
-  }
-
   #[fast]
   fn end(&self, end_time: f64) {
     let end_time = if end_time.is_nan() {
@@ -1448,6 +1439,48 @@ fn op_otel_span_update_name<'s>(
   }
 }
 
+#[op2(fast)]
+fn op_otel_span_add_link<'s>(
+  scope: &mut v8::HandleScope<'s>,
+  span: v8::Local<'s, v8::Value>,
+  trace_id: v8::Local<'s, v8::Value>,
+  span_id: v8::Local<'s, v8::Value>,
+  #[smi] trace_flags: u8,
+  is_remote: bool,
+  #[smi] dropped_attributes_count: u32,
+) -> bool {
+  let trace_id = parse_trace_id(scope, trace_id);
+  if trace_id == TraceId::INVALID {
+    return false;
+  };
+  let span_id = parse_span_id(scope, span_id);
+  if span_id == SpanId::INVALID {
+    return false;
+  };
+  let span_context = SpanContext::new(
+    trace_id,
+    span_id,
+    TraceFlags::new(trace_flags),
+    is_remote,
+    TraceState::NONE,
+  );
+
+  let Some(span) =
+    deno_core::_ops::try_unwrap_cppgc_object::<OtelSpan>(scope, span)
+  else {
+    return true;
+  };
+  let mut state = span.0.borrow_mut();
+  if let OtelSpanState::Recording(span) = &mut **state {
+    span.links.links.push(Link::new(
+      span_context,
+      vec![],
+      dropped_attributes_count,
+    ));
+  }
+  true
+}
+
 struct OtelMeter(opentelemetry::metrics::Meter);
 
 impl deno_core::GarbageCollected for OtelMeter {}

filename: ext/telemetry/telemetry.ts
patch: @@ -15,6 +15,7 @@ import {
   op_otel_metric_record2,
   op_otel_metric_record3,
   op_otel_metric_wait_to_observe,
+  op_otel_span_add_link,
   op_otel_span_attribute1,
   op_otel_span_attribute2,
   op_otel_span_attribute3,
@@ -186,7 +187,6 @@ interface OtelSpan {
   spanContext(): SpanContext;
   setStatus(status: SpanStatusCode, errorDescription: string): void;
   dropEvent(): void;
-  dropLink(): void;
   end(endTime: number): void;
 }
 
@@ -359,14 +359,24 @@ class Span {
     return this;
   }
 
-  addLink(_link: Link): Span {
-    this.#otelSpan?.dropLink();
+  addLink(link: Link): Span {
+    const droppedAttributeCount = (link.droppedAttributesCount ?? 0) +
+      (link.attributes ? ObjectKeys(link.attributes).length : 0);
+    const valid = op_otel_span_add_link(
+      this.#otelSpan,
+      link.context.traceId,
+      link.context.spanId,
+      link.context.traceFlags,
+      link.context.isRemote ?? false,
+      droppedAttributeCount,
+    );
+    if (!valid) return this;
     return this;
   }
 
   addLinks(links: Link[]): Span {
     for (let i = 0; i < links.length; i++) {
-      this.#otelSpan?.dropLink();
+      this.addLink(links[i]);
     }
     return this;
   }

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

filename: tests/specs/cli/otel_basic/links.out
patch: @@ -0,0 +1,96 @@
+{
+  "spans": [
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000001",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "example span",
+      "kind": 1,
+      "startTimeUnixNano": "[WILDCARD]",
+      "endTimeUnixNano": "[WILDCARD]",
+      "attributes": [],
+      "droppedAttributesCount": 0,
+      "events": [],
+      "droppedEventsCount": 0,
+      "links": [
+        {
+          "traceId": "1234567890abcdef1234567890abcdef",
+          "spanId": "1234567890abcdef",
+          "traceState": "",
+          "attributes": [],
+          "droppedAttributesCount": 0,
+          "flags": 1
+        }
+      ],
+      "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": [],
+      "droppedEventsCount": 0,
+      "links": [
+        {
+          "traceId": "1234567890abcdef1234567890abcdef",
+          "spanId": "1234567890abcdef",
+          "traceState": "",
+          "attributes": [],
+          "droppedAttributesCount": 0,
+          "flags": 1
+        }
+      ],
+      "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": [],
+      "droppedEventsCount": 0,
+      "links": [
+        {
+          "traceId": "1234567890abcdef1234567890abcdef",
+          "spanId": "1234567890abcdef",
+          "traceState": "",
+          "attributes": [],
+          "droppedAttributesCount": 2,
+          "flags": 1
+        }
+      ],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    }
+  ],
+  "logs": [],
+  "metrics": []
+}

filename: tests/specs/cli/otel_basic/links.ts
patch: @@ -0,0 +1,40 @@
+// 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", {
+  links: [{
+    context: {
+      traceId: "1234567890abcdef1234567890abcdef",
+      spanId: "1234567890abcdef",
+      traceFlags: 1,
+    },
+  }],
+});
+span1.end();
+
+const span2 = tracer.startSpan("example span");
+span2.addLink({
+  context: {
+    traceId: "1234567890abcdef1234567890abcdef",
+    spanId: "1234567890abcdef",
+    traceFlags: 1,
+  },
+});
+span2.end();
+
+const span3 = tracer.startSpan("example span");
+span3.addLink({
+  context: {
+    traceId: "1234567890abcdef1234567890abcdef",
+    spanId: "1234567890abcdef",
+    traceFlags: 1,
+  },
+  attributes: {
+    key: "value",
+  },
+  droppedAttributesCount: 1,
+});
+span3.end();