Quellcode durchsuchen

feat: export AI SDK telemetry to local OTLP

Kit Langton vor 1 Woche
Ursprung
Commit
c523f7ad2b

+ 63 - 0
bun.lock

@@ -340,6 +340,7 @@
         "@ai-sdk/xai": "3.0.75",
         "@aws-sdk/credential-providers": "3.993.0",
         "@clack/prompts": "1.0.0-alpha.1",
+        "@effect/opentelemetry": "catalog:",
         "@effect/platform-node": "catalog:",
         "@gitlab/opencode-gitlab-auth": "1.3.3",
         "@hono/node-server": "1.19.11",
@@ -357,6 +358,12 @@
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@openrouter/ai-sdk-provider": "2.5.1",
+        "@opentelemetry/api": "catalog:",
+        "@opentelemetry/exporter-trace-otlp-http": "catalog:",
+        "@opentelemetry/resources": "catalog:",
+        "@opentelemetry/sdk-trace-base": "catalog:",
+        "@opentelemetry/sdk-trace-node": "catalog:",
+        "@opentelemetry/semantic-conventions": "catalog:",
         "@opentui/core": "0.1.97",
         "@opentui/solid": "0.1.97",
         "@parcel/watcher": "2.5.1",
@@ -627,6 +634,7 @@
   "trustedDependencies": [
     "esbuild",
     "tree-sitter-powershell",
+    "protobufjs",
     "electron",
     "web-tree-sitter",
     "tree-sitter-bash",
@@ -641,12 +649,19 @@
   },
   "catalog": {
     "@cloudflare/workers-types": "4.20251008.0",
+    "@effect/opentelemetry": "4.0.0-beta.46",
     "@effect/platform-node": "4.0.0-beta.46",
     "@hono/zod-validator": "0.4.2",
     "@kobalte/core": "0.13.11",
     "@lydell/node-pty": "1.2.0-beta.10",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
+    "@opentelemetry/api": "1.9.0",
+    "@opentelemetry/exporter-trace-otlp-http": "0.211.0",
+    "@opentelemetry/resources": "2.5.0",
+    "@opentelemetry/sdk-trace-base": "2.5.0",
+    "@opentelemetry/sdk-trace-node": "2.5.0",
+    "@opentelemetry/semantic-conventions": "1.39.0",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.51.0",
     "@solid-primitives/storage": "4.3.3",
@@ -1027,6 +1042,8 @@
 
     "@effect/language-service": ["@effect/[email protected]", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
 
+    "@effect/opentelemetry": ["@effect/[email protected]", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.46" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-7Wc7X547NZdnU+ybi7JvF2O8t7HxZNciIEMPB7YPMiBo92NPZOK9YgZjBQPQtyv17ROlfgyi0Jnp8P/CzUtttg=="],
+
     "@effect/platform-node": ["@effect/[email protected]", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="],
 
     "@effect/platform-node-shared": ["@effect/[email protected]", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="],
@@ -1541,6 +1558,30 @@
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
+    "@opentelemetry/api-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg=="],
+
+    "@opentelemetry/context-async-hooks": ["@opentelemetry/[email protected]", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw=="],
+
+    "@opentelemetry/core": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="],
+
+    "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/otlp-exporter-base": "0.211.0", "@opentelemetry/otlp-transformer": "0.211.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/sdk-trace-base": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-F1Rv3JeMkgS//xdVjbQMrI3+26e5SXC7vXA6trx8SWEA0OUhw4JHB+qeHtH0fJn46eFItrYbL5m8j4qi9Sfaxw=="],
+
+    "@opentelemetry/otlp-exporter-base": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/otlp-transformer": "0.211.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bp1+63V8WPV+bRI9EQG6E9YID1LIHYSZVbp7f+44g9tRzCq+rtw/o4fpL5PC31adcUsFiz/oN0MdLISSrZDdrg=="],
+
+    "@opentelemetry/otlp-transformer": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/sdk-logs": "0.211.0", "@opentelemetry/sdk-metrics": "2.5.0", "@opentelemetry/sdk-trace-base": "2.5.0", "protobufjs": "8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-julhCJ9dXwkOg9svuuYqqjXLhVaUgyUvO2hWbTxwjvLXX2rG3VtAaB0SzxMnGTuoCZizBT7Xqqm2V7+ggrfCXA=="],
+
+    "@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g=="],
+
+    "@opentelemetry/sdk-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA=="],
+
+    "@opentelemetry/sdk-metrics": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA=="],
+
+    "@opentelemetry/sdk-trace-base": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ=="],
+
+    "@opentelemetry/sdk-trace-node": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.5.0", "@opentelemetry/core": "2.5.0", "@opentelemetry/sdk-trace-base": "2.5.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-O6N/ejzburFm2C84aKNrwJVPpt6HSTSq8T0ZUMq3xT2XmqT4cwxUItcL5UWGThYuq8RTcbH8u1sfj6dmRci0Ow=="],
+
+    "@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="],
+
     "@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="],
 
     "@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="],
