diff --git a/.env.example b/.env.example index 3c42151aee..79a37105b4 100644 --- a/.env.example +++ b/.env.example @@ -168,6 +168,13 @@ PORT=3000 # CLI can override with --quiet (error) or --verbose (debug) # LOG_LEVEL=info +# Observability (Optional - Langfuse) +# AI model call tracing via Langfuse (https://langfuse.com or self-hosted) +# When not set, observability is disabled with zero overhead +# LANGFUSE_PUBLIC_KEY=pk-lf-... +# LANGFUSE_SECRET_KEY=sk-lf-... +# LANGFUSE_BASE_URL=https://cloud.langfuse.com + # Concurrency MAX_CONCURRENT_CONVERSATIONS=10 # Maximum concurrent AI conversations (default: 10) diff --git a/bun.lock b/bun.lock index 356a76ed8d..c4762917e3 100644 --- a/bun.lock +++ b/bun.lock @@ -129,7 +129,10 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/paths": "workspace:*", + "@langfuse/otel": "^5.1.0", + "@langfuse/tracing": "^5.1.0", "@openai/codex-sdk": "^0.116.0", + "@opentelemetry/sdk-node": "^0.214.0", }, "devDependencies": { "pino": "^9", @@ -452,6 +455,10 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/zod-openapi": ["@hono/zod-openapi@0.19.10", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@hono/zod-validator": "^0.7.1", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": ">=3.0.0" } }, "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA=="], @@ -536,6 +543,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@langfuse/core": ["@langfuse/core@5.1.0", "", { "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-yFvC67HBtrY4B3tyzF8+RJaIqK79LBVXtAgtmEc2vhpKauecvSW0zevRnRynFX+ajUHqi9TN7tnD91FJszFLgQ=="], + + "@langfuse/otel": ["@langfuse/otel@5.1.0", "", { "dependencies": { "@langfuse/core": "^5.1.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", "@opentelemetry/exporter-trace-otlp-http": ">=0.202.0 <1.0.0", "@opentelemetry/sdk-trace-base": "^2.0.1" } }, "sha512-pvaXgZHMHqjsRjn+Gs5amrrq61w0Rxz1OChmLr2FfQzlymNl7+MxSXsWBj5dZQlufGbhyG+LT3wdx3MV8aLXHQ=="], + + "@langfuse/tracing": ["@langfuse/tracing@5.1.0", "", { "dependencies": { "@langfuse/core": "^5.1.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-ScwYnQzqLZOaMPZkCsWizx139eb02GI8tD5yxs5XVjGNGZxKdw1DfRPTIONSlOhaAYCY9ILGTJdkqAtNTzsbRg=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], @@ -600,6 +615,64 @@ "@openai/codex-win32-x64": ["@openai/codex@0.116.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-6sBIMOoA9FNuxQvCCnK0P548Wqrlk3I9SMdtOCUg2zYzYU7jOF2mWS1VpRQ6R+Jvo2x50dxeJZ+W37dBmXfprw=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + + "@opentelemetry/configuration": ["@opentelemetry/configuration@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "yaml": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-Q+awuEwxhETwIAXuxHvIY5ZMEP0ZqvxLTi9kclrkyVJppEUXYL3Bhiw3jYrxdHYMh0Y0tVInQH9FEZ1aMinvLA=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.214.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-logs": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw=="], + + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-logs": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw=="], + + "@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.214.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.6.1", "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-metrics": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-metrics": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-metrics": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA=="], + + "@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-4TGYoZKebUWVuYkV6r5wS2dUF4zH7EbWFw/Uqz1ZM1tGHQeFT9wzHGXq3iSIXMUrwu5jRdxjfMaXrYejPu2kpQ=="], + + "@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.214.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg=="], + + "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-km2/hD3inLTqtLnUAHDGz7ZP/VOyZNslrC/iN66x4jkmpckwlONW54LRPNI6fm09/musDtZga9EWsxgwnjGUlw=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], + + "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.214.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], + + "@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Dvz9TA6cPqIbxolSzQ5x9br6iQlqdGhVYrm+oYc7pfJ7LaVXz8F0XIqhWbnKB5YvfZ6SUmabBUUxnvHs/9uhxA=="], + + "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-kKFMxBcjBZAC1vBch5mtZ/dJQvcAEKWga+c+q5iGgRLPIE6Mc649zEwMaCIQCzalziMJQiyUadFYMHmELB7AFw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + + "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/configuration": "0.214.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/exporter-logs-otlp-grpc": "0.214.0", "@opentelemetry/exporter-logs-otlp-http": "0.214.0", "@opentelemetry/exporter-logs-otlp-proto": "0.214.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.214.0", "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.214.0", "@opentelemetry/exporter-prometheus": "0.214.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", "@opentelemetry/exporter-zipkin": "2.6.1", "@opentelemetry/instrumentation": "0.214.0", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/propagator-b3": "2.6.1", "@opentelemetry/propagator-jaeger": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="], @@ -618,6 +691,26 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@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/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -998,6 +1091,8 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1106,6 +1201,8 @@ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], @@ -1536,6 +1633,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-in-the-middle": ["import-in-the-middle@3.0.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], @@ -1680,6 +1779,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], @@ -1702,6 +1803,8 @@ "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1856,6 +1959,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -2032,6 +2137,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "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-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "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@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -2132,6 +2239,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -2648,6 +2757,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "protobufjs/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "react-markdown/remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "react-markdown/unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -2886,6 +2997,8 @@ "parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "react-markdown/unified/bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "react-markdown/unified/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5b66262435..391e4fc157 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -82,6 +82,7 @@ import { BUNDLED_VERSION, } from '@archon/paths'; import * as git from '@archon/git'; +import { initLangfuse, shutdownLangfuse, isLangfuseEnabled } from '@archon/providers'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -253,6 +254,11 @@ async function main(): Promise { setLogLevel('debug'); } + // Initialize Langfuse observability (no-op when env vars not set) + if (isLangfuseEnabled()) { + await initLangfuse(); + } + // Note: orphaned run cleanup moved to `workflow cleanup` command only. // Running it on every CLI startup killed parallel workflow runs (all // 'running' status rows were marked failed by each new process). @@ -573,6 +579,7 @@ async function main(): Promise { } return 1; } finally { + await shutdownLangfuse(); // Always close database connection await closeDb(); } diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 8c38adc810..e7831e153f 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -43,6 +43,7 @@ import type { MergedConfig } from '../config/config-types'; import { generateAndSetTitle } from '../services/title-generator'; import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator'; import { IsolationBlockedError } from '@archon/isolation'; +import { withObservabilityContext } from '@archon/providers/observability'; import { buildOrchestratorPrompt, buildProjectScopedPrompt } from './prompt-builder'; import * as workflowDb from '../db/workflows'; import * as workflowEventDb from '../db/workflow-events'; @@ -500,6 +501,18 @@ export async function handleMessage( conversationId: string, message: string, context?: HandleMessageContext +): Promise { + return withObservabilityContext( + { conversationId, platformType: platform.getPlatformType() }, + () => handleMessageInner(platform, conversationId, message, context) + ); +} + +async function handleMessageInner( + platform: IPlatformAdapter, + conversationId: string, + message: string, + context?: HandleMessageContext ): Promise { const { issueContext, threadContext, parentConversationId, isolationHints, attachedFiles } = context ?? {}; diff --git a/packages/providers/package.json b/packages/providers/package.json index cbe4a4617a..9b529f94d8 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -13,16 +13,20 @@ "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", "./errors": "./src/errors.ts", - "./registry": "./src/registry.ts" + "./registry": "./src/registry.ts", + "./observability": "./src/observability.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts", + "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/observability.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/paths": "workspace:*", - "@openai/codex-sdk": "^0.116.0" + "@langfuse/otel": "^5.1.0", + "@langfuse/tracing": "^5.1.0", + "@openai/codex-sdk": "^0.116.0", + "@opentelemetry/sdk-node": "^0.214.0" }, "devDependencies": { "pino": "^9" diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index b4769e66ec..c6ea9b704b 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -18,6 +18,7 @@ import { type HookCallback, type HookCallbackMatcher, } from '@anthropic-ai/claude-agent-sdk'; +import { traceQuery } from '../observability'; import cliPath from '@anthropic-ai/claude-agent-sdk/embed'; import type { IAgentProvider, @@ -147,16 +148,17 @@ class FirstEventTimeoutError extends Error {} * within `timeoutMs`. If it doesn't, aborts the controller and throws. */ export async function* withFirstMessageTimeout( - gen: AsyncGenerator, + iterable: AsyncIterable, controller: AbortController, timeoutMs: number, diagnostics: Record ): AsyncGenerator { + const iter = iterable[Symbol.asyncIterator](); let timerId: ReturnType | undefined; let firstValue: IteratorResult; try { firstValue = await Promise.race([ - gen.next(), + iter.next(), new Promise((_, reject) => { timerId = setTimeout(() => { reject(new FirstEventTimeoutError()); @@ -182,7 +184,10 @@ export async function* withFirstMessageTimeout( if (firstValue.done) return; yield firstValue.value; - yield* gen; + // Continue yielding remaining items from the iterator + for await (const item of { [Symbol.asyncIterator]: () => iter }) { + yield item; + } } /** @@ -916,8 +921,8 @@ export class ClaudeProvider implements IAgentProvider { ); const events = withFirstMessageTimeout(rawEvents, controller, timeoutMs, diagnostics); - // 5. Stream normalized events - yield* streamClaudeMessages(events, toolResultQueue); + // 5. Stream normalized events (with optional Langfuse tracing) + yield* traceQuery(prompt, options.model, streamClaudeMessages(events, toolResultQueue)); return; } catch (error) { const err = error as Error; diff --git a/packages/providers/src/codex/provider.ts b/packages/providers/src/codex/provider.ts index b9e1d493e9..0d1f7181c6 100644 --- a/packages/providers/src/codex/provider.ts +++ b/packages/providers/src/codex/provider.ts @@ -16,6 +16,7 @@ import type { ProviderCapabilities, } from '../types'; import { parseCodexConfig } from './config'; +import { traceQuery } from '../observability'; import { CODEX_CAPABILITIES } from './capabilities'; import { resolveCodexBinaryPath } from './binary-resolver'; import { createLogger } from '@archon/paths'; @@ -584,11 +585,15 @@ export class CodexProvider implements IAgentProvider { const result = await thread.runStreamed(prompt, turnOptions); // 5. Stream normalized events (fresh state per attempt to avoid dedup leaks) - yield* streamCodexEvents( - result.events as AsyncIterable>, - hasOutputFormat, - thread.id, - requestOptions?.abortSignal + yield* traceQuery( + prompt, + requestOptions?.model, + streamCodexEvents( + result.events as AsyncIterable>, + hasOutputFormat, + thread.id, + requestOptions?.abortSignal + ) ); return; } catch (error) { diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index e24bb630eb..657f5e5d45 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -43,3 +43,12 @@ export { parseCodexConfig, type CodexProviderDefaults } from './codex/config'; // Utilities (needed by consumers) export { resetCodexSingleton } from './codex/provider'; export { resolveCodexBinaryPath, fileExists } from './codex/binary-resolver'; + +// Observability (optional Langfuse integration) +export { + initLangfuse, + shutdownLangfuse, + isLangfuseEnabled, + withObservabilityContext, + type ObservabilityAttrs, +} from './observability'; diff --git a/packages/providers/src/observability.test.ts b/packages/providers/src/observability.test.ts new file mode 100644 index 0000000000..8729e3a2b4 --- /dev/null +++ b/packages/providers/src/observability.test.ts @@ -0,0 +1,300 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { + withObservabilityContext, + getObservabilityContext, + isLangfuseEnabled, + initLangfuse, + shutdownLangfuse, + traceQuery, +} from './observability'; +import type { MessageChunk } from './types'; + +describe('observability', () => { + // ─── Context Propagation ──────────────────────────────────────────────── + + describe('withObservabilityContext', () => { + test('sets context for synchronous callback', () => { + let captured: ReturnType; + + withObservabilityContext({ conversationId: 'conv-1' }, () => { + captured = getObservabilityContext(); + }); + + expect(captured!).toEqual({ conversationId: 'conv-1' }); + }); + + test('sets context for async callback', async () => { + let captured: ReturnType; + + await withObservabilityContext( + { conversationId: 'conv-2', platformType: 'web' }, + async () => { + await new Promise(r => setTimeout(r, 10)); + captured = getObservabilityContext(); + } + ); + + expect(captured!).toEqual({ conversationId: 'conv-2', platformType: 'web' }); + }); + + test('nested calls merge attributes (inner overrides outer)', () => { + let outerCtx: ReturnType; + let innerCtx: ReturnType; + + withObservabilityContext({ conversationId: 'conv-1', platformType: 'slack' }, () => { + outerCtx = getObservabilityContext(); + withObservabilityContext({ workflowName: 'assist', platformType: 'cli' }, () => { + innerCtx = getObservabilityContext(); + }); + }); + + expect(outerCtx!).toEqual({ conversationId: 'conv-1', platformType: 'slack' }); + expect(innerCtx!).toEqual({ + conversationId: 'conv-1', + platformType: 'cli', + workflowName: 'assist', + }); + }); + + test('returns callback return value', () => { + const result = withObservabilityContext({ conversationId: 'c' }, () => 42); + expect(result).toBe(42); + }); + + test('returns async callback return value', async () => { + const result = await withObservabilityContext({ conversationId: 'c' }, async () => { + await new Promise(r => setTimeout(r, 1)); + return 'hello'; + }); + expect(result).toBe('hello'); + }); + }); + + describe('getObservabilityContext', () => { + test('returns undefined outside any context', () => { + expect(getObservabilityContext()).toBeUndefined(); + }); + }); + + // ─── isLangfuseEnabled ────────────────────────────────────────────────── + + describe('isLangfuseEnabled', () => { + const origPK = process.env.LANGFUSE_PUBLIC_KEY; + const origSK = process.env.LANGFUSE_SECRET_KEY; + + afterEach(() => { + // Restore original env + if (origPK !== undefined) process.env.LANGFUSE_PUBLIC_KEY = origPK; + else delete process.env.LANGFUSE_PUBLIC_KEY; + if (origSK !== undefined) process.env.LANGFUSE_SECRET_KEY = origSK; + else delete process.env.LANGFUSE_SECRET_KEY; + }); + + test('returns false when no env vars set', () => { + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + expect(isLangfuseEnabled()).toBe(false); + }); + + test('returns false when only public key set', () => { + process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'; + delete process.env.LANGFUSE_SECRET_KEY; + expect(isLangfuseEnabled()).toBe(false); + }); + + test('returns false when only secret key set', () => { + delete process.env.LANGFUSE_PUBLIC_KEY; + process.env.LANGFUSE_SECRET_KEY = 'sk-test'; + expect(isLangfuseEnabled()).toBe(false); + }); + + test('returns true when both keys set', () => { + process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'; + process.env.LANGFUSE_SECRET_KEY = 'sk-test'; + expect(isLangfuseEnabled()).toBe(true); + }); + + test('returns false for empty string keys', () => { + process.env.LANGFUSE_PUBLIC_KEY = ''; + process.env.LANGFUSE_SECRET_KEY = ''; + expect(isLangfuseEnabled()).toBe(false); + }); + }); + + // ─── initLangfuse ────────────────────────────────────────────────────── + + describe('initLangfuse', () => { + const origPK = process.env.LANGFUSE_PUBLIC_KEY; + const origSK = process.env.LANGFUSE_SECRET_KEY; + + afterEach(async () => { + await shutdownLangfuse(); + if (origPK !== undefined) process.env.LANGFUSE_PUBLIC_KEY = origPK; + else delete process.env.LANGFUSE_PUBLIC_KEY; + if (origSK !== undefined) process.env.LANGFUSE_SECRET_KEY = origSK; + else delete process.env.LANGFUSE_SECRET_KEY; + }); + + test('returns false when env vars not set', async () => { + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + const result = await initLangfuse(); + expect(result).toBe(false); + }); + }); + + // ─── traceQuery ───────────────────────────────────────────────────────── + + describe('traceQuery', () => { + async function* fakeGenerator(): AsyncGenerator { + yield { type: 'assistant', content: 'Hello ' }; + yield { type: 'assistant', content: 'world' }; + yield { type: 'result', sessionId: 'sess-1', tokens: { input: 10, output: 5 } }; + } + + test('passes through all chunks when Langfuse not initialized', async () => { + const chunks: MessageChunk[] = []; + for await (const chunk of traceQuery('test prompt', 'sonnet', fakeGenerator())) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ type: 'assistant', content: 'Hello ' }); + expect(chunks[1]).toEqual({ type: 'assistant', content: 'world' }); + expect(chunks[2]).toMatchObject({ type: 'result', sessionId: 'sess-1' }); + }); + + test('passes through tool call and tool result chunks', async () => { + async function* toolGenerator(): AsyncGenerator { + yield { type: 'tool', toolName: 'Read', toolInput: { path: '/file.ts' } }; + yield { type: 'tool_result', toolName: 'Read', toolOutput: 'file content here' }; + yield { type: 'assistant', content: 'I read the file.' }; + yield { type: 'result', sessionId: 'sess-2', tokens: { input: 50, output: 20 } }; + } + + const chunks: MessageChunk[] = []; + for await (const chunk of traceQuery('read a file', 'sonnet', toolGenerator())) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(4); + expect(chunks[0]).toMatchObject({ type: 'tool', toolName: 'Read' }); + expect(chunks[1]).toMatchObject({ type: 'tool_result', toolName: 'Read' }); + expect(chunks[2]).toEqual({ type: 'assistant', content: 'I read the file.' }); + expect(chunks[3]).toMatchObject({ type: 'result', sessionId: 'sess-2' }); + }); + + test('handles empty generator', async () => { + async function* emptyGenerator(): AsyncGenerator { + // yields nothing + } + + const chunks: MessageChunk[] = []; + for await (const chunk of traceQuery('empty', 'sonnet', emptyGenerator())) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(0); + }); + + test('passes through cost and numTurns from result', async () => { + async function* costGenerator(): AsyncGenerator { + yield { type: 'assistant', content: 'done' }; + yield { + type: 'result', + sessionId: 'sess-3', + tokens: { input: 100, output: 50, total: 150 }, + cost: 0.0042, + numTurns: 3, + }; + } + + const chunks: MessageChunk[] = []; + for await (const chunk of traceQuery('test', 'opus', costGenerator())) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(2); + const result = chunks[1]; + expect(result).toMatchObject({ + type: 'result', + cost: 0.0042, + numTurns: 3, + }); + }); + + test('delivers all chunks when consumed via yield* delegation chain', async () => { + // Regression test: traceQuery must deliver all chunks even when consumed + // through multiple layers of yield* delegation (as in sendQuery → DAG executor). + // A previous streaming implementation lost traces because post-yield code + // in async generators is not reliably executed through yield* chains in Bun. + async function* innerGenerator(): AsyncGenerator { + yield { type: 'tool', toolName: 'Read', toolInput: { path: '/a.ts' } }; + yield { type: 'tool_result', toolName: 'Read', toolOutput: 'content-a' }; + yield { type: 'assistant', content: 'Analysis: ' }; + yield { type: 'tool', toolName: 'Bash', toolInput: { command: 'ls' } }; + yield { type: 'tool_result', toolName: 'Bash', toolOutput: 'file1\nfile2' }; + yield { type: 'assistant', content: 'done.' }; + yield { type: 'result', sessionId: 'sess-chain', tokens: { input: 200, output: 80 } }; + } + + // Simulate yield* delegation chain (sendQuery pattern) + async function* middleLayer(): AsyncGenerator { + yield* traceQuery('analyze code', 'sonnet', innerGenerator()); + } + + async function* outerLayer(): AsyncGenerator { + yield* middleLayer(); + } + + const chunks: MessageChunk[] = []; + for await (const chunk of outerLayer()) { + chunks.push(chunk); + } + + // All 7 chunks must arrive — none lost in the delegation chain + expect(chunks).toHaveLength(7); + expect(chunks[0]).toMatchObject({ type: 'tool', toolName: 'Read' }); + expect(chunks[1]).toMatchObject({ type: 'tool_result', toolName: 'Read' }); + expect(chunks[2]).toEqual({ type: 'assistant', content: 'Analysis: ' }); + expect(chunks[3]).toMatchObject({ type: 'tool', toolName: 'Bash' }); + expect(chunks[4]).toMatchObject({ type: 'tool_result', toolName: 'Bash' }); + expect(chunks[5]).toEqual({ type: 'assistant', content: 'done.' }); + expect(chunks[6]).toMatchObject({ type: 'result', sessionId: 'sess-chain' }); + }); + + test('preserves generator error propagation', async () => { + async function* errorGenerator(): AsyncGenerator { + yield { type: 'assistant', content: 'partial' }; + throw new Error('stream failed'); + } + + const chunks: MessageChunk[] = []; + try { + for await (const chunk of traceQuery('test', 'sonnet', errorGenerator())) { + chunks.push(chunk); + } + } catch (err) { + expect((err as Error).message).toBe('stream failed'); + } + + // Chunk was yielded immediately (streaming preserved), before error + expect(chunks).toHaveLength(1); + expect(chunks[0]).toEqual({ type: 'assistant', content: 'partial' }); + }); + }); + + // ─── shutdownLangfuse ────────────────────────────────────────────────── + + describe('shutdownLangfuse', () => { + test('is safe to call when not initialized', async () => { + // Should not throw + await shutdownLangfuse(); + }); + + test('is safe to call multiple times', async () => { + await shutdownLangfuse(); + await shutdownLangfuse(); + }); + }); +}); diff --git a/packages/providers/src/observability.ts b/packages/providers/src/observability.ts new file mode 100644 index 0000000000..0ba511bed3 --- /dev/null +++ b/packages/providers/src/observability.ts @@ -0,0 +1,308 @@ +/** + * Observability module — optional Langfuse integration via OpenTelemetry. + * + * When LANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEY are set, initializes: + * - OTel NodeSDK with LangfuseSpanProcessor + * - Manual tracing of Claude Agent SDK calls with input/output/usage/tool calls + * + * When not configured, all exports are safe no-ops with zero overhead. + */ +import { AsyncLocalStorage } from 'async_hooks'; +import { createLogger } from '@archon/paths'; +import type { MessageChunk, TokenUsage } from './types'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('observability'); + return cachedLog; +} + +// ─── Context Propagation ──────────────────────────────────────────────────── + +/** Attributes propagated through async call chains via AsyncLocalStorage */ +export interface ObservabilityAttrs { + conversationId?: string; + platformType?: string; + workflowName?: string; +} + +const store = new AsyncLocalStorage(); + +/** + * Run a callback with observability attributes attached. + * Nested calls merge: inner attrs override outer attrs for the same key. + */ +export function withObservabilityContext(attrs: ObservabilityAttrs, fn: () => T): T { + const parent = store.getStore(); + const merged = parent ? { ...parent, ...attrs } : attrs; + return store.run(merged, fn); +} + +/** Read the current observability context (undefined when outside any scope) */ +export function getObservabilityContext(): ObservabilityAttrs | undefined { + return store.getStore(); +} + +// ─── Langfuse Initialization ──────────────────────────────────────────────── + +let initialized = false; + +// Hold references for clean shutdown +let otelSdk: { shutdown: () => Promise } | null = null; +let spanProcessor: { forceFlush: () => Promise } | null = null; + +// Lazily-loaded tracing functions (populated by initLangfuse) +let tracingStartActiveObservation: + | typeof import('@langfuse/tracing').startActiveObservation + | null = null; +let tracingPropagateAttributes: typeof import('@langfuse/tracing').propagateAttributes | null = + null; + +/** Check whether Langfuse env vars are present */ +export function isLangfuseEnabled(): boolean { + return !!(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY); +} + +/** + * Initialize Langfuse observability. + * + * - Uses dynamic imports so the packages are only loaded when needed + * - Gracefully degrades: logs a warning and continues if anything fails + * - Idempotent: calling multiple times is safe + */ +export async function initLangfuse(): Promise { + if (initialized) return true; + if (!isLangfuseEnabled()) return false; + + try { + const [otelSdkMod, langfuseOtel, langfuseTracing] = await Promise.all([ + import('@opentelemetry/sdk-node'), + import('@langfuse/otel'), + import('@langfuse/tracing'), + ]); + + tracingStartActiveObservation = langfuseTracing.startActiveObservation; + tracingPropagateAttributes = langfuseTracing.propagateAttributes; + + const publicKey = process.env.LANGFUSE_PUBLIC_KEY ?? ''; + const secretKey = process.env.LANGFUSE_SECRET_KEY ?? ''; + const processor = new langfuseOtel.LangfuseSpanProcessor({ + publicKey, + secretKey, + baseUrl: process.env.LANGFUSE_BASE_URL ?? 'https://cloud.langfuse.com', + }); + + const sdk = new otelSdkMod.NodeSDK({ + spanProcessors: [processor], + }); + sdk.start(); + otelSdk = sdk; + spanProcessor = processor; + + initialized = true; + getLog().info( + { baseUrl: process.env.LANGFUSE_BASE_URL ?? 'https://cloud.langfuse.com' }, + 'langfuse.init_completed' + ); + return true; + } catch (error) { + const err = error as Error; + getLog().warn({ error: err.message, errorType: err.constructor.name }, 'langfuse.init_failed'); + resetState(); + return false; + } +} + +/** Flush pending spans and shut down the OTel SDK */ +export async function shutdownLangfuse(): Promise { + try { + // Explicit flush before shutdown to ensure all spans are exported + if (spanProcessor) { + await spanProcessor.forceFlush(); + } + if (otelSdk) { + await otelSdk.shutdown(); + getLog().debug('langfuse.shutdown_completed'); + } + } catch (error) { + const err = error as Error; + getLog().warn({ error: err.message }, 'langfuse.shutdown_failed'); + } finally { + resetState(); + } +} + +function resetState(): void { + otelSdk = null; + spanProcessor = null; + initialized = false; + tracingStartActiveObservation = null; + tracingPropagateAttributes = null; +} + +// ─── Query Tracing ────────────────────────────────────────────────────────── + +/** + * Wrap a MessageChunk async generator with Langfuse tracing. + * + * Creates a parent "generation" observation with: + * - Input: the prompt sent to Claude + * - Output: collected assistant text + * - Usage: token counts and cost from the result event + * - Children: one "tool" span per tool call with name and input/output + * + * When Langfuse is not initialized, returns the generator unchanged (zero overhead). + */ +export async function* traceQuery( + prompt: string, + model: string | undefined, + generator: AsyncIterable +): AsyncGenerator { + if (!initialized || !tracingStartActiveObservation || !tracingPropagateAttributes) { + yield* generator as AsyncGenerator; + return; + } + + const startActiveObservation = tracingStartActiveObservation; + const propagateAttributes = tracingPropagateAttributes; + const ctx = getObservabilityContext(); + + // Collect all chunks first, then create trace, then yield. + // We buffer because post-yield code in async generators is not reliably + // executed when delegated through multiple yield* layers in Bun. + const textParts: string[] = []; + const toolCalls: { name: string; input?: unknown; output?: string; durationMs?: number }[] = []; + let usage: TokenUsage | undefined; + let cost: number | undefined; + let numTurns: number | undefined; + let sessionId: string | undefined; + let traceError: Error | undefined; + + // Track active tool calls by ID for correct pairing (tools can overlap) + const activeToolCalls = new Map(); + const unnamedToolQueue: { name: string; input?: unknown; startTime: number }[] = []; + + const chunks: MessageChunk[] = []; + let streamError: Error | undefined; + + // Consume the stream and collect metadata + try { + for await (const chunk of generator) { + chunks.push(chunk); + + if (chunk.type === 'assistant') { + textParts.push(chunk.content); + } else if (chunk.type === 'tool') { + const call = { name: chunk.toolName, input: chunk.toolInput, startTime: Date.now() }; + if (chunk.toolCallId) activeToolCalls.set(chunk.toolCallId, call); + else unnamedToolQueue.push(call); + } else if (chunk.type === 'tool_result') { + const matched = chunk.toolCallId + ? activeToolCalls.get(chunk.toolCallId) + : unnamedToolQueue.shift(); + if (matched) { + toolCalls.push({ + name: matched.name, + input: matched.input, + output: + typeof chunk.toolOutput === 'string' + ? chunk.toolOutput.slice(0, 1000) + : JSON.stringify(chunk.toolOutput).slice(0, 1000), + durationMs: Date.now() - matched.startTime, + }); + if (chunk.toolCallId) activeToolCalls.delete(chunk.toolCallId); + } + } else if (chunk.type === 'result') { + // Flush remaining unmatched tool calls + for (const pending of activeToolCalls.values()) { + toolCalls.push({ + name: pending.name, + input: pending.input, + durationMs: Date.now() - pending.startTime, + }); + } + activeToolCalls.clear(); + for (let pending = unnamedToolQueue.shift(); pending; pending = unnamedToolQueue.shift()) { + toolCalls.push({ + name: pending.name, + input: pending.input, + durationMs: Date.now() - pending.startTime, + }); + } + if (chunk.tokens) usage = chunk.tokens; + if (chunk.cost !== undefined) cost = chunk.cost; + if (chunk.numTurns !== undefined) numTurns = chunk.numTurns; + if (chunk.sessionId) sessionId = chunk.sessionId; + } + } + } catch (err) { + streamError = err as Error; + traceError = streamError; + } + + // Create the trace after stream completes + try { + await propagateAttributes( + { + ...(ctx?.conversationId ? { sessionId: ctx.conversationId } : {}), + tags: [ + ...(ctx?.platformType ? [ctx.platformType] : []), + ...(ctx?.workflowName ? [ctx.workflowName] : []), + ], + }, + async () => { + await startActiveObservation( + 'agent-query', + async obs => { + for (const tool of toolCalls) { + const toolObs = obs.startObservation( + tool.name, + { input: tool.input, output: tool.output }, + { asType: 'tool' } + ); + toolObs.end(); + } + + const usageDetails: Record = {}; + if (usage) { + if (usage.input) usageDetails.input = usage.input; + if (usage.output) usageDetails.output = usage.output; + if (usage.total) usageDetails.total = usage.total; + } + + obs.update({ + input: prompt, + output: textParts.join(''), + ...(model ? { model } : {}), + usageDetails: Object.keys(usageDetails).length > 0 ? usageDetails : undefined, + metadata: { + ...(cost !== undefined ? { totalCostUsd: cost } : {}), + ...(numTurns !== undefined ? { numTurns } : {}), + ...(sessionId ? { sessionId } : {}), + ...(toolCalls.length > 0 ? { toolCallCount: toolCalls.length } : {}), + ...(traceError ? { error: traceError.message } : {}), + }, + level: traceError ? 'ERROR' : undefined, + }); + }, + { asType: 'generation' } + ); + } + ); + } catch (traceErr) { + getLog().warn( + { error: (traceErr as Error).message, errorType: (traceErr as Error).constructor.name }, + 'langfuse.trace_failed' + ); + } + + // Yield all collected chunks to the consumer + for (const chunk of chunks) { + yield chunk; + } + + if (streamError) { + throw streamError; + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d8b1a4c4c8..9790de24d5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -67,6 +67,7 @@ import { MessagePersistence } from './adapters/web/persistence'; import { SSETransport } from './adapters/web/transport'; import { WorkflowEventBridge } from './adapters/web/workflow-bridge'; import { registerApiRoutes } from './routes/api'; +import { initLangfuse, shutdownLangfuse, isLangfuseEnabled } from '@archon/providers'; import { handleMessage, pool, @@ -205,6 +206,11 @@ export async function startServer(opts: ServerOptions = {}): Promise { const config = await loadConfig(); logConfig(config); + // Initialize Langfuse observability (no-op when env vars not set) + if (isLangfuseEnabled()) { + await initLangfuse(); + } + // Start cleanup scheduler startCleanupScheduler(); @@ -617,9 +623,12 @@ export async function startServer(opts: ServerOptions = {}): Promise { stopCleanupScheduler(); persistence.stopPeriodicFlush(); - // Flush all buffered messages before stopping adapters - persistence - .flushAll() + // Flush Langfuse spans, then buffered messages before stopping adapters + shutdownLangfuse() + .catch((e: unknown) => { + getLog().error({ err: e }, 'langfuse.shutdown_failed'); + }) + .then(() => persistence.flushAll()) .catch((e: unknown) => { getLog().error({ err: e }, 'shutdown_flush_failed'); }) diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index aef51bc764..f6ca702d45 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -21,6 +21,7 @@ import type { TokenUsage, } from '@archon/providers/types'; import { getProviderCapabilities } from '@archon/providers'; +import { withObservabilityContext } from '@archon/providers/observability'; import type { DagNode, ApprovalNode, @@ -2123,6 +2124,46 @@ export async function executeDagWorkflow( configuredCommandFolder?: string, issueContext?: string, priorCompletedNodes?: Map +): Promise { + return withObservabilityContext({ workflowName: workflow.name, conversationId }, () => + executeDagWorkflowInner( + deps, + platform, + conversationId, + cwd, + workflow, + workflowRun, + workflowProvider, + workflowModel, + artifactsDir, + logDir, + baseBranch, + docsDir, + config, + configuredCommandFolder, + issueContext, + priorCompletedNodes + ) + ); +} + +async function executeDagWorkflowInner( + deps: WorkflowDeps, + platform: IWorkflowPlatform, + conversationId: string, + cwd: string, + workflow: { name: string; nodes: readonly DagNode[] } & WorkflowLevelOptions, + workflowRun: WorkflowRun, + workflowProvider: 'claude' | 'codex', + workflowModel: string | undefined, + artifactsDir: string, + logDir: string, + baseBranch: string, + docsDir: string, + config: WorkflowConfig, + configuredCommandFolder?: string, + issueContext?: string, + priorCompletedNodes?: Map ): Promise { const dagStartTime = Date.now(); const workflowLevelOptions = {