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) });
+ }
+}