From c523f7ad2bfad79638708ef8eda476838aa41c0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 11 Apr 2026 20:06:51 -0400 Subject: [PATCH] feat: export AI SDK telemetry to local OTLP --- bun.lock | 63 +++++++++++ package.json | 7 ++ packages/opencode/package.json | 7 ++ packages/opencode/src/agent/agent.ts | 74 +++++++------ packages/opencode/src/effect/oltp.ts | 136 ++++++++++++++++++++---- packages/opencode/src/flag/flag.ts | 2 + packages/opencode/src/session/llm.ts | 135 ++++++++++++----------- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/tool/tool.ts | 22 +++- 9 files changed, 331 insertions(+), 121 deletions(-) diff --git a/bun.lock b/bun.lock index 88d963549194..68f542843c08 100644 --- a/bun.lock +++ b/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/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.46", "", { "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/platform-node@4.0.0-beta.46", "", { "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/platform-node-shared@4.0.0-beta.46", "", { "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/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.211.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.5.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "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/exporter-trace-otlp-http@0.211.0", "", { "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/otlp-exporter-base@0.211.0", "", { "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/otlp-transformer@0.211.0", "", { "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/resources@2.5.0", "", { "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/sdk-logs@0.211.0", "", { "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/sdk-metrics@2.5.0", "", { "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/sdk-trace-base@2.5.0", "", { "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/sdk-trace-node@2.5.0", "", { "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/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], + "@opentui/core": ["@opentui/core@0.1.97", "", { "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/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="], @@ -1695,6 +1736,26 @@ "@protobuf-ts/runtime-rpc": ["@protobuf-ts/runtime-rpc@2.11.1", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" } }, "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="], @@ -4163,6 +4224,8 @@ "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + "protobufjs": ["protobufjs@8.0.0", "", { "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": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], diff --git a/package.json b/package.json index d08bada05203..b3740380b8b1 100644 --- a/package.json +++ b/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", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 18feb467578a..7f92bad7a2c6 100644 --- a/packages/opencode/package.json +++ b/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", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index fd9ac43e8bf2..c858c4b48034 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/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[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[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) }), }) }), diff --git a/packages/opencode/src/effect/oltp.ts b/packages/opencode/src/effect/oltp.ts index 33b67151af34..1225c25306ab 100644 --- a/packages/opencode/src/effect/oltp.ts +++ b/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()("@opencode/Observability/AITracer") {} + + const clean = >(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 + } + + 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, + ) + : 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, - ) - : 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 = (span: Option.Option, fn: () => A): A => + Option.match(span, { + onNone: fn, + onSome: (span) => context.with(trace.setSpan(context.active(), span), fn), + }) + + const withActiveParent = (effect: Effect.Effect) => { + const active = trace.getActiveSpan() + if (!active) return effect + return effect.pipe(OtelTracer.withSpanContext(active.spanContext())) + } + + export const runPromise = (effect: Effect.Effect) => runtime.runPromise(withActiveParent(effect)) + + export const runFork = (effect: Effect.Effect) => runtime.runFork(withActiveParent(effect)) + + export const promise = (fn: (tracer: Tracer) => Promise | 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 + }) => { + 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, + } + } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f091fa02a987..ea95116f652c 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/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") diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f6e5c9a3f2fb..58185e87fc56 100644 --- a/packages/opencode/src/session/llm.ts +++ b/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 - retries?: number - toolChoice?: "auto" | "required" | "none" - } - - export type StreamRequest = StreamInput & { - abort: AbortSignal - } - - export type Event = Awaited>["fullStream"] extends AsyncIterable ? T : never - - export interface Interface { - readonly stream: (input: StreamInput) => Stream.Stream - } - - export class Service extends Context.Service()("@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 + retries?: number + toolChoice?: "auto" | "required" | "none" + } + + export type StreamRequest = StreamInput & { + abort: AbortSignal + } + + export type Event = Awaited>["fullStream"] extends AsyncIterable ? T : never + + export interface Interface { + readonly stream: (input: StreamInput) => Stream.Stream + } + + export class Service extends Context.Service()("@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) { const disabled = Permission.disabled( Object.keys(input.tools), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 97a37865dfa2..c8a2e765c8c8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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: (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))), - fork: (effect: Effect.Effect) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))), + promise: (effect: Effect.Effect) => Observability.runPromise(effect), + fork: (effect: Effect.Effect) => Observability.runFork(effect), } const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 3009f4cd275c..4a74fbb2d1d4 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/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 }) }