Repository: denoland/deno
Pull request: fix(otel/unstable): trace error cases of fetch

closes #28377

This updates the tracing of `fetch` when it resulted in an error state.

Changed files:
filename: ext/fetch/26_fetch.js
patch: @@ -65,6 +65,7 @@ import {
   TRACING_ENABLED,
 } from "ext:deno_telemetry/telemetry.ts";
 import {
+  updateSpanFromError,
   updateSpanFromRequest,
   updateSpanFromResponse,
 } from "ext:deno_telemetry/util.ts";
@@ -378,6 +379,11 @@ function fetch(input, init = { __proto__: null }) {
       const request = toInnerRequest(requestObject);
       // 4.
       if (requestObject.signal.aborted) {
+        if (span) {
+          // Handles this case here as this is the only case where `result` promise
+          // is settled immediately.
+          updateSpanFromError(span, requestObject.signal.reason);
+        }
         reject(abortFetch(request, null, requestObject.signal.reason));
         return;
       }
@@ -448,7 +454,11 @@ function fetch(input, init = { __proto__: null }) {
     });
 
     if (opPromise) {
-      PromisePrototypeCatch(result, () => {});
+      PromisePrototypeCatch(result, (e) => {
+        if (span) {
+          updateSpanFromError(span, e);
+        }
+      });
       return (async function fetch() {
         try {
           await opPromise;

filename: ext/telemetry/util.ts
patch: @@ -24,6 +24,16 @@ export function updateSpanFromResponse(span: Span, response: Response) {
     "http.response.status_code",
     String(response.status),
   );
+  if (response.status >= 400) {
+    span.setAttribute("error.type", String(response.status));
+    span.setStatus({ code: 2, message: response.statusText });
+  }
+}
+
+// deno-lint-ignore no-explicit-any
+export function updateSpanFromError(span: Span, error: any) {
+  span.setAttribute("error.type", error.name ?? "Error");
+  span.setStatus({ code: 2, message: error.message ?? String(error) });
 }
 
 // deno-lint-ignore no-explicit-any

filename: tests/specs/cli/otel_basic/__test__.jsonc
patch: @@ -23,6 +23,10 @@
       "args": "run -A main.ts metric.ts",
       "output": "metric.out"
     },
+    "fetch": {
+      "args": "run -A main.ts fetch.ts",
+      "output": "fetch.out"
+    },
     "http_metric": {
       "envs": {
         "OTEL_METRIC_EXPORT_INTERVAL": "1000"

filename: tests/specs/cli/otel_basic/fetch.out
patch: @@ -0,0 +1,244 @@
+{
+  "spans": [
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000001",
+      "traceState": "",
+      "parentSpanId": "",
+      "flags": 1,
+      "name": "GET",
+      "kind": 3,
+      "startTimeUnixNano": "[WILDLINE]",
+      "endTimeUnixNano": "[WILDLINE]",
+      "attributes": [
+        {
+          "key": "http.request.method",
+          "value": {
+            "stringValue": "GET"
+          }
+        },
+        {
+          "key": "url.full",
+          "value": {
+            "stringValue": "http://localhost:4545/echo.ts"
+          }
+        },
+        {
+          "key": "url.scheme",
+          "value": {
+            "stringValue": "http"
+          }
+        },
+        {
+          "key": "url.path",
+          "value": {
+            "stringValue": "/echo.ts"
+          }
+        },
+        {
+          "key": "url.query",
+          "value": {
+            "stringValue": ""
+          }
+        },
+        {
+          "key": "http.response.status_code",
+          "value": {
+            "stringValue": "200"
+          }
+        }
+      ],
+      "droppedAttributesCount": 0,
+      "events": [],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "",
+        "code": 0
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000002",
+      "traceState": "",
+      "parentSpanId": "0000000000000001",
+      "flags": 1,
+      "name": "GET",
+      "kind": 3,
+      "startTimeUnixNano": "[WILDLINE]",
+      "endTimeUnixNano": "[WILDLINE]",
+      "attributes": [
+        {
+          "key": "http.request.method",
+          "value": {
+            "stringValue": "GET"
+          }
+        },
+        {
+          "key": "url.full",
+          "value": {
+            "stringValue": "http://localhost:4545/not-found"
+          }
+        },
+        {
+          "key": "url.scheme",
+          "value": {
+            "stringValue": "http"
+          }
+        },
+        {
+          "key": "url.path",
+          "value": {
+            "stringValue": "/not-found"
+          }
+        },
+        {
+          "key": "url.query",
+          "value": {
+            "stringValue": ""
+          }
+        },
+        {
+          "key": "http.response.status_code",
+          "value": {
+            "stringValue": "404"
+          }
+        },
+        {
+          "key": "error.type",
+          "value": {
+            "stringValue": "404"
+          }
+        }
+      ],
+      "droppedAttributesCount": 0,
+      "events": [],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "Not Found",
+        "code": 2
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000003",
+      "traceState": "",
+      "parentSpanId": "0000000000000001",
+      "flags": 1,
+      "name": "GET",
+      "kind": 3,
+      "startTimeUnixNano": "[WILDLINE]",
+      "endTimeUnixNano": "[WILDLINE]",
+      "attributes": [
+        {
+          "key": "http.request.method",
+          "value": {
+            "stringValue": "GET"
+          }
+        },
+        {
+          "key": "url.full",
+          "value": {
+            "stringValue": "http://unreachable-host.abc/"
+          }
+        },
+        {
+          "key": "url.scheme",
+          "value": {
+            "stringValue": "http"
+          }
+        },
+        {
+          "key": "url.path",
+          "value": {
+            "stringValue": "/"
+          }
+        },
+        {
+          "key": "url.query",
+          "value": {
+            "stringValue": ""
+          }
+        },
+        {
+          "key": "error.type",
+          "value": {
+            "stringValue": "TypeError"
+          }
+        }
+      ],
+      "droppedAttributesCount": 0,
+      "events": [],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "error sending request for url (http://unreachable-host.abc/): client error (Connect): dns error: [WILDLINE]",
+        "code": 2
+      }
+    },
+    {
+      "traceId": "00000000000000000000000000000001",
+      "spanId": "0000000000000004",
+      "traceState": "",
+      "parentSpanId": "0000000000000001",
+      "flags": 1,
+      "name": "GET",
+      "kind": 3,
+      "startTimeUnixNano": "[WILDLINE]",
+      "endTimeUnixNano": "[WILDLINE]",
+      "attributes": [
+        {
+          "key": "http.request.method",
+          "value": {
+            "stringValue": "GET"
+          }
+        },
+        {
+          "key": "url.full",
+          "value": {
+            "stringValue": "http://localhost:4545/echo.ts"
+          }
+        },
+        {
+          "key": "url.scheme",
+          "value": {
+            "stringValue": "http"
+          }
+        },
+        {
+          "key": "url.path",
+          "value": {
+            "stringValue": "/echo.ts"
+          }
+        },
+        {
+          "key": "url.query",
+          "value": {
+            "stringValue": ""
+          }
+        },
+        {
+          "key": "error.type",
+          "value": {
+            "stringValue": "AbortError"
+          }
+        }
+      ],
+      "droppedAttributesCount": 0,
+      "events": [],
+      "droppedEventsCount": 0,
+      "links": [],
+      "droppedLinksCount": 0,
+      "status": {
+        "message": "The signal has been aborted",
+        "code": 2
+      }
+    }
+  ],
+  "logs": [],
+  "metrics": []
+}

filename: tests/specs/cli/otel_basic/fetch.ts
patch: @@ -0,0 +1,11 @@
+async function request(url: string, options: any) {
+  try {
+    await (await fetch(url, options)).text();
+  } catch {
+  }
+}
+
+await request("http://localhost:4545/echo.ts");
+await request("http://localhost:4545/not-found");
+await request("http://unreachable-host.abc/");
+await request("http://localhost:4545/echo.ts", { signal: AbortSignal.abort() });