From b09982cc7412977c370d7769e4b53aec12e96c80 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 7 Sep 2026 10:56:48 +0530 Subject: [PATCH 1/6] feat(local): Add agent debugging fixture Provide a runnable Hono service with correlated database, agent, MCP, log, and error telemetry for local debugging. --- apps/cli-docs/src/fragments/commands/local.md | 32 +++++ packages/cli/package.json | 1 + .../skills/sentry-cli/references/local.md | 11 ++ .../test/commands/local/agent-fixture.test.ts | 65 +++++++++ .../cli/test/fixtures/local-agent-server.ts | 128 ++++++++++++++++++ pnpm-lock.yaml | 87 +++--------- 6 files changed, 257 insertions(+), 67 deletions(-) create mode 100644 packages/cli/test/commands/local/agent-fixture.test.ts create mode 100644 packages/cli/test/fixtures/local-agent-server.ts diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index eb7a3f653..9e9ecad4e 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -120,3 +120,35 @@ sentry local --format json ``` This is useful for AI coding agents and automation tools that need to consume Sentry events programmatically. + +## Agent-debugging fixture + +The repository includes a small Hono server that produces a normal database +request, an agent/MCP trace, and an intentional failure. It sends only to the +local server unless you explicitly set `SENTRY_DSN`. + +In one terminal, start the local receiver: + +```bash +sentry local serve --format json --attributes +``` + +In another, run the fixture with Spotlight pointed at that receiver: + +```bash +SENTRY_SPOTLIGHT=http://localhost:8969/stream \ + pnpm --filter sentry exec tsx test/fixtures/local-agent-server.ts +``` + +Then exercise each telemetry shape: + +```bash +curl http://127.0.0.1:3030/api/users/42 +curl -X POST http://127.0.0.1:3030/api/agent/run \ + -H 'content-type: application/json' \ + -d '{"prompt":"Where is the rate limit configured?"}' +curl -i http://127.0.0.1:3030/api/broken +``` + +The final request intentionally returns HTTP 500. The fixture is for local +experimentation only; do not run it with production credentials. diff --git a/packages/cli/package.json b/packages/cli/package.json index 9ceb4cae3..9bf937c4a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -93,6 +93,7 @@ "@mastra/client-js": "^1.38.0", "@sentry/api": "^0.256.0", "@sentry/core": "10.63.0", + "@sentry/node": "10.65.0", "@sentry/node-core": "10.63.0", "@sentry/sqlish": "^1.0.1", "@sentry/symbolic": "13.7.0", diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 062e5b9d9..b8d9e22ad 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -58,6 +58,17 @@ sentry local -f ai # only AI/agent spans sentry local -f ai -f error # agent spans and errors sentry local --format json + +sentry local serve --format json --attributes + +SENTRY_SPOTLIGHT=http://localhost:8969/stream \ + pnpm --filter sentry exec tsx test/fixtures/local-agent-server.ts + +curl http://127.0.0.1:3030/api/users/42 +curl -X POST http://127.0.0.1:3030/api/agent/run \ + -H 'content-type: application/json' \ + -d '{"prompt":"Where is the rate limit configured?"}' +curl -i http://127.0.0.1:3030/api/broken ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/test/commands/local/agent-fixture.test.ts b/packages/cli/test/commands/local/agent-fixture.test.ts new file mode 100644 index 000000000..0269357c8 --- /dev/null +++ b/packages/cli/test/commands/local/agent-fixture.test.ts @@ -0,0 +1,65 @@ +/** + * Contract tests for the manually runnable local-agent Hono fixture. + * + * The fixture is intentionally small, but each route represents a distinct + * telemetry shape that `sentry local` needs to make useful to an agent. + */ + +import { describe, expect, test } from "vitest"; +import { createLocalAgentServer } from "../../fixtures/local-agent-server.js"; + +describe("local agent fixture", () => { + test("reports that it is ready for a local-only telemetry session", async () => { + const app = createLocalAgentServer(); + + const res = await app.request("/health"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + service: "local-agent-fixture", + status: "ok", + }); + }); + + test("returns a user after a simulated database span", async () => { + const app = createLocalAgentServer(); + + const res = await app.request("/api/users/42"); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "42", + name: "Ada Lovelace", + source: "fixture-db", + }); + }); + + test("runs a simulated agent tool call", async () => { + const app = createLocalAgentServer(); + + const res = await app.request("/api/agent/run", { + method: "POST", + body: JSON.stringify({ prompt: "Where is the rate limit configured?" }), + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + answer: "The rate limit is configured in src/lib/rate-limit.ts.", + tool: "search_files", + }); + }); + + test("returns a safe error response after capturing the underlying exception", async () => { + const app = createLocalAgentServer(); + + const res = await app.request("/api/broken"); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ + error: "fixture_failure", + message: + "The fixture intentionally failed. Check sentry local for details.", + }); + }); +}); diff --git a/packages/cli/test/fixtures/local-agent-server.ts b/packages/cli/test/fixtures/local-agent-server.ts new file mode 100644 index 000000000..467ce7af8 --- /dev/null +++ b/packages/cli/test/fixtures/local-agent-server.ts @@ -0,0 +1,128 @@ +/** + * A deliberately small Hono server for exercising `sentry local` manually. + * + * Run it with `SENTRY_SPOTLIGHT` pointed at a local server. It never sets a + * DSN itself, so the caller controls whether events stay local or are also + * sent to a configured Sentry project. + */ + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { serve } from "@hono/node-server"; +import { captureException, init, logger, startSpan } from "@sentry/node"; +import { Hono } from "hono"; + +const SERVICE_NAME = "local-agent-fixture"; +const DEFAULT_PORT = 3030; + +export function createLocalAgentServer() { + const app = new Hono(); + + app.get("/health", (c) => c.json({ service: SERVICE_NAME, status: "ok" })); + + app.get("/api/users/:id", (c) => + startSpan( + { + name: "SELECT users", + op: "db.query", + attributes: { + "db.system.name": "sqlite", + "db.operation.name": "SELECT", + "db.query.summary": "SELECT id, name FROM users WHERE id = ?", + }, + }, + () => { + const id = c.req.param("id"); + logger.info("Fixture user loaded", { attributes: { id } }); + return c.json({ id, name: "Ada Lovelace", source: "fixture-db" }); + } + ) + ); + + app.post("/api/agent/run", async (c) => { + const { prompt } = (await c.req.json()) as { prompt?: unknown }; + const safePrompt = typeof prompt === "string" ? prompt : ""; + + return startSpan( + { + name: "agent.run", + op: "gen_ai.invoke_agent", + attributes: { + "gen_ai.operation.name": "chat", + "gen_ai.agent.name": "fixture-agent", + "gen_ai.provider.name": "sentry", + "gen_ai.request.model": "fixture-model", + }, + }, + async () => { + logger.info("Fixture agent received a prompt", { + attributes: { prompt_length: safePrompt.length }, + }); + + const tool = await startSpan( + { + name: "tools/call search_files", + op: "mcp.client", + attributes: { + "mcp.method.name": "tools/call", + "gen_ai.tool.name": "search_files", + }, + }, + async () => "search_files" + ); + + return c.json({ + answer: "The rate limit is configured in src/lib/rate-limit.ts.", + tool, + }); + } + ); + }); + + app.get("/api/broken", (c) => + startSpan({ name: "fixture.failure", op: "http.server" }, () => { + const error = new Error("Intentional local-agent fixture failure"); + logger.error("Fixture request failed", { + attributes: { scenario: "broken" }, + }); + captureException(error, { + tags: { "fixture.scenario": "broken" }, + }); + return c.json( + { + error: "fixture_failure", + message: + "The fixture intentionally failed. Check sentry local for details.", + }, + 500 + ); + }) + ); + + return app; +} + +function startServer(): void { + init({ + dsn: process.env.SENTRY_DSN, + enableLogs: true, + tracesSampleRate: 1, + }); + + const port = Number(process.env.PORT ?? DEFAULT_PORT); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("PORT must be an integer between 1 and 65535"); + } + + serve({ fetch: createLocalAgentServer().fetch, port, hostname: "127.0.0.1" }); + process.stderr.write( + `Local agent fixture listening at http://127.0.0.1:${port}\n` + ); +} + +if ( + process.argv[1] && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + startServer(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 818996cc9..08a9717ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,9 +88,12 @@ importers: '@sentry/core': specifier: 10.63.0 version: 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) + '@sentry/node': + specifier: 10.65.0 + version: 10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) '@sentry/node-core': specifier: 10.63.0 - version: 10.63.0(patch_hash=0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640)(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + version: 10.63.0(patch_hash=0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640)(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) '@sentry/sqlish': specifier: ^1.0.1 version: 1.0.1(react@19.2.8) @@ -1262,12 +1265,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.9.0': - resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/instrumentation@0.220.0': resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1280,36 +1277,18 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.9.0': - resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.10.0': resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.9.0': - resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace@2.10.0': resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace@2.9.0': - resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -5806,11 +5785,6 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -5826,12 +5800,6 @@ snapshots: '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -5840,14 +5808,6 @@ snapshots: '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -5855,13 +5815,6 @@ snapshots: '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/semantic-conventions@1.43.0': {} '@oslojs/encoding@1.1.0': {} @@ -6084,29 +6037,29 @@ snapshots: dependencies: '@sentry/core': 10.69.0 - '@sentry/node-core@10.63.0(patch_hash=0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640)(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/node-core@10.63.0(patch_hash=0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640)(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.12.0 '@sentry/core': 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) - '@sentry/opentelemetry': 10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) import-in-the-middle: 3.3.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) - '@sentry/node-core@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/node-core@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.15.1 '@sentry/core': 10.65.0 - '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) - import-in-the-middle: 3.3.1 + '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.3.2 optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@sentry/node-core@10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: @@ -6124,13 +6077,13 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.15.1 '@sentry/core': 10.65.0 - '@sentry/node-core': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) - '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/node-core': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) '@sentry/server-utils': 10.65.0 - import-in-the-middle: 3.3.1 + import-in-the-middle: 3.3.2 transitivePeerDependencies: - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' @@ -6152,19 +6105,19 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-http' - supports-color - '@sentry/opentelemetry@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/opentelemetry@10.63.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.12.0 '@sentry/core': 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) - '@sentry/opentelemetry@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/opentelemetry@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.15.1 '@sentry/core': 10.65.0 @@ -6323,7 +6276,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@sentry/core': 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) - '@sentry/node': 10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) + '@sentry/node': 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) anser: 2.3.5 chalk: 5.6.2 eventsource: 4.1.0 From e9bdb592f814745b9d7becbec81641f703e5c22b Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 7 Sep 2026 11:22:25 +0530 Subject: [PATCH 2/6] feat(local): Stream JSON observations to stdout Add a versioned NDJSON contract while keeping lifecycle diagnostics on stderr for agent consumers. --- apps/cli-docs/src/fragments/commands/local.md | 4 +++ .../skills/sentry-cli/references/local.md | 2 +- packages/cli/src/commands/local/server.ts | 17 ++++++++++--- packages/cli/src/lib/formatters/local.ts | 21 ++++++++++++---- packages/cli/src/lib/logger.ts | 10 ++++++++ .../cli/test/lib/formatters/local.test.ts | 10 ++++++++ packages/cli/test/lib/logger.test.ts | 25 +++++++++++++++++++ 7 files changed, 79 insertions(+), 10 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 9e9ecad4e..4d5043b91 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -121,6 +121,10 @@ sentry local --format json This is useful for AI coding agents and automation tools that need to consume Sentry events programmatically. +In JSON mode, event records are versioned NDJSON on standard output. Startup, +connection, and shutdown messages stay on standard error, so an agent can pipe +the evidence stream without parsing terminal status text. + ## Agent-debugging fixture The repository includes a small Hono server that produces a normal database diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index b8d9e22ad..e43b1e2d4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -20,7 +20,7 @@ Start the local dev server and tail events - `-H, --host - Hostname to bind to (default localhost) - (default: "localhost")` - `-q, --quiet - Suppress per-envelope tail output` - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` -- `-F, --format - Output format: human (default) or json (NDJSON) - (default: "human")` +- `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` ### `sentry local run ` diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 777e1d931..f43733311 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -36,7 +36,7 @@ import { isItemIncluded, SENTRY_CONTENT_TYPE, } from "../../lib/formatters/local.js"; -import { logger, printLine } from "../../lib/logger.js"; +import { logger, printJsonLine, printLine } from "../../lib/logger.js"; /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; @@ -694,7 +694,7 @@ function processSSEEvent( showAttributes ); for (const line of lines) { - printLine(line); + printLocalEventLine(line, useJson); } } } catch (err) { @@ -704,6 +704,15 @@ function processSSEEvent( } } +/** Route human event tails to stderr and JSON observations to stdout. */ +function printLocalEventLine(line: string, useJson: boolean): void { + if (useJson) { + printJsonLine(line); + } else { + printLine(line); + } +} + export const serverCommand = buildCommand({ docs: { brief: "Start the local dev server and tail events", @@ -745,7 +754,7 @@ export const serverCommand = buildCommand({ format: { kind: "parsed", parse: parseFormat, - brief: "Output format: human (default) or json (NDJSON)", + brief: "Output format: human (default) or json (NDJSON on stdout)", default: "human", }, attributes: { @@ -814,7 +823,7 @@ export const serverCommand = buildCommand({ activeFilters, flags.attributes )) { - printLine(line); + printLocalEventLine(line, useJson); } } catch (err) { logger.debug( diff --git a/packages/cli/src/lib/formatters/local.ts b/packages/cli/src/lib/formatters/local.ts index f99f2efe8..314e7a265 100644 --- a/packages/cli/src/lib/formatters/local.ts +++ b/packages/cli/src/lib/formatters/local.ts @@ -59,6 +59,9 @@ export const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope"; export const FORMAT_VALUES = ["human", "json"] as const; export type FormatValue = (typeof FORMAT_VALUES)[number]; +/** Version of the stable, machine-readable local observation record. */ +export const LOCAL_EVENT_SCHEMA_VERSION = 1; + /** Envelope item categories that can be filtered via `--filter`. */ export const FILTER_VALUES = ["error", "transaction", "log", "ai"] as const; export type FilterValue = (typeof FILTER_VALUES)[number]; @@ -720,6 +723,14 @@ function jsonSafe(value: unknown): string | undefined { return typeof value === "string" ? stripBidi(value) : undefined; } +/** Serialize one versioned local observation as an NDJSON record. */ +function formatJsonObservation(observation: Record): string { + return JSON.stringify({ + schema_version: LOCAL_EVENT_SCHEMA_VERSION, + ...observation, + }); +} + /** Format an error item as a JSON object, including the best stack frame. */ function formatErrorJson( payload: Record, @@ -738,7 +749,7 @@ function formatErrorJson( const frame = first?.stacktrace?.frames?.find((f) => f.in_app) ?? first?.stacktrace?.frames?.at(-1); - return JSON.stringify({ + return formatJsonObservation({ type: "error", timestamp: payload.timestamp, trace_id: extractTraceId(payload), @@ -801,7 +812,7 @@ function formatTransactionJson( start !== undefined && end !== undefined ? Math.round((end - start) * 1000) : undefined; - return JSON.stringify({ + return formatJsonObservation({ type: "transaction", timestamp: payload.timestamp, trace_id: extractTraceId(payload), @@ -830,7 +841,7 @@ function formatLogJson( } const source = inferSourceName(header); return items.map((entry) => - JSON.stringify({ + formatJsonObservation({ type: "log", timestamp: entry.timestamp, trace_id: extractLogTraceId(entry), @@ -877,7 +888,7 @@ function formatSpanJson( span.start_timestamp !== undefined && span.end_timestamp !== undefined ? Math.round((span.end_timestamp - span.start_timestamp) * 1000) : undefined; - return JSON.stringify({ + return formatJsonObservation({ type: "span", timestamp: span.end_timestamp, trace_id: span.trace_id, @@ -931,7 +942,7 @@ export function formatItemJson( return formatLogJson(payload, header); } return [ - JSON.stringify({ + formatJsonObservation({ type: itemType ?? "unknown", timestamp: payload.timestamp, }), diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index d6880d2b7..544a24a7b 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -188,6 +188,16 @@ export function printLine(line: string): void { process.stderr.write(`${line}\n`); } +/** + * Write a machine-readable event record to stdout as one NDJSON line. + * + * Local command lifecycle messages continue to use stderr, leaving stdout + * safe for an agent or another process to consume as a record stream. + */ +export function printJsonLine(line: string): void { + process.stdout.write(`${line}\n`); +} + /** * Patch a consola instance's `withTag` so every child (and grandchild) * is registered in {@link scopedLoggers} for {@link setLogLevel} propagation. diff --git a/packages/cli/test/lib/formatters/local.test.ts b/packages/cli/test/lib/formatters/local.test.ts index 1f9f6d49c..841f4c47a 100644 --- a/packages/cli/test/lib/formatters/local.test.ts +++ b/packages/cli/test/lib/formatters/local.test.ts @@ -772,6 +772,16 @@ describe("formatItemJson", () => { expect(parsed.source).toBe("server"); }); + test("adds the versioned observation schema to JSON records", () => { + const lines = formatItemJson( + "error", + { timestamp: 1_700_000_000, message: "boom" }, + serverHeader + ); + + expect(JSON.parse(lines[0]).schema_version).toBe(1); + }); + test("formats error without stack frame", () => { const event = { timestamp: 1_700_000_000, diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index e81899cd9..86a628344 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -241,6 +241,31 @@ describe("printLine", () => { }); }); +describe("printJsonLine", () => { + test("writes an NDJSON record only to stdout", async () => { + const loggerModule = (await import("../../src/lib/logger.js")) as typeof import("../../src/lib/logger.js") & { + printJsonLine?: (line: string) => void; + }; + const stdout = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + expect(loggerModule.printJsonLine).toBeTypeOf("function"); + loggerModule.printJsonLine?.('{"schema_version":1,"type":"error"}'); + expect(stdout).toHaveBeenCalledWith( + '{"schema_version":1,"type":"error"}\n' + ); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); +}); + describe("attachSentryReporter", () => { test("can be called without error", () => { // attachSentryReporter is idempotent and safe to call even when From 7738b53101e9fd6d9d5c8da5a31b8888cffa44e5 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 7 Sep 2026 11:38:10 +0530 Subject: [PATCH 3/6] fix(local): Preserve JSON observation identities --- apps/cli-docs/src/fragments/commands/local.md | 4 +- packages/cli/src/lib/formatters/local.ts | 191 +++++++++++------- .../cli/test/fixtures/local-agent-server.ts | 1 + .../cli/test/lib/formatters/local.test.ts | 21 ++ 4 files changed, 142 insertions(+), 75 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 4d5043b91..0ad3d698a 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -123,7 +123,9 @@ This is useful for AI coding agents and automation tools that need to consume Se In JSON mode, event records are versioned NDJSON on standard output. Startup, connection, and shutdown messages stay on standard error, so an agent can pipe -the evidence stream without parsing terminal status text. +the evidence stream without parsing terminal status text. Records include +`schema_version`, `trace_id`, and, when supplied by the SDK, `event_id` and +`envelope_id` for exact correlation. ## Agent-debugging fixture diff --git a/packages/cli/src/lib/formatters/local.ts b/packages/cli/src/lib/formatters/local.ts index 314e7a265..18e8e9cb7 100644 --- a/packages/cli/src/lib/formatters/local.ts +++ b/packages/cli/src/lib/formatters/local.ts @@ -723,10 +723,33 @@ function jsonSafe(value: unknown): string | undefined { return typeof value === "string" ? stripBidi(value) : undefined; } +/** Normalize an SDK identity that may be represented by a UUID object. */ +function jsonSafeIdentifier(value: unknown): string | undefined { + if (typeof value === "string") { + return stripBidi(value); + } + if (value === null || typeof value !== "object") { + return undefined; + } + + try { + const identifier = value.toString(); + return identifier === "[object Object]" ? undefined : stripBidi(identifier); + } catch { + return undefined; + } +} + /** Serialize one versioned local observation as an NDJSON record. */ -function formatJsonObservation(observation: Record): string { +function formatJsonObservation( + observation: Record, + payload: Record, + header: Record +): string { return JSON.stringify({ schema_version: LOCAL_EVENT_SCHEMA_VERSION, + envelope_id: jsonSafeIdentifier(header.__spotlight_envelope_id), + event_id: jsonSafe(payload.event_id) ?? jsonSafe(header.event_id), ...observation, }); } @@ -749,19 +772,23 @@ function formatErrorJson( const frame = first?.stacktrace?.frames?.find((f) => f.in_app) ?? first?.stacktrace?.frames?.at(-1); - return formatJsonObservation({ - type: "error", - timestamp: payload.timestamp, - trace_id: extractTraceId(payload), - error_type: jsonSafe(first?.type) ?? "Error", - message: - jsonSafe(first?.value) ?? jsonSafe(payload.message) ?? "Unknown error", - filename: jsonSafe(frame?.filename), - lineno: frame?.lineno, - colno: frame?.colno, - function: jsonSafe(frame?.function), - source: inferSourceName(header), - }); + return formatJsonObservation( + { + type: "error", + timestamp: payload.timestamp, + trace_id: extractTraceId(payload), + error_type: jsonSafe(first?.type) ?? "Error", + message: + jsonSafe(first?.value) ?? jsonSafe(payload.message) ?? "Unknown error", + filename: jsonSafe(frame?.filename), + lineno: frame?.lineno, + colno: frame?.colno, + function: jsonSafe(frame?.function), + source: inferSourceName(header), + }, + payload, + header + ); } /** @@ -812,22 +839,26 @@ function formatTransactionJson( start !== undefined && end !== undefined ? Math.round((end - start) * 1000) : undefined; - return formatJsonObservation({ - type: "transaction", - timestamp: payload.timestamp, - trace_id: extractTraceId(payload), - op: inferSemanticOp(attrs) ?? trace?.op, - label: stripBidi(semantic.label), - metadata: - semantic.metadata.length > 0 - ? semantic.metadata.map(stripBidi) - : undefined, - duration_ms: durationMs, - status: trace?.status, - span_count: (payload.spans as unknown[] | undefined)?.length, - attributes: includeAttributes ? buildJsonAttributes(payload) : undefined, - source: inferSourceName(header), - }); + return formatJsonObservation( + { + type: "transaction", + timestamp: payload.timestamp, + trace_id: extractTraceId(payload), + op: inferSemanticOp(attrs) ?? trace?.op, + label: stripBidi(semantic.label), + metadata: + semantic.metadata.length > 0 + ? semantic.metadata.map(stripBidi) + : undefined, + duration_ms: durationMs, + status: trace?.status, + span_count: (payload.spans as unknown[] | undefined)?.length, + attributes: includeAttributes ? buildJsonAttributes(payload) : undefined, + source: inferSourceName(header), + }, + payload, + header + ); } /** Format a log item as JSON objects (one per entry). */ @@ -841,29 +872,33 @@ function formatLogJson( } const source = inferSourceName(header); return items.map((entry) => - formatJsonObservation({ - type: "log", - timestamp: entry.timestamp, - trace_id: extractLogTraceId(entry), - level: entry.level ?? "log", - message: stripBidi(entry.body ?? ""), - attributes: entry.attributes - ? Object.fromEntries( - Object.entries(entry.attributes) - .filter( - ([k, v]) => - isUserLogAttribute(k) && - v?.value !== null && - v?.value !== undefined - ) - .map(([k, v]) => [ - stripBidi(k), - typeof v.value === "string" ? stripBidi(v.value) : v.value, - ]) - ) - : undefined, - source, - }) + formatJsonObservation( + { + type: "log", + timestamp: entry.timestamp, + trace_id: extractLogTraceId(entry), + level: entry.level ?? "log", + message: stripBidi(entry.body ?? ""), + attributes: entry.attributes + ? Object.fromEntries( + Object.entries(entry.attributes) + .filter( + ([k, v]) => + isUserLogAttribute(k) && + v?.value !== null && + v?.value !== undefined + ) + .map(([k, v]) => [ + stripBidi(k), + typeof v.value === "string" ? stripBidi(v.value) : v.value, + ]) + ) + : undefined, + source, + }, + payload, + header + ) ); } @@ -888,24 +923,28 @@ function formatSpanJson( span.start_timestamp !== undefined && span.end_timestamp !== undefined ? Math.round((span.end_timestamp - span.start_timestamp) * 1000) : undefined; - return formatJsonObservation({ - type: "span", - timestamp: span.end_timestamp, - trace_id: span.trace_id, - span_id: span.span_id, - op: inferSemanticOp(flat) ?? flat["sentry.op"], - label: stripBidi(semantic.label), - metadata: - semantic.metadata.length > 0 - ? semantic.metadata.map(stripBidi) + return formatJsonObservation( + { + type: "span", + timestamp: span.end_timestamp, + trace_id: span.trace_id, + span_id: span.span_id, + op: inferSemanticOp(flat) ?? flat["sentry.op"], + label: stripBidi(semantic.label), + metadata: + semantic.metadata.length > 0 + ? semantic.metadata.map(stripBidi) + : undefined, + duration_ms: durationMs, + status: span.status, + attributes: includeAttributes + ? buildJsonAttributes({ contexts: { trace: { data: flat } } }) : undefined, - duration_ms: durationMs, - status: span.status, - attributes: includeAttributes - ? buildJsonAttributes({ contexts: { trace: { data: flat } } }) - : undefined, - source, - }); + source, + }, + payload, + header + ); }); } @@ -942,10 +981,14 @@ export function formatItemJson( return formatLogJson(payload, header); } return [ - formatJsonObservation({ - type: itemType ?? "unknown", - timestamp: payload.timestamp, - }), + formatJsonObservation( + { + type: itemType ?? "unknown", + timestamp: payload.timestamp, + }, + payload, + header + ), ]; } diff --git a/packages/cli/test/fixtures/local-agent-server.ts b/packages/cli/test/fixtures/local-agent-server.ts index 467ce7af8..cc07263e7 100644 --- a/packages/cli/test/fixtures/local-agent-server.ts +++ b/packages/cli/test/fixtures/local-agent-server.ts @@ -106,6 +106,7 @@ function startServer(): void { init({ dsn: process.env.SENTRY_DSN, enableLogs: true, + spotlight: process.env.SENTRY_SPOTLIGHT, tracesSampleRate: 1, }); diff --git a/packages/cli/test/lib/formatters/local.test.ts b/packages/cli/test/lib/formatters/local.test.ts index 841f4c47a..81ab7fadc 100644 --- a/packages/cli/test/lib/formatters/local.test.ts +++ b/packages/cli/test/lib/formatters/local.test.ts @@ -782,6 +782,27 @@ describe("formatItemJson", () => { expect(JSON.parse(lines[0]).schema_version).toBe(1); }); + test("preserves event and envelope identities for agent correlation", () => { + const lines = formatItemJson( + "error", + { + event_id: "event-123", + timestamp: 1_700_000_000, + message: "boom", + }, + { + ...serverHeader, + // Spotlight stores its internal envelope identity as a UUID object. + __spotlight_envelope_id: { toString: () => "envelope-123" }, + } + ); + + expect(JSON.parse(lines[0])).toMatchObject({ + event_id: "event-123", + envelope_id: "envelope-123", + }); + }); + test("formats error without stack frame", () => { const event = { timestamp: 1_700_000_000, From e46d008f374f04921d11d1aaba86eef5ad3d0184 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 7 Sep 2026 12:39:11 +0530 Subject: [PATCH 4/6] feat(local): Add agent JSON run mode --- apps/cli-docs/src/fragments/commands/local.md | 14 +- .../skills/sentry-cli/references/local.md | 7 + packages/cli/src/commands/local/run.ts | 124 +++++++++++++++--- packages/cli/src/commands/local/server.ts | 4 +- packages/cli/test/commands/local/run.test.ts | 101 +++++++++++++- .../cli/test/fixtures/local-agent-server.ts | 1 - 6 files changed, 228 insertions(+), 23 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 0ad3d698a..b0aacd43d 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -113,6 +113,16 @@ Use `--format json` (or `-F json`) for machine-readable NDJSON output, one JSON sentry local --format json ``` +`local run` supports the same JSON, attribute, and filter options while it +starts your app and injects the receiver URL. For an agent-friendly stream +without SDK housekeeping envelopes, use: + +```bash +sentry local run --format json \ + --filter error --filter transaction --filter log --filter ai \ + -- npm run dev +``` + ```json {"type":"transaction","timestamp":1700000001,"op":"gen_ai","label":"chat anthropic/claude-4-sonnet","duration_ms":1200,"span_count":5,"source":"server"} {"type":"error","timestamp":1700000002,"error_type":"RateLimitError","message":"API quota exceeded","source":"server"} @@ -125,7 +135,9 @@ In JSON mode, event records are versioned NDJSON on standard output. Startup, connection, and shutdown messages stay on standard error, so an agent can pipe the evidence stream without parsing terminal status text. Records include `schema_version`, `trace_id`, and, when supplied by the SDK, `event_id` and -`envelope_id` for exact correlation. +`envelope_id` for exact correlation. In `local run --format json`, the wrapped +app's standard output is also forwarded to standard error, leaving standard +output exclusively for NDJSON observations. ## Agent-debugging fixture diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index e43b1e2d4..86d030075 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -30,8 +30,11 @@ Run a command with the local dev server enabled **Flags:** - `-p, --port - Port for the local server (default 8969) - (default: "8969")` - `--host - Hostname for the local server (default localhost) - (default: "localhost")` +- `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-V, --verify - Verify SDK sends events, then exit` - `-t, --timeout - Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode) - (default: "0")` +- `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` +- `-a, --attributes - Include selected event attributes in output` **Examples:** @@ -59,6 +62,10 @@ sentry local -f ai -f error # agent spans and errors sentry local --format json +sentry local run --format json \ + --filter error --filter transaction --filter log --filter ai \ + -- npm run dev + sentry local serve --format json --attributes SENTRY_SPOTLIGHT=http://localhost:8969/stream \ diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 9d88bc432..b6580d2f5 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -13,7 +13,11 @@ * so events still tail to the terminal. */ -import { type ChildProcess, spawn } from "node:child_process"; +import { + type ChildProcess, + type StdioOptions, + spawn, +} from "node:child_process"; import type { Server } from "node:http"; import { resolve } from "node:path"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; @@ -22,14 +26,21 @@ import { buildCommand } from "../../lib/command.js"; import { detectDevCommand } from "../../lib/dev-script.js"; import { CliError, EXIT, ValidationError } from "../../lib/errors.js"; import { bold } from "../../lib/formatters/colors.js"; -import { formatEnvelopeLines } from "../../lib/formatters/local.js"; -import { logger, printLine } from "../../lib/logger.js"; +import { + type FilterValue, + type FormatValue, + formatEnvelopeLines, + formatEnvelopeLinesJson, +} from "../../lib/formatters/local.js"; +import { logger, printJsonLine, printLine } from "../../lib/logger.js"; import { injectWranglerSpotlightBinding } from "../../lib/wrangler.js"; import { buildApp, consumeSSE, DEFAULT_PORT, isServerRunning, + parseFilter, + parseFormat, parsePort, tryListen, } from "./server.js"; @@ -39,6 +50,9 @@ type RunFlags = { readonly host: string; readonly verify: boolean; readonly timeout: number; + readonly filter?: readonly FilterValue[]; + readonly format: FormatValue; + readonly attributes: boolean; }; /** Buffer size for the auto-started background server. */ @@ -108,6 +122,20 @@ type EventTail = { cleanup: () => Promise; }; +/** Keep stdout exclusively for NDJSON when an agent requests JSON output. */ +function childStdio(useJson: boolean): StdioOptions { + return useJson ? ["inherit", "pipe", "pipe"] : "inherit"; +} + +/** Forward the wrapped app's console output without contaminating NDJSON. */ +function forwardChildOutput(child: ChildProcess, useJson: boolean): void { + if (!useJson) { + return; + } + child.stdout?.pipe(process.stderr, { end: false }); + child.stderr?.pipe(process.stderr, { end: false }); +} + /** * Tail events from a server this command did not start. * @@ -117,12 +145,19 @@ type EventTail = { * reached the other server, but nothing was ever printed here. Attaching as * an SSE consumer is what `sentry local serve` does in the same situation. */ -function attachToExistingServer(url: string): EventTail { +function attachToExistingServer( + url: string, + activeFilters: ReadonlySet, + useJson: boolean, + showAttributes: boolean +): EventTail { const ac = new AbortController(); const tail = consumeSSE({ url, - activeFilters: new Set(), + activeFilters, signal: ac.signal, + useJson, + showAttributes, }).catch((err: unknown) => { if (!ac.signal.aborted) { logger.debug( @@ -146,18 +181,27 @@ function attachToExistingServer(url: string): EventTail { */ async function startBackgroundServer( port: number, - host: string + host: string, + activeFilters: ReadonlySet, + useJson: boolean, + showAttributes: boolean ): Promise { const buffer = createSpotlightBuffer(BUFFER_SIZE); const app = buildApp(buffer); const { server, port: boundPort } = await tryListen(app, port, host); const url = `http://${host}:${boundPort}`; - const noFilters = new Set(); const subscriptionId = buffer.subscribe((container) => { try { - for (const line of formatEnvelopeLines(container, noFilters)) { - printLine(line); + const formatLines = useJson + ? formatEnvelopeLinesJson(container, activeFilters, showAttributes) + : formatEnvelopeLines(container, activeFilters, showAttributes); + for (const line of formatLines) { + if (useJson) { + printJsonLine(line); + } else { + printLine(line); + } } } catch (err) { logger.debug( @@ -183,17 +227,29 @@ async function startBackgroundServer( * with it, and falls back to attaching if the bind loses a race. `run` wraps * the user's dev command, so a busy port must never be fatal here. */ -async function openEventTail(port: number, host: string): Promise { +async function openEventTail( + port: number, + host: string, + activeFilters: ReadonlySet, + useJson: boolean, + showAttributes: boolean +): Promise { const url = `http://${host}:${port}`; if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); - return attachToExistingServer(url); + return attachToExistingServer(url, activeFilters, useJson, showAttributes); } logger.info("No server detected, starting one in the background..."); try { - const bg = await startBackgroundServer(port, host); + const bg = await startBackgroundServer( + port, + host, + activeFilters, + useJson, + showAttributes + ); logger.info(`Background server listening on ${bold(bg.url)}`); return bg; } catch (err) { @@ -202,7 +258,7 @@ async function openEventTail(port: number, host: string): Promise { } // Something grabbed the port between the probe and the bind. logger.warn(`${err.message}; attaching to it instead`); - return attachToExistingServer(url); + return attachToExistingServer(url, activeFilters, useJson, showAttributes); } } @@ -284,7 +340,8 @@ export const runCommand = buildCommand({ "The child process inherits all current env vars plus\n" + "SENTRY_SPOTLIGHT (server-side SDKs read this automatically), the\n" + "framework-prefixed client variants (NEXT_PUBLIC_, VITE_, etc.), and\n" + - "SENTRY_TRACES_SAMPLE_RATE=1.\n\n" + + "SENTRY_TRACES_SAMPLE_RATE=1. Use --format json to stream versioned\n" + + "NDJSON observations to stdout for agents.\n\n" + "Example:\n" + " sentry local run -- npm run dev\n" + " sentry local run -- python manage.py runserver", @@ -311,6 +368,14 @@ export const runCommand = buildCommand({ brief: "Hostname for the local server (default localhost)", default: "localhost", }, + filter: { + kind: "parsed", + parse: parseFilter, + brief: + "Only show items of this type (repeatable: error, transaction, log, ai)", + variadic: true, + optional: true, + }, verify: { kind: "boolean", brief: "Verify SDK sends events, then exit", @@ -323,11 +388,25 @@ export const runCommand = buildCommand({ "Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode)", default: "0", }, + format: { + kind: "parsed", + parse: parseFormat, + brief: "Output format: human (default) or json (NDJSON on stdout)", + default: "human", + }, + attributes: { + kind: "boolean", + brief: "Include selected event attributes in output", + default: false, + }, }, aliases: { p: "port", + f: "filter", V: "verify", t: "timeout", + F: "format", + a: "attributes", }, }, auth: false, @@ -342,7 +421,15 @@ export const runCommand = buildCommand({ let url = `http://${flags.host}:${flags.port}`; - const tail = await openEventTail(flags.port, flags.host); + const useJson = flags.format === "json"; + const activeFilters = new Set(flags.filter); + const tail = await openEventTail( + flags.port, + flags.host, + activeFilters, + useJson, + flags.attributes + ); url = tail.url; const spotlightUrl = `${url}/stream`; @@ -365,8 +452,9 @@ export const runCommand = buildCommand({ child = spawn(cmd, cmdArgs, { cwd: this.cwd, env: childEnv, - stdio: "inherit", + stdio: childStdio(useJson), }); + forwardChildOutput(child, useJson); } catch (err) { await tail.cleanup(); throw new CliError( @@ -502,13 +590,15 @@ async function* runWithVerify( } let child: ChildProcess; + const useJson = flags.format === "json"; try { const [cmd = "", ...cmdArgs] = wrangler.args; child = spawn(cmd, cmdArgs, { cwd, env: childEnv, - stdio: "inherit", + stdio: childStdio(useJson), }); + forwardChildOutput(child, useJson); } catch (err) { await shutdownServer(server); throw new CliError( diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index f43733311..cb3960488 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -66,7 +66,7 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024; * Parse and validate a `--format` value. * Accepts: human, json. */ -function parseFormat(value: string): FormatValue { +export function parseFormat(value: string): FormatValue { const lower = value.toLowerCase(); if (!FORMAT_VALUES.includes(lower as FormatValue)) { throw new ValidationError( @@ -81,7 +81,7 @@ function parseFormat(value: string): FormatValue { * Parse and validate a `--filter` value. * Accepts the canonical names: error, transaction, logger. */ -function parseFilter(value: string): FilterValue { +export function parseFilter(value: string): FilterValue { const lower = value.toLowerCase(); if (!FILTER_VALUES.includes(lower as FilterValue)) { throw new ValidationError( diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index 54c769a30..bb61ad4e2 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -26,7 +26,11 @@ import { TEST_TMP_DIR } from "../../constants.js"; * delegates to the real `spawn`, so commands like `printenv`/`true` run for * real and exit codes propagate normally. */ -const spawnCapture: { args?: readonly string[]; env?: NodeJS.ProcessEnv } = {}; +const spawnCapture: { + args?: readonly string[]; + env?: NodeJS.ProcessEnv; + stdio?: Parameters[2]["stdio"]; +} = {}; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -39,6 +43,9 @@ vi.mock("node:child_process", async (importOriginal) => { ) => { spawnCapture.args = args; spawnCapture.env = (options as { env?: NodeJS.ProcessEnv })?.env; + spawnCapture.stdio = ( + options as { stdio?: typeof spawnCapture.stdio } + )?.stdio; return actual.spawn(cmd, args as string[], options); }, }; @@ -46,7 +53,15 @@ vi.mock("node:child_process", async (importOriginal) => { type RunFunc = ( this: unknown, - flags: { port: number; host: string; verify: boolean; timeout: number }, + flags: { + port: number; + host: string; + verify: boolean; + timeout: number; + format?: "human" | "json"; + attributes?: boolean; + filter?: ("error" | "transaction" | "log" | "ai")[]; + }, ...args: string[] ) => Promise; @@ -76,6 +91,7 @@ describe("sentry local run", () => { beforeEach(() => { spawnCapture.args = undefined; spawnCapture.env = undefined; + spawnCapture.stdio = undefined; }); test("throws ValidationError when no command and no auto-detect", async () => { @@ -353,6 +369,87 @@ describe("sentry local run", () => { expect(output).not.toContain("Could not attach to the event stream"); }); + test("writes normalized NDJSON to stdout when following an existing server", async () => { + // A one-command agent workflow uses `local run --format json`, including + // when another process already owns the local receiver. + const buffer = createSpotlightBuffer(10); + const { server, port } = await tryListen(buildApp(buffer), 0, "127.0.0.1"); + const savedFetch = globalThis.fetch; + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (realFetch) { + globalThis.fetch = realFetch; + } + + const stdoutWrites: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stdoutWrites.push(chunk.toString()); + return true; + }); + + try { + await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: '{"sdk":{"name":"sentry.javascript.node"}}\n{"type":"session"}\n{"timestamp":"2026-09-07T00:00:00Z"}\n{"type":"event","event_id":"event-123"}\n{"event_id":"event-123","timestamp":1750000000,"message":"JSON tail failure"}', + }); + + const func = (await runCommand.loader()) as unknown as RunFunc; + await func.call( + makeContext(), + { + port, + host: "127.0.0.1", + verify: false, + timeout: 0, + format: "json", + attributes: false, + filter: ["error"], + }, + "sleep", + "1" + ); + } finally { + stdoutSpy.mockRestore(); + globalThis.fetch = savedFetch; + await shutdownServer(server); + } + + const records = stdoutWrites + .join("") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + expect(records).toEqual([ + expect.objectContaining({ + schema_version: 1, + event_id: "event-123", + message: "JSON tail failure", + }), + ]); + }); + + test("routes child output away from the NDJSON channel", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + await func.call( + makeContext(), + { + port: 0, + host: "127.0.0.1", + verify: false, + timeout: 0, + format: "json", + attributes: false, + }, + "true" + ); + + expect(spawnCapture.stdio).toEqual(["inherit", "pipe", "pipe"]); + }); + test("warns when the existing server's stream cannot be attached", async () => { // `/health` answers but `/stream` does not — e.g. an unrelated service // squatting on the port. Attaching fails, and since `run` keeps the child diff --git a/packages/cli/test/fixtures/local-agent-server.ts b/packages/cli/test/fixtures/local-agent-server.ts index cc07263e7..467ce7af8 100644 --- a/packages/cli/test/fixtures/local-agent-server.ts +++ b/packages/cli/test/fixtures/local-agent-server.ts @@ -106,7 +106,6 @@ function startServer(): void { init({ dsn: process.env.SENTRY_DSN, enableLogs: true, - spotlight: process.env.SENTRY_SPOTLIGHT, tracesSampleRate: 1, }); From 24a36628a9496dbfe9d0dc17c82caf0f7b19332f Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 7 Sep 2026 12:50:32 +0530 Subject: [PATCH 5/6] fix(local): Satisfy lint checks --- packages/cli/src/commands/local/run.ts | 52 ++++++++++++++---------- packages/cli/src/lib/formatters/local.ts | 9 ++-- packages/cli/test/lib/logger.test.ts | 4 +- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index b6580d2f5..b2561b0ba 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -122,6 +122,14 @@ type EventTail = { cleanup: () => Promise; }; +type EventTailOptions = { + port: number; + host: string; + activeFilters: ReadonlySet; + useJson: boolean; + showAttributes: boolean; +}; + /** Keep stdout exclusively for NDJSON when an agent requests JSON output. */ function childStdio(useJson: boolean): StdioOptions { return useJson ? ["inherit", "pipe", "pipe"] : "inherit"; @@ -179,13 +187,13 @@ function attachToExistingServer( * Start a background dev server and subscribe to its buffer so incoming * envelopes are printed inline, matching the behavior of `sentry local serve`. */ -async function startBackgroundServer( - port: number, - host: string, - activeFilters: ReadonlySet, - useJson: boolean, - showAttributes: boolean -): Promise { +async function startBackgroundServer({ + port, + host, + activeFilters, + useJson, + showAttributes, +}: EventTailOptions): Promise { const buffer = createSpotlightBuffer(BUFFER_SIZE); const app = buildApp(buffer); const { server, port: boundPort } = await tryListen(app, port, host); @@ -227,13 +235,13 @@ async function startBackgroundServer( * with it, and falls back to attaching if the bind loses a race. `run` wraps * the user's dev command, so a busy port must never be fatal here. */ -async function openEventTail( - port: number, - host: string, - activeFilters: ReadonlySet, - useJson: boolean, - showAttributes: boolean -): Promise { +async function openEventTail({ + port, + host, + activeFilters, + useJson, + showAttributes, +}: EventTailOptions): Promise { const url = `http://${host}:${port}`; if (await isServerRunning(url)) { @@ -243,13 +251,13 @@ async function openEventTail( logger.info("No server detected, starting one in the background..."); try { - const bg = await startBackgroundServer( + const bg = await startBackgroundServer({ port, host, activeFilters, useJson, - showAttributes - ); + showAttributes, + }); logger.info(`Background server listening on ${bold(bg.url)}`); return bg; } catch (err) { @@ -423,13 +431,13 @@ export const runCommand = buildCommand({ const useJson = flags.format === "json"; const activeFilters = new Set(flags.filter); - const tail = await openEventTail( - flags.port, - flags.host, + const tail = await openEventTail({ + port: flags.port, + host: flags.host, activeFilters, useJson, - flags.attributes - ); + showAttributes: flags.attributes, + }); url = tail.url; const spotlightUrl = `${url}/stream`; diff --git a/packages/cli/src/lib/formatters/local.ts b/packages/cli/src/lib/formatters/local.ts index 18e8e9cb7..292171cf1 100644 --- a/packages/cli/src/lib/formatters/local.ts +++ b/packages/cli/src/lib/formatters/local.ts @@ -729,14 +729,17 @@ function jsonSafeIdentifier(value: unknown): string | undefined { return stripBidi(value); } if (value === null || typeof value !== "object") { - return undefined; + return; } try { const identifier = value.toString(); - return identifier === "[object Object]" ? undefined : stripBidi(identifier); + if (identifier === "[object Object]") { + return; + } + return stripBidi(identifier); } catch { - return undefined; + log.debug("Could not serialize local envelope identity"); } } diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index 86a628344..03ce6b6aa 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -243,7 +243,9 @@ describe("printLine", () => { describe("printJsonLine", () => { test("writes an NDJSON record only to stdout", async () => { - const loggerModule = (await import("../../src/lib/logger.js")) as typeof import("../../src/lib/logger.js") & { + const loggerModule = (await import( + "../../src/lib/logger.js" + )) as typeof import("../../src/lib/logger.js") & { printJsonLine?: (line: string) => void; }; const stdout = vi From edbb913f8ba9bdaa2cf7a94c0ba840ba0d2c05e1 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 8 Sep 2026 19:12:54 +0530 Subject: [PATCH 6/6] fix(local): Sanitize all JSON observation fields --- packages/cli/src/lib/formatters/local.ts | 39 ++++++++++++++++--- .../cli/test/lib/formatters/local.test.ts | 33 ++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/formatters/local.ts b/packages/cli/src/lib/formatters/local.ts index 292171cf1..14eabb6f6 100644 --- a/packages/cli/src/lib/formatters/local.ts +++ b/packages/cli/src/lib/formatters/local.ts @@ -743,18 +743,45 @@ function jsonSafeIdentifier(value: unknown): string | undefined { } } +/** + * Remove terminal-affecting characters from every string in an observation. + * + * Envelope payloads are untrusted, and `JSON.stringify()` does not escape C1 + * controls or BiDi overrides. Normalizing the final record in one place keeps + * new JSON fields from accidentally bypassing the terminal-safety contract. + */ +function sanitizeJsonValue(value: unknown): unknown { + if (typeof value === "string") { + return stripBidi(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeJsonValue); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + stripBidi(key), + sanitizeJsonValue(nestedValue), + ]) + ); + } + return value; +} + /** Serialize one versioned local observation as an NDJSON record. */ function formatJsonObservation( observation: Record, payload: Record, header: Record ): string { - return JSON.stringify({ - schema_version: LOCAL_EVENT_SCHEMA_VERSION, - envelope_id: jsonSafeIdentifier(header.__spotlight_envelope_id), - event_id: jsonSafe(payload.event_id) ?? jsonSafe(header.event_id), - ...observation, - }); + return JSON.stringify( + sanitizeJsonValue({ + schema_version: LOCAL_EVENT_SCHEMA_VERSION, + envelope_id: jsonSafeIdentifier(header.__spotlight_envelope_id), + event_id: jsonSafe(payload.event_id) ?? jsonSafe(header.event_id), + ...observation, + }) + ); } /** Format an error item as a JSON object, including the best stack frame. */ diff --git a/packages/cli/test/lib/formatters/local.test.ts b/packages/cli/test/lib/formatters/local.test.ts index 81ab7fadc..530b92b46 100644 --- a/packages/cli/test/lib/formatters/local.test.ts +++ b/packages/cli/test/lib/formatters/local.test.ts @@ -951,6 +951,39 @@ describe("formatItemJson", () => { expect(parsed.type).toBe("attachment"); }); + test("strips terminal controls from every JSON observation field", () => { + const lines = formatItemJson( + "attachment\u202e", + { timestamp: "2026-09-08\u009b", event_id: "event\u202e-123" }, + serverHeader + ); + const parsed = JSON.parse(lines[0]); + + expect(parsed).toMatchObject({ + type: "attachment", + timestamp: "2026-09-08", + event_id: "event-123", + }); + + const logLines = formatItemJson( + "log", + { + items: [ + { + body: "safe", + attributes: { + nested: { value: { child: "unsafe\u202evalue" } }, + }, + }, + ], + }, + serverHeader + ); + expect(JSON.parse(logLines[0]).attributes).toEqual({ + nested: { child: "unsafevalue" }, + }); + }); + test("detects browser source in JSON", () => { const event = { timestamp: 1_700_000_000, message: "error" }; const lines = formatItemJson("error", event, browserHeader);