From 1b81e820bfc47c3d4fc77dabc0ff3da8cae82716 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 5 Feb 2026 02:17:26 +0100 Subject: [PATCH] feat(coding-agent): added Grafana Pyroscope continuous profiling integration - Added Grafana Pyroscope continuous profiling integration for CPU and heap profiling. - Added seven new Pyroscope configuration settings: enabled, serverAddress, appName, basicAuthUser, basicAuthPassword, tenantID, and flushIntervalMs. - Added support for Pyroscope configuration via environment variables: PYROSCOPE_URL, PYROSCOPE_APP_NAME, PYROSCOPE_BASIC_AUTH_USER, PYROSCOPE_BASIC_AUTH_PASSWORD, PYROSCOPE_TENANT_ID, and PYROSCOPE_FLUSH_INTERVAL_MS. - Integrated Pyroscope initialization and shutdown into the main application lifecycle with automatic profiling start/stop. - Added @pyroscope/nodejs dependency for continuous profiling capabilities. --- bun.lock | 27 ++- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/package.json | 1 + .../src/config/settings-schema.ts | 22 +++ packages/coding-agent/src/config/settings.ts | 1 + packages/coding-agent/src/debug/pyroscope.ts | 177 ++++++++++++++++++ packages/coding-agent/src/main.ts | 13 +- 7 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/debug/pyroscope.ts diff --git a/bun.lock b/bun.lock index 388394dee..215ee5e63 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", "@openai/agents": "^0.4.5", + "@pyroscope/nodejs": "0.4.9", "@sinclair/typebox": "^0.34.48", "ajv": "^8.17.1", "chalk": "^5.6.2", @@ -307,6 +308,8 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@datadog/pprof": ["@datadog/pprof@5.13.2", "", { "dependencies": { "delay": "^5.0.0", "node-gyp-build": "<4.0", "p-limit": "^3.1.0", "pprof-format": "^2.2.1", "source-map": "^0.7.4" } }, "sha512-Th8u7pvoguTfbUx/mlZLHD9TxFCHO+wRAvlEaYFAYAmMvbPLww4YowvTy1UMvpi8LbUbwil3Fo8rKCrilvXHhQ=="], + "@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="], "@google/genai": ["@google/genai@1.40.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA=="], @@ -373,6 +376,8 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.11.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw=="], + "@pyroscope/nodejs": ["@pyroscope/nodejs@0.4.9", "", { "dependencies": { "@datadog/pprof": "5.13.2", "debug": "^4.4.3", "p-limit": "^7.2.0", "regenerator-runtime": "^0.14.1", "source-map": "^0.7.6" }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0", "fastify": "^4.0.0 || ^5.0.0" }, "optionalPeers": ["express", "fastify"] }, "sha512-OYAxF+Z13GGxuaxhFEWCGuQCH15Fevo9xf0NII6XRD0blFUQDGdN6Lw3Dp+c6BYidOFaRbPUyPJ17tSmYVvjuA=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], @@ -703,6 +708,8 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "devtools-protocol": ["devtools-protocol@0.0.1551306", "", {}, "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg=="], @@ -979,6 +986,8 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp-build": ["node-gyp-build@3.9.0", "", { "bin": { "node-gyp-build": "./bin.js", "node-gyp-build-test": "./build-test.js", "node-gyp-build-optional": "./optional.js" } }, "sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A=="], + "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -999,6 +1008,8 @@ "openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="], + "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -1033,6 +1044,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pprof-format": ["pprof-format@2.2.1", "", {}, "sha512-p4tVN7iK19ccDqQv8heyobzUmbHyds4N2FI6aBMcXz6y99MglTWDxIyhFkNaLeEXs6IFUEzT0zya0icbSLLY0g=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1075,6 +1088,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1137,7 +1152,7 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1271,6 +1286,8 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -1291,6 +1308,8 @@ "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.982.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ=="], + "@datadog/pprof/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1315,8 +1334,12 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1351,6 +1374,8 @@ "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.982.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ=="], + "@datadog/pprof/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 783d327e6..20e7bc78e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog ## [Unreleased] + ### Added +- Added Grafana Pyroscope continuous profiling integration for CPU and heap profiling +- Added `pyroscope.enabled`, `pyroscope.serverAddress`, `pyroscope.appName`, `pyroscope.basicAuthUser`, `pyroscope.basicAuthPassword`, `pyroscope.tenantID`, and `pyroscope.flushIntervalMs` settings for profiling configuration +- Added support for Pyroscope configuration via environment variables (`PYROSCOPE_URL`, `PYROSCOPE_APP_NAME`, `PYROSCOPE_BASIC_AUTH_USER`, `PYROSCOPE_BASIC_AUTH_PASSWORD`, `PYROSCOPE_TENANT_ID`, `PYROSCOPE_FLUSH_INTERVAL_MS`) - Added `task.maxConcurrency` setting to control concurrent limit for subagents (default: 32) - Added UI options for task concurrency configuration with presets from unlimited to 64 tasks - Added support for loading skills from `~/.agents/skills` diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 3f3dc0dbd..2dd78575f 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -87,6 +87,7 @@ "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", "@openai/agents": "^0.4.5", + "@pyroscope/nodejs": "^0.4.9", "@sinclair/typebox": "^0.34.48", "ajv": "^8.17.1", "chalk": "^5.6.2", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 84a7d685f..d1ae1905b 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -755,6 +755,17 @@ export const SETTINGS_SCHEMA = { "thinkingBudgets.medium": { type: "number", default: 8192 }, "thinkingBudgets.high": { type: "number", default: 16384 }, + // ───────────────────────────────────────────────────────────────────────── + // Pyroscope continuous profiling settings (no UI - advanced) + // ───────────────────────────────────────────────────────────────────────── + "pyroscope.enabled": { type: "boolean", default: false }, + "pyroscope.serverAddress": { type: "string", default: undefined }, + "pyroscope.appName": { type: "string", default: undefined }, + "pyroscope.basicAuthUser": { type: "string", default: undefined }, + "pyroscope.basicAuthPassword": { type: "string", default: undefined }, + "pyroscope.tenantID": { type: "string", default: undefined }, + "pyroscope.flushIntervalMs": { type: "number", default: 60000 }, + // ───────────────────────────────────────────────────────────────────────── // Status line settings // ───────────────────────────────────────────────────────────────────────── @@ -942,6 +953,16 @@ export interface ThinkingBudgetsSettings { high: number; } +export interface PyroscopeSettings { + enabled: boolean; + serverAddress: string | undefined; + appName: string | undefined; + basicAuthUser: string | undefined; + basicAuthPassword: string | undefined; + tenantID: string | undefined; + flushIntervalMs: number; +} + export interface BashInterceptorRule { pattern: string; flags?: string; @@ -961,6 +982,7 @@ export interface GroupTypeMap { exa: ExaSettings; statusLine: StatusLineSettings; thinkingBudgets: ThinkingBudgetsSettings; + pyroscope: PyroscopeSettings; modelRoles: Record; } diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 7ac885daf..a2fb6c289 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -41,6 +41,7 @@ export type { ExaSettings, GroupPrefix, GroupTypeMap, + PyroscopeSettings, RetrySettings, SettingPath, SettingValue, diff --git a/packages/coding-agent/src/debug/pyroscope.ts b/packages/coding-agent/src/debug/pyroscope.ts new file mode 100644 index 000000000..79225fa16 --- /dev/null +++ b/packages/coding-agent/src/debug/pyroscope.ts @@ -0,0 +1,177 @@ +/** + * Grafana Pyroscope continuous profiling integration. + * + * Sends CPU and heap profiles to a Pyroscope server for continuous profiling. + * Enable via settings or environment variables: + * - PYROSCOPE_URL (required to enable) + * - PYROSCOPE_APP_NAME (defaults to APP_NAME) + * - PYROSCOPE_BASIC_AUTH_USER + * - PYROSCOPE_BASIC_AUTH_PASSWORD + * - PYROSCOPE_TENANT_ID + * - PYROSCOPE_FLUSH_INTERVAL_MS (default: 60000) + * + * @see https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/nodejs/ + */ +import * as os from "node:os"; +import { getEnv, logger } from "@oh-my-pi/pi-utils"; +import { APP_NAME, VERSION } from "../config"; + +interface PyroscopeClient { + init(config: Record): void; + start(): void; + stop(): Promise; + setLabels(labels: Record): void; + getLabels(): Record; +} + +let client: PyroscopeClient | null = null; +let isStarted = false; + +export interface PyroscopeInitOptions { + /** Pyroscope server URL (e.g., http://localhost:4040 or Grafana Cloud URL) */ + serverAddress?: string; + /** Application name label */ + appName?: string; + /** Basic auth username (e.g., Grafana Cloud stack user ID) */ + basicAuthUser?: string; + /** Basic auth password (e.g., Grafana Cloud API key) */ + basicAuthPassword?: string; + /** Tenant ID for multi-tenant Pyroscope servers */ + tenantID?: string; + /** Flush interval in milliseconds (default: 60000) */ + flushIntervalMs?: number; + /** Additional static tags */ + tags?: Record; + /** Enable CPU/wall profiling (default: true) */ + enableWall?: boolean; + /** Enable heap profiling (default: true) */ + enableHeap?: boolean; +} + +/** + * Initialize and start Pyroscope profiling. + * + * Should be called early in startup, before heavy work begins. + * Does nothing if serverAddress is not configured. + * + * @returns true if Pyroscope was started, false otherwise + */ +export async function initPyroscope(options: PyroscopeInitOptions = {}): Promise { + if (isStarted) { + logger.debug("Pyroscope: already started"); + return true; + } + + const serverAddress = options.serverAddress || getEnv("PYROSCOPE_URL"); + + if (!serverAddress) { + logger.debug("Pyroscope: no server address configured, skipping"); + return false; + } + + const appName = options.appName || getEnv("PYROSCOPE_APP_NAME") || APP_NAME; + const basicAuthUser = options.basicAuthUser || getEnv("PYROSCOPE_BASIC_AUTH_USER"); + const basicAuthPassword = options.basicAuthPassword || getEnv("PYROSCOPE_BASIC_AUTH_PASSWORD"); + const tenantID = options.tenantID || getEnv("PYROSCOPE_TENANT_ID"); + const flushIntervalMs = options.flushIntervalMs ?? 60000; + + const tags: Record = { + version: VERSION, + hostname: os.hostname(), + pid: process.pid, + ...(options.tags || {}), + }; + + try { + const mod = await import("@pyroscope/nodejs"); + client = mod.default as unknown as PyroscopeClient; + + client.init({ + serverAddress, + appName, + basicAuthUser, + basicAuthPassword, + tenantID, + flushIntervalMs, + tags, + }); + + client.start(); + isStarted = true; + + logger.debug("Pyroscope: profiling started", { + serverAddress: serverAddress.replace(/\/\/[^:]+:[^@]+@/, "//***:***@"), + appName, + flushIntervalMs, + }); + + return true; + } catch (err) { + logger.warn("Pyroscope: failed to initialize", { + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + +/** + * Stop Pyroscope profiling and flush remaining data. + * + * Call during graceful shutdown. + */ +export async function stopPyroscope(): Promise { + if (!isStarted || !client) { + return; + } + + try { + await client.stop(); + isStarted = false; + logger.debug("Pyroscope: profiling stopped"); + } catch (err) { + logger.warn("Pyroscope: error during stop", { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Set dynamic labels for the current profiling context. + * + * Use for request-scoped labels like session ID, model name, etc. + */ +export function setPyroscopeLabels(labels: Record): void { + if (!isStarted || !client) { + return; + } + + try { + client.setLabels(labels); + } catch (err) { + logger.debug("Pyroscope: failed to set labels", { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Get current labels. + */ +export function getPyroscopeLabels(): Record { + if (!isStarted || !client) { + return {}; + } + + try { + return client.getLabels(); + } catch { + return {}; + } +} + +/** + * Check if Pyroscope profiling is active. + */ +export function isPyroscopeActive(): boolean { + return isStarted; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4c4435af6..8ee9a35f5 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,7 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { createInterface } from "node:readline/promises"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; -import { postmortem } from "@oh-my-pi/pi-utils"; +import { getEnv, logger, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { type Args, parseArgs, printHelp } from "./cli/args"; import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli"; @@ -29,6 +29,7 @@ import { findConfigFile, getModelsPath, VERSION } from "./config"; import type { ModelRegistry } from "./config/model-registry"; import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver"; import { Settings, settings } from "./config/settings"; +import { initPyroscope, stopPyroscope } from "./debug/pyroscope"; import { initializeWithSettings } from "./discovery"; import { exportFromFile } from "./export/html"; import type { ExtensionUIContext } from "./extensibility/extensions/types"; @@ -640,6 +641,15 @@ export async function main(args: string[]) { await Settings.init({ cwd }); debugStartup("main:Settings.init"); time("Settings.init"); + + // Initialize Pyroscope continuous profiling if configured + const pyroscopeSettings = settings.getGroup("pyroscope"); + if (pyroscopeSettings.enabled || getEnv("PYROSCOPE_URL")) { + await initPyroscope(pyroscopeSettings); + logger.debug("Pyroscope: initialized", { settings: pyroscopeSettings }); + debugStartup("main:initPyroscope"); + } + const pipedInput = await readPipedInput(); let { initialMessage, initialImages } = await prepareInitialMessage(parsed, settings.get("images.autoResize")); if (pipedInput) { @@ -809,6 +819,7 @@ export async function main(args: string[]) { }); await session.dispose(); stopThemeWatcher(); + await stopPyroscope(); await postmortem.quit(0); } }