diff --git a/bun.lock b/bun.lock index d01e54dbe7..c380532019 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,11 @@ "@mozilla/readability": "^0.6.0", "@novnc/novnc": "^1.6.0", "@openrouter/ai-sdk-provider": "^2.9.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.38.0", "@orpc/client": "^1.11.3", "@orpc/openapi": "^1.12.2", "@orpc/server": "^1.11.3", @@ -81,8 +86,8 @@ "quickjs-emscripten-core": "^0.31.0", "react": "18.3.1", "react-colorful": "^5.6.1", - "react-resizable-panels": "^3.0.6", "react-dom": "18.3.1", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.11.0", "recharts": "^2.15.3", "rehype-harden": "^1.1.5", @@ -898,6 +903,30 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.7.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jbzDw1q+BkwKFq9yxhjAJ9rjKldbt5AgIy1gmEIJjEV/WRxQ3B6HcLVkwbjJ3RcMif86BDNKR846KJ0tY0aOJA=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@orpc/client": ["@orpc/client@1.12.2", "", { "dependencies": { "@orpc/shared": "1.12.2", "@orpc/standard-server": "1.12.2", "@orpc/standard-server-fetch": "1.12.2", "@orpc/standard-server-peer": "1.12.2" } }, "sha512-3MTFnWRYYjcyzhtcYpodvkaYQlqsxKd5xGv+7PPJSpjCgFg9wcp7mZmRKy7hK0sCwUlkyi7AKs1Q19aUVUFIGA=="], "@orpc/contract": ["@orpc/contract@1.12.2", "", { "dependencies": { "@orpc/client": "1.12.2", "@orpc/shared": "1.12.2", "@standard-schema/spec": "^1.0.0", "openapi-types": "^12.1.3" } }, "sha512-eleSbF7WgfkWz+7jl1b9t3C3DWn127694+yEdR3j6EiBjb9mzHMIeOMRTXsclIP4gWj13wD1NtXp1Qlv8m7oZw=="], @@ -936,6 +965,26 @@ "@posthog/core": ["@posthog/core@1.6.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg=="], + "@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.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@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.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@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=="], @@ -2764,6 +2813,8 @@ "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -3146,6 +3197,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "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=="], @@ -3860,6 +3913,22 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + "@orpc/contract/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@orpc/shared/type-fest": ["type-fest@5.3.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g=="], @@ -4214,6 +4283,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "protobufjs/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], @@ -4592,6 +4663,8 @@ "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "spawn-wrap/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], diff --git a/docs/docs.json b/docs/docs.json index 3f3b1d0ecd..458ab9e20b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -119,6 +119,7 @@ "pages": [ "reference/debugging", "reference/telemetry", + "reference/tracing", "reference/storybook", "reference/benchmarking", "adr/0003-context-boundaries-for-compaction-and-reset", @@ -177,6 +178,7 @@ { "source": "/vscode-extension", "destination": "/integrations/vscode-extension" }, { "source": "/acp", "destination": "/integrations/acp" }, { "source": "/telemetry", "destination": "/reference/telemetry" }, + { "source": "/tracing", "destination": "/reference/tracing" }, { "source": "/storybook", "destination": "/reference/storybook" }, { "source": "/benchmarking", "destination": "/reference/benchmarking" }, { "source": "/debugging", "destination": "/reference/debugging" } diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index 131b8a1443..99a9941d71 100644 --- a/docs/reference/telemetry.mdx +++ b/docs/reference/telemetry.mdx @@ -5,6 +5,12 @@ description: What Mux collects, what it doesn’t, and how to disable it Mux collects anonymous usage telemetry to help improve the product. + + Looking to export traces/spans to your own observability backend (Jaeger, Grafana Tempo, SigNoz, + ...)? See [OpenTelemetry Tracing](/reference/tracing). That is opt-in and separate from the + anonymous product telemetry described here. + + ## Privacy policy - **No personal information**: Mux does not collect usernames, project names, file paths, or code content. diff --git a/docs/reference/tracing.mdx b/docs/reference/tracing.mdx new file mode 100644 index 0000000000..edf61dae91 --- /dev/null +++ b/docs/reference/tracing.mdx @@ -0,0 +1,84 @@ +--- +title: OpenTelemetry Tracing +description: Export OTLP traces and spans for Mux agent activity to your own observability backend +--- + +Mux can emit [OpenTelemetry](https://opentelemetry.io/) traces so you can inspect +agent turns — LLM requests, streaming, and tool calls — in any OTLP-compatible +backend (Jaeger, Grafana Tempo, SigNoz, Honeycomb, ...). This is separate from +the anonymous product [telemetry](/reference/telemetry): tracing is **off by +default**, sends data only to the collector _you_ configure, and is meant for +self-hosted observability and debugging. + + + Tracing is distinct from product telemetry. Setting `MUX_DISABLE_TELEMETRY=1` also disables + tracing. + + +## Enabling + +Tracing turns on as soon as you point Mux at a collector using the standard +OpenTelemetry environment variables — the same ones any OTEL application reads: + +```bash +# Send traces to a local OTLP/HTTP collector (e.g. an OpenTelemetry Collector, +# Jaeger, or Grafana Alloy listening on the default 4318 port). +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" + +# Optional: override the service name (defaults to "mux"). +export OTEL_SERVICE_NAME="mux" + +mux +``` + +Other standard knobs are honored automatically because Mux uses the upstream +exporter: + +- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` — traces-only endpoint override. +- `OTEL_EXPORTER_OTLP_HEADERS` — e.g. auth headers for a hosted backend + (`OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer "`). +- `OTEL_SDK_DISABLED=true` — standard OTEL kill switch. + +If none of these are set, no tracer is initialized and there is zero overhead. + +## What gets traced + +Each agent turn is a trace rooted at a `mux.stream` span. The +[Vercel AI SDK](https://sdk.vercel.ai/)'s built-in telemetry contributes the +nested LLM and tool spans, using standard `gen_ai.*` +[semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +``` +mux.stream (workspace, model, agent mode) +└─ ai.streamText (one per request, incl. retries) + ├─ ai.streamText.doStream (the provider HTTP/SSE call) + └─ ai.toolCall (one per tool invocation) +``` + +The `mux.stream` span carries Mux-specific attributes the generic spans can't +know: + +| Attribute | Description | +| ---------------------- | -------------------------------------------- | +| `mux.workspace.id` | Random workspace ID (no project/path leaked) | +| `mux.workspace.name` | Workspace name, when available | +| `mux.agent.mode` | Agent mode for the turn, when set | +| `mux.thinking_level` | Effective thinking level, when set | +| `gen_ai.request.model` | Model string for the turn | + +## Prompt and response content + +Prompt and response bodies are **redacted by default**: spans record metadata +(model, token counts, timings, status) but not message content. To attach +inputs/outputs while debugging against a private collector, opt in explicitly: + +```bash +export MUX_OTEL_RECORD_IO=1 +``` + +Leave this unset for shared or hosted backends. + +## Source code + +- **Tracing service**: [`src/node/services/tracingService.ts`](https://github.com/coder/mux/blob/main/src/node/services/tracingService.ts) +- **Stream instrumentation**: [`src/node/services/streamManager.ts`](https://github.com/coder/mux/blob/main/src/node/services/streamManager.ts) diff --git a/package.json b/package.json index 0f9f6d5b91..fee9eb92a1 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,11 @@ "@mozilla/readability": "^0.6.0", "@novnc/novnc": "^1.6.0", "@openrouter/ai-sdk-provider": "^2.9.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.38.0", "@orpc/client": "^1.11.3", "@orpc/openapi": "^1.12.2", "@orpc/server": "^1.11.3", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 67dbc70bdd..df4650b9a3 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -72,6 +72,7 @@ import { readAdditionalSystemContext, } from "./additionalSystemContext"; import type { TelemetryService } from "@/node/services/telemetryService"; +import type { TracingService } from "@/node/services/tracingService"; import type { DevToolsService } from "@/node/services/devToolsService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; @@ -408,7 +409,8 @@ export class AIService extends EventEmitter { telemetryService?: TelemetryService, devToolsService?: DevToolsService, opResolver?: ExternalSecretResolver, - experimentsService?: ExperimentsService + experimentsService?: ExperimentsService, + tracingService?: TracingService ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -426,8 +428,11 @@ export class AIService extends EventEmitter { this.opResolver = opResolver; this.experimentsService = experimentsService; this.providerService = providerService; - this.streamManager = new StreamManager(historyService, sessionUsageService, () => - this.providerService.getConfig() + this.streamManager = new StreamManager( + historyService, + sessionUsageService, + () => this.providerService.getConfig(), + tracingService ); this.devToolsService = devToolsService; this.providerModelFactory = new ProviderModelFactory( diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 23300e0f90..c52bc41894 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -26,6 +26,7 @@ import { TaskService } from "@/node/services/taskService"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { PolicyService } from "@/node/services/policyService"; import type { TelemetryService } from "@/node/services/telemetryService"; +import type { TracingService } from "@/node/services/tracingService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { ExternalSecretResolver } from "@/common/types/secrets"; @@ -41,6 +42,7 @@ export interface CoreServicesOptions { /** Optional cross-cutting services (desktop creates before core services). */ policyService?: PolicyService; telemetryService?: TelemetryService; + tracingService?: TracingService; analyticsService?: GoalLifecycleAnalyticsSink; goalServiceOptions?: WorkspaceGoalServiceOptions; experimentsService?: ExperimentsService; @@ -101,7 +103,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.telemetryService, opts.devToolsService, opts.opResolver, - opts.experimentsService + opts.experimentsService, + opts.tracingService ); // MCP: allow callers to override which Config provides server definitions diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 1b19818376..ac510fcbeb 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -22,6 +22,7 @@ import { ServerService } from "@/node/services/serverService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; import { TelemetryService } from "@/node/services/telemetryService"; +import { TracingService } from "@/node/services/tracingService"; import type { ReasoningDeltaEvent, StreamAbortEvent, @@ -114,6 +115,7 @@ export class ServiceContainer { public readonly mcpOauthService: McpOauthService; public readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; public readonly telemetryService: TelemetryService; + public readonly tracingService: TracingService; public readonly sessionTimingService: SessionTimingService; public readonly devToolsService: DevToolsService; public readonly browserSessionDiscoveryService: AgentBrowserSessionDiscoveryService; @@ -144,6 +146,7 @@ export class ServiceContainer { // services via constructor params (no setter injection needed). this.policyService = new PolicyService(config); this.telemetryService = new TelemetryService(config.rootDir); + this.tracingService = new TracingService(); this.experimentsService = new ExperimentsService({ telemetryService: this.telemetryService, muxHome: config.rootDir, @@ -174,6 +177,7 @@ export class ServiceContainer { workspaceMcpOverridesService: this.workspaceMcpOverridesService, policyService: this.policyService, telemetryService: this.telemetryService, + tracingService: this.tracingService, analyticsService: this.analyticsService, experimentsService: this.experimentsService, sessionTimingService: this.sessionTimingService, @@ -443,6 +447,7 @@ export class ServiceContainer { await recordStep("extensionMetadata.initialize", () => this.extensionMetadata.initialize()); // Initialize telemetry service await recordStep("telemetryService.initialize", () => this.telemetryService.initialize()); + await recordStep("tracingService.initialize", () => this.tracingService.initialize()); // Initialize policy service (startup gating) await recordStep("policyService.initialize", () => this.policyService.initialize()); @@ -552,6 +557,7 @@ export class ServiceContainer { this.browserBridgeTokenManager.dispose(); await this.analyticsService.dispose(); await this.telemetryService.shutdown(); + await this.tracingService.shutdown(); } setProjectDirectoryPicker(picker: (initialPath?: string | null) => Promise): void { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 88c71cc683..a2d623a515 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -35,6 +35,8 @@ import { stripNoisyErrorPrefix, type StreamErrorPayload, } from "@/node/services/utils/sendMessageError"; +import type { Span, Attributes } from "@opentelemetry/api"; +import type { TracingService } from "./tracingService"; import type { HistoryService } from "./historyService"; import { addUsage, accumulateProviderMetadata } from "@/common/utils/tokens/usageHelpers"; import { linkAbortSignal } from "@/node/utils/abort"; @@ -441,6 +443,10 @@ interface WorkspaceStreamInfo { lastStepUsage?: LanguageModelV2Usage; // Last step's provider metadata (for context window cache display) lastStepProviderMetadata?: Record; + // OpenTelemetry root span for this agent turn. The AI SDK's per-request spans + // (ai.streamText, ai.toolCall, ...) nest under it because streamText() is + // invoked inside this span's context. Undefined when tracing is disabled. + turnSpan?: Span; } // Ensure per-stream part timestamps are strictly monotonic. @@ -455,6 +461,35 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { return timestamp; } +/** + * Build OTEL attributes for a mux.stream turn span. Only includes defined + * values — OpenTelemetry attribute values must not be undefined. Uses the + * gen_ai.* semantic convention for model so backends group it with the AI SDK's + * spans, plus mux.* keys for workspace/agent context the AI SDK can't know. + */ +function buildTurnSpanAttributes( + workspaceId: WorkspaceId, + modelString: string, + workspaceName: string | undefined, + thinkingLevel: string | undefined, + initialMetadata: Partial | undefined +): Attributes { + const attributes: Attributes = { + "mux.workspace.id": workspaceId as string, + "gen_ai.request.model": modelString, + }; + if (workspaceName) { + attributes["mux.workspace.name"] = workspaceName; + } + if (thinkingLevel) { + attributes["mux.thinking_level"] = thinkingLevel; + } + if (initialMetadata?.mode) { + attributes["mux.agent.mode"] = initialMetadata.mode; + } + return attributes; +} + /** * StreamManager - Handles all streaming operations with type safety and atomic operations * @@ -471,6 +506,7 @@ export class StreamManager extends EventEmitter { private mcpServerManager?: MCPServerManager; private readonly sessionUsageService?: SessionUsageService; private readonly getProvidersConfig: () => ProvidersConfigMap | null; + private readonly tracingService?: TracingService; // Token tracker for live streaming statistics private tokenTracker = new StreamingTokenTracker(); // Track OpenAI previousResponseIds that have been invalidated @@ -480,12 +516,14 @@ export class StreamManager extends EventEmitter { constructor( historyService: HistoryService, sessionUsageService?: SessionUsageService, - getProvidersConfig?: () => ProvidersConfigMap | null + getProvidersConfig?: () => ProvidersConfigMap | null, + tracingService?: TracingService ) { super(); this.historyService = historyService; this.sessionUsageService = sessionUsageService; this.getProvidersConfig = getProvidersConfig ?? (() => null); + this.tracingService = tracingService; } private getWorkspaceLogger( @@ -1314,11 +1352,27 @@ export class StreamManager extends EventEmitter { abortController: AbortController, stepTracker?: StepMessageTracker ): Awaited> { + // When tracing is enabled, route the AI SDK's built-in telemetry through our + // tracer. This emits ai.streamText / ai.streamText.doStream / ai.toolCall + // spans with gen_ai.* attributes, nested under the active mux.stream span. + // Prompt/response bodies are redacted unless explicitly opted in. + const tracer = this.tracingService?.getTracer(); + const experimentalTelemetry = tracer + ? { + isEnabled: true, + tracer, + functionId: "mux.stream", + recordInputs: this.tracingService?.recordIo ?? false, + recordOutputs: this.tracingService?.recordIo ?? false, + } + : undefined; + return streamText({ model: request.model, messages: request.messages, system: request.system, abortSignal: abortController.signal, + experimental_telemetry: experimentalTelemetry, prepareStep: async ({ messages: stepMessages }) => { // streamText runs multiple internal LLM calls (steps) when tools are enabled. // Extract supported attachments out of tool-result JSON so providers don't treat them as text. @@ -1342,6 +1396,17 @@ export class StreamManager extends EventEmitter { }); } + /** + * Run `fn` with this turn's OTEL span active so any streamText() invoked + * inside it nests under mux.stream. Passthrough when tracing is disabled. + */ + private runInTurnSpan(streamInfo: WorkspaceStreamInfo, fn: () => T): T { + if (!this.tracingService) { + return fn(); + } + return this.tracingService.runInSpanContext(streamInfo.turnSpan, fn); + } + /** * Atomically creates a new stream with all necessary setup */ @@ -1392,13 +1457,34 @@ export class StreamManager extends EventEmitter { onStepMessages ); - // Start streaming - this can throw immediately if API key is missing + // Root OTEL span for this agent turn. Created before streamText() so the AI + // SDK's spans nest beneath it (see WorkspaceStreamInfo.turnSpan). Carries + // mux-specific context the gen_ai.* spans don't have on their own. + const turnSpan = this.tracingService?.startSpan( + "mux.stream", + buildTurnSpanAttributes( + workspaceId, + modelString, + workspaceName, + thinkingLevel, + initialMetadata + ) + ); + + // Start streaming - this can throw immediately if API key is missing. + // Invoke streamText() inside the turn span's context so its telemetry spans + // become children of mux.stream. let streamResult; try { - streamResult = this.createStreamResult(request, abortController, stepTracker); + streamResult = this.tracingService + ? this.tracingService.runInSpanContext(turnSpan, () => + this.createStreamResult(request, abortController, stepTracker) + ) + : this.createStreamResult(request, abortController, stepTracker); } catch (error) { // Clean up abort controller if stream creation fails abortController.abort(); + this.tracingService?.endSpan(turnSpan, error); // Re-throw the error to be caught by startStream throw error; } @@ -1436,6 +1522,7 @@ export class StreamManager extends EventEmitter { // Initialize cumulative tracking for multi-step streams cumulativeUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, cumulativeProviderMetadata: undefined, + turnSpan, }; // Atomically register the stream @@ -1794,10 +1881,13 @@ export class StreamManager extends EventEmitter { workspaceLog, }); streamInfo.currentStepStartIndex = 0; - streamInfo.streamResult = this.createStreamResult( - streamInfo.request, - streamInfo.abortController, - streamInfo.stepTracker + // Keep the retried request's AI SDK spans nested under the same turn span. + streamInfo.streamResult = this.runInTurnSpan(streamInfo, () => + this.createStreamResult( + streamInfo.request, + streamInfo.abortController, + streamInfo.stepTracker + ) ); return true; } @@ -1812,6 +1902,9 @@ export class StreamManager extends EventEmitter { ): Promise { this.mcpServerManager?.acquireLease(workspaceId as string); + // Captured so the turn span (ended in finally) can record the failure. + let turnError: unknown; + try { // Update state to streaming streamInfo.state = StreamState.STREAMING; @@ -2397,10 +2490,16 @@ export class StreamManager extends EventEmitter { } } } catch (error) { + turnError = error; await this.handleStreamFailure(workspaceId, streamInfo, error); } finally { this.mcpServerManager?.releaseLease(workspaceId as string); + // Finalize the turn span. The AI SDK has already closed its own child + // spans; this ends the mux.stream parent and records overall turn status. + this.tracingService?.endSpan(streamInfo.turnSpan, turnError); + streamInfo.turnSpan = undefined; + // Guaranteed cleanup in all code paths // Clear any pending timers to prevent keeping process alive if (streamInfo.partialWriteTimer) { @@ -2759,10 +2858,13 @@ export class StreamManager extends EventEmitter { ...(stepMessages ? { messages: stepMessages } : {}), providerOptions, }; - streamInfo.streamResult = this.createStreamResult( - streamInfo.request, - streamInfo.abortController, - streamInfo.stepTracker + // Keep the retried request's AI SDK spans nested under the same turn span. + streamInfo.streamResult = this.runInTurnSpan(streamInfo, () => + this.createStreamResult( + streamInfo.request, + streamInfo.abortController, + streamInfo.stepTracker + ) ); return true; @@ -3086,6 +3188,10 @@ export class StreamManager extends EventEmitter { // subsequently call stopStream() again (it already ran), so we'd never emit stream-abort/end. // In that case, immediately drop the registered stream and rely on the caller to handle UI. if (streamAbortController.signal.aborted) { + // processStreamWithCleanup won't run, so close the turn span here to + // avoid leaking it. + this.tracingService?.endSpan(streamInfo.turnSpan); + streamInfo.turnSpan = undefined; this.workspaceStreams.delete(typedWorkspaceId); return Ok(streamToken); } diff --git a/src/node/services/tracingService.test.ts b/src/node/services/tracingService.test.ts new file mode 100644 index 0000000000..414a6d13f9 --- /dev/null +++ b/src/node/services/tracingService.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { context, trace } from "@opentelemetry/api"; + +import { shouldEnableTracing, TracingService } from "./tracingService"; + +describe("shouldEnableTracing", () => { + test("disabled when no OTEL env vars are set", () => { + expect(shouldEnableTracing({})).toBe(false); + }); + + test("enabled when an OTLP endpoint is configured", () => { + expect(shouldEnableTracing({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318" })).toBe( + true + ); + }); + + test("enabled when a traces-specific endpoint is configured", () => { + expect( + shouldEnableTracing({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://localhost:4318/v1/traces" }) + ).toBe(true); + }); + + test("enabled when only a service name is configured", () => { + expect(shouldEnableTracing({ OTEL_SERVICE_NAME: "mux" })).toBe(true); + }); + + test("blank env values do not opt in", () => { + expect(shouldEnableTracing({ OTEL_EXPORTER_OTLP_ENDPOINT: " " })).toBe(false); + }); + + test("explicit mux opt-out wins over a configured endpoint", () => { + expect( + shouldEnableTracing({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + MUX_DISABLE_TELEMETRY: "1", + }) + ).toBe(false); + }); + + test("standard OTEL_SDK_DISABLED wins over a configured endpoint", () => { + expect( + shouldEnableTracing({ + OTEL_SERVICE_NAME: "mux", + OTEL_SDK_DISABLED: "true", + }) + ).toBe(false); + }); +}); + +describe("TracingService when disabled", () => { + test("stays a no-op after initialize() with no configuration", async () => { + const original = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + const originalService = process.env.OTEL_SERVICE_NAME; + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_SERVICE_NAME; + try { + const tracing = new TracingService(); + await tracing.initialize(); + + expect(tracing.isEnabled()).toBe(false); + expect(tracing.getTracer()).toBeNull(); + // No span is created, but the helpers must not throw. + expect(tracing.startSpan("mux.stream")).toBeUndefined(); + // Context helper passes the callback through untouched. + expect(tracing.runInSpanContext(undefined, () => 42)).toBe(42); + // Ending a non-existent span and shutting down are safe. + tracing.endSpan(undefined); + await tracing.shutdown(); + } finally { + if (original !== undefined) process.env.OTEL_EXPORTER_OTLP_ENDPOINT = original; + if (originalService !== undefined) process.env.OTEL_SERVICE_NAME = originalService; + } + }); +}); + +describe("TracingService when enabled", () => { + let tracing: TracingService | null = null; + + afterEach(async () => { + await tracing?.shutdown(); + tracing = null; + // shutdown() releases our refs but the provider registered itself globally; + // unregister it so this test stays hermetic across the shared test process. + trace.disable(); + }); + + test("produces real spans and propagates them as the active context", async () => { + const original = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"; + try { + tracing = new TracingService(); + await tracing.initialize(); + + expect(tracing.isEnabled()).toBe(true); + + const span = tracing.startSpan("mux.stream", { "mux.workspace.id": "ws-1" }); + expect(span).toBeDefined(); + + // The span we created should be the active span inside runInSpanContext, + // which is what lets the AI SDK nest its spans beneath ours. + const activeSpanId = tracing.runInSpanContext( + span, + () => trace.getSpan(context.active())?.spanContext().spanId + ); + expect(activeSpanId).toBe(span!.spanContext().spanId); + + tracing.endSpan(span); + } finally { + if (original !== undefined) { + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = original; + } else { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + } + } + }); +}); diff --git a/src/node/services/tracingService.ts b/src/node/services/tracingService.ts new file mode 100644 index 0000000000..3380cae696 --- /dev/null +++ b/src/node/services/tracingService.ts @@ -0,0 +1,216 @@ +/** + * OpenTelemetry tracing service (main process only). + * + * Emits OTLP trace spans for mux agent activity — one trace per agent turn, + * with the Vercel AI SDK's built-in `experimental_telemetry` contributing the + * LLM/tool spans (`ai.streamText`, `ai.streamText.doStream`, `ai.toolCall`) and + * their standard `gen_ai.*` semantic-convention attributes. The result is the + * same kind of observability other coding agents ship (codex-cli, opencode): + * traces/spans you can ship to any OTLP-compatible backend (Jaeger, Grafana + * Tempo, SigNoz, Honeycomb, ...). + * + * This is an original implementation built directly on the upstream + * OpenTelemetry SDK + the AI SDK telemetry hook — it does not vendor any code + * from those projects. The span/attribute names it targets are open + * OpenTelemetry semantic conventions, not project-specific schemas. + * + * Behavior: + * - Opt-in and OFF by default. Enabled only when the operator configures a + * standard OTEL endpoint/service env var (so users with no backend pay + * nothing), and never when telemetry is explicitly disabled. + * - Standard config: honors `OTEL_EXPORTER_OTLP_(TRACES_)ENDPOINT` / + * `_HEADERS` / `OTEL_SERVICE_NAME` exactly like any other OTEL app. + * - Startup-safe: all setup is wrapped in try/catch and degrades to a no-op, + * per docs/AGENTS.md ("startup-time initialization must never crash"). + * - Node/main-process only; never imported by the renderer bundle (the SDK + * touches Node-only APIs). + */ + +import { + trace, + context, + SpanStatusCode, + type Span, + type Tracer, + type Attributes, +} from "@opentelemetry/api"; +import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; +import { VERSION } from "@/version"; +import { log } from "./log"; + +const DEFAULT_SERVICE_NAME = "mux"; +/** Instrumentation scope name for spans we create ourselves (distinct from the AI SDK's "ai" scope). */ +const TRACER_NAME = "mux"; + +/** + * Env vars that, when set, opt the user into trace export. Mirrors how + * opencode/codex gate their exporters: tracing turns on only once a collector + * (or at minimum a service name) is configured, so the default experience is + * unchanged. + */ +const TRACING_OPT_IN_ENV_VARS = [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_SERVICE_NAME", +] as const; + +export function shouldEnableTracing(env: NodeJS.ProcessEnv): boolean { + // Respect explicit opt-outs: mux's own kill switch and the OTEL standard one. + // Keeping these in sync with the existing telemetry surface means a user who + // disables telemetry disables tracing too. + if (env.MUX_DISABLE_TELEMETRY === "1") { + return false; + } + if (env.OTEL_SDK_DISABLED === "true") { + return false; + } + return TRACING_OPT_IN_ENV_VARS.some((key) => (env[key] ?? "").trim().length > 0); +} + +function getServiceName(env: NodeJS.ProcessEnv): string { + const configured = env.OTEL_SERVICE_NAME?.trim(); + return configured && configured.length > 0 ? configured : DEFAULT_SERVICE_NAME; +} + +function getServiceVersion(): string { + if ( + typeof VERSION === "object" && + VERSION !== null && + typeof (VERSION as Record).git_describe === "string" + ) { + return (VERSION as { git_describe: string }).git_describe; + } + return "unknown"; +} + +export class TracingService { + private provider: NodeTracerProvider | null = null; + private tracer: Tracer | null = null; + /** + * Whether prompt/response bodies may be attached to spans. Redacted by + * default (matching codex's `log_user_prompt = false`); opt in with + * `MUX_OTEL_RECORD_IO=1` when debugging against a private collector. + */ + readonly recordIo: boolean = process.env.MUX_OTEL_RECORD_IO === "1"; + + isEnabled(): boolean { + return this.tracer !== null; + } + + /** The tracer for our own spans, or null when tracing is disabled. */ + getTracer(): Tracer | null { + return this.tracer; + } + + /** + * Initialize the OTLP exporter and register a global tracer provider. + * Idempotent and never throws. Returns a promise to match the service + * lifecycle interface even though setup is synchronous today. + */ + initialize(): Promise { + if (this.tracer) { + return Promise.resolve(); + } + if (!shouldEnableTracing(process.env)) { + return Promise.resolve(); + } + + try { + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: getServiceName(process.env), + [ATTR_SERVICE_VERSION]: getServiceVersion(), + }); + + // Constructed with no explicit URL/headers so it reads the standard + // OTEL_EXPORTER_OTLP_* env vars — operators configure mux the same way + // they would any OpenTelemetry application. + const exporter = new OTLPTraceExporter(); + const provider = new NodeTracerProvider({ + resource, + spanProcessors: [new BatchSpanProcessor(exporter)], + }); + + // register() installs the global tracer provider plus a Node + // AsyncLocalStorage context manager. The context manager is what lets the + // AI SDK's spans nest under our turn span across async boundaries. + provider.register(); + + this.provider = provider; + this.tracer = trace.getTracer(TRACER_NAME, getServiceVersion()); + log.info("[TracingService] OpenTelemetry tracing enabled", { + service: getServiceName(process.env), + recordIo: this.recordIo, + }); + } catch (error) { + // Telemetry must never take down startup. + log.warn("[TracingService] Failed to initialize tracing; continuing without it", { + error: error instanceof Error ? error.message : String(error), + }); + this.provider = null; + this.tracer = null; + } + + return Promise.resolve(); + } + + /** + * Start a span without making it active. The caller owns its lifecycle and + * must call {@link endSpan}. Returns undefined when tracing is disabled so + * callers can pass it around without branching. + */ + startSpan(name: string, attributes?: Attributes): Span | undefined { + return this.tracer?.startSpan(name, attributes ? { attributes } : undefined); + } + + /** + * Run `fn` with `span` installed as the active span. Any spans created during + * `fn` (including those the AI SDK creates) become children of `span`. + * Passthrough when `span` is undefined (tracing disabled). + */ + runInSpanContext(span: Span | undefined, fn: () => T): T { + if (!span) { + return fn(); + } + return context.with(trace.setSpan(context.active(), span), fn); + } + + /** Finalize a span started via {@link startSpan}, recording an error if provided. */ + endSpan(span: Span | undefined, error?: unknown): void { + if (!span) { + return; + } + if (error !== undefined) { + recordSpanError(span, error); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + span.end(); + } + + /** Flush pending spans and tear down the provider. Safe to call when disabled. */ + async shutdown(): Promise { + const provider = this.provider; + this.provider = null; + this.tracer = null; + if (!provider) { + return; + } + try { + await provider.shutdown(); + } catch { + // Shutdown failures are non-fatal. + } + } +} + +function recordSpanError(span: Span, error: unknown): void { + if (error instanceof Error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); + } else { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); + } +}