@@ -1695,6 +1736,26 @@
 
     "@protobuf-ts/runtime-rpc": ["@protobuf-ts/[email protected]", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" } }, "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ=="],
 
+    "@protobufjs/aspromise": ["@protobufjs/[email protected]", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+    "@protobufjs/base64": ["@protobufjs/[email protected]", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+    "@protobufjs/codegen": ["@protobufjs/[email protected]", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
+
+    "@protobufjs/eventemitter": ["@protobufjs/[email protected]", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
+
+    "@protobufjs/fetch": ["@protobufjs/[email protected]", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
+
+    "@protobufjs/float": ["@protobufjs/[email protected]", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+    "@protobufjs/inquire": ["@protobufjs/[email protected]", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
+
+    "@protobufjs/path": ["@protobufjs/[email protected]", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+    "@protobufjs/pool": ["@protobufjs/[email protected]", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+    "@protobufjs/utf8": ["@protobufjs/[email protected]", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
+
     "@radix-ui/colors": ["@radix-ui/[email protected]", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
 
     "@radix-ui/primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
@@ -4163,6 +4224,8 @@
 
     "proto-list": ["[email protected]", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
 
+    "protobufjs": ["[email protected]", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw=="],
+
     "proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
 
     "proxy-from-env": ["[email protected]", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],

+ 7 - 0
package.json

@@ -26,6 +26,13 @@
       "packages/slack"
     ],
     "catalog": {
+      "@effect/opentelemetry": "4.0.0-beta.46",
+      "@opentelemetry/api": "1.9.0",
+      "@opentelemetry/exporter-trace-otlp-http": "0.211.0",
+      "@opentelemetry/resources": "2.5.0",
+      "@opentelemetry/sdk-trace-base": "2.5.0",
+      "@opentelemetry/sdk-trace-node": "2.5.0",
+      "@opentelemetry/semantic-conventions": "1.39.0",
       "@effect/platform-node": "4.0.0-beta.46",
       "@types/bun": "1.3.11",
       "@types/cross-spawn": "6.0.6",

+ 7 - 0
packages/opencode/package.json

@@ -104,7 +104,14 @@
     "@ai-sdk/xai": "3.0.75",
     "@aws-sdk/credential-providers": "3.993.0",
     "@clack/prompts": "1.0.0-alpha.1",
+    "@effect/opentelemetry": "catalog:",
     "@effect/platform-node": "catalog:",
+    "@opentelemetry/api": "catalog:",
+    "@opentelemetry/exporter-trace-otlp-http": "catalog:",
+    "@opentelemetry/resources": "catalog:",
+    "@opentelemetry/sdk-trace-base": "catalog:",
+    "@opentelemetry/sdk-trace-node": "catalog:",
+    "@opentelemetry/semantic-conventions": "catalog:",
     "@gitlab/opencode-gitlab-auth": "1.3.3",
     "@hono/node-server": "1.19.11",
     "@hono/node-ws": "1.3.0",

+ 41 - 33
packages/opencode/src/agent/agent.ts

@@ -21,7 +21,9 @@ import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
 import { Effect, Context, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
+import { Observability } from "@/effect/oltp"
 import { makeRuntime } from "@/effect/run-service"
+import type { Tracer } from "@opentelemetry/api"
 
 export namespace Agent {
   export const Info = z
@@ -345,38 +347,42 @@ export namespace Agent {
           const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
           const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
 
-          const params = {
-            experimental_telemetry: {
-              isEnabled: cfg.experimental?.openTelemetry,
-              metadata: {
-                userId: cfg.username ?? "unknown",
-              },
-            },
-            temperature: 0.3,
-            messages: [
-              ...(isOpenaiOauth
-                ? []
-                : system.map(
-                    (item): ModelMessage => ({
-                      role: "system",
-                      content: item,
-                    }),
-                  )),
-              {
-                role: "user",
-                content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n  Return ONLY the JSON object, no other text, do not wrap in backticks`,
-              },
-            ],
-            model: language,
-            schema: z.object({
-              identifier: z.string(),
-              whenToUse: z.string(),
-              systemPrompt: z.string(),
-            }),
-          } satisfies Parameters<typeof generateObject>[0]
+          const run = async (tracer: Tracer) => {
+            const params = {
+              experimental_telemetry: Observability.aiTelemetry({
+                enabled: cfg.experimental?.openTelemetry,
+                tracer,
+                functionId: "Agent.generate",
+                metadata: {
+                  userID: cfg.username ?? "unknown",
+                  providerID: resolved.providerID,
+                  modelID: resolved.id,
+                },
+              }),
+              temperature: 0.3,
+              messages: [
+                ...(isOpenaiOauth
+                  ? []
+                  : system.map(
+                      (item): ModelMessage => ({
+                        role: "system",
+                        content: item,
+                      }),
+                    )),
+                {
+                  role: "user",
+                  content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n  Return ONLY the JSON object, no other text, do not wrap in backticks`,
+                },
+              ],
+              model: language,
+              schema: z.object({
+                identifier: z.string(),
+                whenToUse: z.string(),
+                systemPrompt: z.string(),
+              }),
+            } satisfies Parameters<typeof generateObject>[0]
 
-          if (isOpenaiOauth) {
-            return yield* Effect.promise(async () => {
+            if (isOpenaiOauth) {
               const result = streamObject({
                 ...params,
                 providerOptions: ProviderTransform.providerOptions(resolved, {
@@ -389,10 +395,12 @@ export namespace Agent {
                 if (part.type === "error") throw part.error
               }
               return result.object
-            })
+            }
+
+            return generateObject(params).then((r) => r.object)
           }
 
-          return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
+          return yield* Observability.promise(run)
         }),
       })
     }),

+ 115 - 21
packages/opencode/src/effect/oltp.ts

@@ -1,13 +1,45 @@
-import { Duration, Layer } from "effect"
+import * as NodeSdk from "@effect/opentelemetry/NodeSdk"
+import * as OtelResource from "@effect/opentelemetry/Resource"
+import * as OtelTracer from "@effect/opentelemetry/Tracer"
+import { context, trace, type AttributeValue, type Span, type Tracer } from "@opentelemetry/api"
+import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
+import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
+import { Duration, Effect, Layer, ManagedRuntime, Option } from "effect"
+import * as Context from "effect/Context"
 import { FetchHttpClient } from "effect/unstable/http"
-import { Otlp } from "effect/unstable/observability"
+import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
+import { normalizeServerUrl } from "@/account/url"
 import { EffectLogger } from "@/effect/logger"
 import { Flag } from "@/flag/flag"
 import { CHANNEL, VERSION } from "@/installation/meta"
 
 export namespace Observability {
+  export class AITracer extends Context.Service<AITracer, Tracer>()("@opencode/Observability/AITracer") {}
+
+  const clean = <T extends Record<string, unknown>>(value: T) =>
+    Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) as {
+      [K in keyof T as undefined extends T[K] ? never : K]: Exclude<T[K], undefined>
+    }
+
+  const parseHeaders = () =>
+    Flag.OTEL_EXPORTER_OTLP_HEADERS
+      ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
+          (acc, item) => {
+            const at = item.indexOf("=")
+            if (at < 1 || at === item.length - 1) return acc
+            acc[item.slice(0, at)] = item.slice(at + 1)
+            return acc
+          },
+          {} as Record<string, string>,
+        )
+      : undefined
+
   const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
-  export const enabled = !!base
+  const root = base ? normalizeServerUrl(base) : undefined
+  const traces = Flag.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? (root ? `${root}/v1/traces` : undefined)
+  const logs = Flag.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? (root ? `${root}/v1/logs` : undefined)
+
+  export const enabled = !!traces || !!logs
 
   const resource = {
     serviceName: "opencode",
@@ -18,24 +50,86 @@ export namespace Observability {
     },
   }
 
-  const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
-    ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
-        (acc, x) => {
-          const [key, value] = x.split("=")
-          acc[key] = value
-          return acc
-        },
-        {} as Record<string, string>,
-      )
-    : undefined
-
-  export const layer = !base
-    ? EffectLogger.layer
-    : Otlp.layerJson({
-        baseUrl: base,
-        loggerExportInterval: Duration.seconds(1),
-        loggerMergeWithExisting: true,
+  const headers = parseHeaders()
+
+  const tracer = traces
+    ? OtlpTracer.layer({
+        url: traces,
         resource,
         headers,
-      }).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
+      })
+    : Layer.empty
+
+  const logger = logs
+    ? OtlpLogger.layer({
+        url: logs,
+        resource,
+        headers,
+        exportInterval: Duration.seconds(1),
+        mergeWithExisting: true,
+      })
+    : Layer.empty
+
+  const ai = traces
+    ? Layer.effect(AITracer, Effect.service(OtelTracer.OtelTracer)).pipe(
+        Layer.provide(
+          OtelTracer.layerTracer.pipe(
+            Layer.provide(
+              NodeSdk.layerTracerProvider(new BatchSpanProcessor(new OTLPTraceExporter({ url: traces, headers }))),
+            ),
+            Layer.provide(OtelResource.layer(resource)),
+          ),
+        ),
+      )
+    : Layer.succeed(AITracer, trace.getTracer(resource.serviceName, resource.serviceVersion))
+
+  export const layer =
+    !traces && !logs
+      ? Layer.mergeAll(EffectLogger.layer, ai)
+      : Layer.mergeAll(tracer, logger, ai).pipe(
+          Layer.provide(EffectLogger.layer),
+          Layer.provide(OtlpSerialization.layerJson),
+          Layer.provide(FetchHttpClient.layer),
+        )
+
+  const runtime = ManagedRuntime.make(layer)
+  const aiRuntime = ManagedRuntime.make(ai)
+
+  const withSpan = <A>(span: Option.Option<Span>, fn: () => A): A =>
+    Option.match(span, {
+      onNone: fn,
+      onSome: (span) => context.with(trace.setSpan(context.active(), span), fn),
+    })
+
+  const withActiveParent = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
+    const active = trace.getActiveSpan()
+    if (!active) return effect
+    return effect.pipe(OtelTracer.withSpanContext(active.spanContext()))
+  }
+
+  export const runPromise = <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(withActiveParent(effect))
+
+  export const runFork = <A, E>(effect: Effect.Effect<A, E>) => runtime.runFork(withActiveParent(effect))
+
+  export const promise = <A>(fn: (tracer: Tracer) => Promise<A> | A) =>
+    Effect.gen(function* () {
+      const span = yield* Effect.option(OtelTracer.currentOtelSpan)
+      const tracer = yield* Effect.promise(() => aiRuntime.runPromise(Effect.service(AITracer)))
+      return yield* Effect.promise(() => Promise.resolve(withSpan(span, () => fn(tracer))))
+    })
+
+  export const aiTelemetry = (input: {
+    enabled: boolean | undefined
+    tracer: Tracer
+    functionId: string
+    metadata?: Record<string, AttributeValue | undefined>
+  }) => {
+    if (!input.enabled || !traces) return { isEnabled: false as const }
+    return {
+      isEnabled: true as const,
+      functionId: input.functionId,
+      tracer: input.tracer,
+      metadata: input.metadata ? clean(input.metadata) : undefined,
+    }
+  }
 }

+ 2 - 0
packages/opencode/src/flag/flag.ts

@@ -12,6 +12,8 @@ function falsy(key: string) {
 
 export namespace Flag {
   export const OTEL_EXPORTER_OTLP_ENDPOINT = process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
+  export const OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = process.env["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"]
+  export const OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = process.env["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"]
   export const OTEL_EXPORTER_OTLP_HEADERS = process.env["OTEL_EXPORTER_OTLP_HEADERS"]
 
   export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")

+ 74 - 61
packages/opencode/src/session/llm.ts

@@ -21,67 +21,14 @@ import { Wildcard } from "@/util/wildcard"
 import { SessionID } from "@/session/schema"
 import { Auth } from "@/auth"
 import { Installation } from "@/installation"
+import { Observability } from "@/effect/oltp"
+import type { Tracer } from "@opentelemetry/api"
 
 export namespace LLM {
   const log = Log.create({ service: "llm" })
   export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
 
-  export type StreamInput = {
-    user: MessageV2.User
-    sessionID: string
-    parentSessionID?: string
-    model: Provider.Model
-    agent: Agent.Info
-    permission?: Permission.Ruleset
-    system: string[]
-    messages: ModelMessage[]
-    small?: boolean
-    tools: Record<string, Tool>
-    retries?: number
-    toolChoice?: "auto" | "required" | "none"
-  }
-
-  export type StreamRequest = StreamInput & {
-    abort: AbortSignal
-  }
-
-  export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
-
-  export interface Interface {
-    readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      return Service.of({
-        stream(input) {
-          return Stream.scoped(
-            Stream.unwrap(
-              Effect.gen(function* () {
-                const ctrl = yield* Effect.acquireRelease(
-                  Effect.sync(() => new AbortController()),
-                  (ctrl) => Effect.sync(() => ctrl.abort()),
-                )
-
-                const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
-
-                return Stream.fromAsyncIterable(result.fullStream, (e) =>
-                  e instanceof Error ? e : new Error(String(e)),
-                )
-              }),
-            ),
-          )
-        },
-      })
-    }),
-  )
-
-  export const defaultLayer = layer
-
-  export async function stream(input: StreamRequest) {
+  const request = async (input: StreamRequest, tracer: Tracer) => {
     const l = log
       .clone()
       .tag("providerID", input.model.providerID)
@@ -384,16 +331,82 @@ export namespace LLM {
           },
         ],
       }),
-      experimental_telemetry: {
-        isEnabled: cfg.experimental?.openTelemetry,
+      experimental_telemetry: Observability.aiTelemetry({
+        enabled: cfg.experimental?.openTelemetry,
+        tracer,
+        functionId: "LLM.stream",
         metadata: {
-          userId: cfg.username ?? "unknown",
-          sessionId: input.sessionID,
+          userID: cfg.username ?? "unknown",
+          sessionID: input.sessionID,
+          providerID: input.model.providerID,
+          modelID: input.model.id,
+          agent: input.agent.name,
         },
-      },
+      }),
     })
   }
 
+  export type StreamInput = {
+    user: MessageV2.User
+    sessionID: string
+    parentSessionID?: string
+    model: Provider.Model
+    agent: Agent.Info
+    permission?: Permission.Ruleset
+    system: string[]
+    messages: ModelMessage[]
+    small?: boolean
+    tools: Record<string, Tool>
+    retries?: number
+    toolChoice?: "auto" | "required" | "none"
+  }
+
+  export type StreamRequest = StreamInput & {
+    abort: AbortSignal
+  }
+
+  export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
+
+  export interface Interface {
+    readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      return Service.of({
+        stream(input) {
+          return Stream.scoped(
+            Stream.unwrap(
+              Effect.gen(function* () {
+                const ctrl = yield* Effect.acquireRelease(
+                  Effect.sync(() => new AbortController()),
+                  (ctrl) => Effect.sync(() => ctrl.abort()),
+                )
+
+                const result = yield* Observability.promise((tracer) =>
+                  request({ ...input, abort: ctrl.signal }, tracer),
+                )
+
+                return Stream.fromAsyncIterable(result.fullStream, (e) =>
+                  e instanceof Error ? e : new Error(String(e)),
+                )
+              }),
+            ),
+          )
+        },
+      })
+    }),
+  )
+
+  export const defaultLayer = layer
+
+  export async function stream(input: StreamRequest) {
+    return Observability.runPromise(Observability.promise((tracer) => request(input, tracer)))
+  }
+
   function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
     const disabled = Permission.disabled(
       Object.keys(input.tools),

+ 3 - 3
packages/opencode/src/session/prompt.ts

@@ -45,6 +45,7 @@ import { decodeDataUrl } from "@/util/data-url"
 import { Process } from "@/util/process"
 import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
 import { EffectLogger } from "@/effect/logger"
+import { Observability } from "@/effect/oltp"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
@@ -106,9 +107,8 @@ export namespace SessionPrompt {
       const llm = yield* LLM.Service
 
       const run = {
-        promise: <A, E>(effect: Effect.Effect<A, E>) =>
-          Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
-        fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
+        promise: <A, E>(effect: Effect.Effect<A, E>) => Observability.runPromise(effect),
+        fork: <A, E>(effect: Effect.Effect<A, E>) => Observability.runFork(effect),
       }
 
       const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {

+ 19 - 3
packages/opencode/src/tool/tool.ts

@@ -75,8 +75,17 @@ export namespace Tool {
       Effect.gen(function* () {
         const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
         const execute = toolInfo.execute
-        toolInfo.execute = (args, ctx) =>
-          Effect.gen(function* () {
+        toolInfo.execute = (args, ctx) => {
+          const ann = Object.fromEntries(
+            Object.entries({
+              tool: id,
+              agent: ctx.agent,
+              sessionID: ctx.sessionID,
+              messageID: ctx.messageID,
+              callID: ctx.callID,
+            }).filter((entry) => entry[1] !== undefined),
+          )
+          return Effect.gen(function* () {
             yield* Effect.try({
               try: () => toolInfo.parameters.parse(args),
               catch: (error) => {
@@ -104,7 +113,14 @@ export namespace Tool {
                 ...(truncated.truncated && { outputPath: truncated.outputPath }),
               },
             }
-          }).pipe(Effect.orDie)
+          }).pipe(
+            Effect.annotateLogs(ann),
+            Effect.annotateSpans(ann),
+            Effect.withLogSpan(`Tool.${id}`),
+            Effect.withSpan(`Tool.${id}`),
+            Effect.orDie,
+          )
+        }
         return toolInfo
       })
   }