diff --git a/.claude/skills/prod-telemetry/SKILL.md b/.claude/skills/prod-telemetry/SKILL.md new file mode 100644 index 000000000..47f79bbc7 --- /dev/null +++ b/.claude/skills/prod-telemetry/SKILL.md @@ -0,0 +1,114 @@ +--- +name: prod-telemetry +description: Query Executor's production telemetry — Axiom traces (executor-cloud dataset), prod Postgres via PlanetScale, PostHog product analytics — through the Executor MCP. Use when investigating prod errors, latency, usage, churn signals, or verifying a deploy's telemetry; includes the dataset field layout, working APL recipes, and the error-attribution join. +--- + +# Production telemetry access + +All three stores are queryable through the Executor MCP's connected +integrations — no dashboards or credentials needed. Verify the connection +exists with `connections.list` if a call fails. + +## Axiom traces (`axiom_mcp`) + +Tool: `axiom_mcp.user.axiomMcpOAuth.querydataset` — the argument is `apl` +(NOT `query`). Dataset: `['executor-cloud']` (worker spans; browser spans +join the same traces via traceparent). + +**Field layout (the part you'd otherwise rediscover by failed queries):** + +- Custom span attributes live under the JSON map `['attributes.custom']`, + NOT as top-level `attributes.*` columns. Read with + `['attributes.custom']['mcp.tool.name']`. A nonexistent top-level field is + a hard query error ("invalid field"), not an empty result. +- Span status: `['status.code']` (`"OK"`/`"ERROR"`), `['status.message']`. +- Exceptions: the `events` column carries `exception.type` / + `exception.stacktrace` JSON. +- OTel basics are top-level: `name`, `trace_id`, `span_id`, + `parent_span_id`, `duration`, `_time`. + +**Span names worth querying** (and their custom attrs): + +- `executor.tool.execute` — `mcp.tool.name` (full address), and since + PR #992: `executor.tool.outcome` (`ok`/`fail`), + `executor.tool.error_code`, `executor.tool.error_status`, + `executor.tenant`, `executor.subject`. +- `mcp.tool.dispatch` — `mcp.tool.name` (sandbox path), + `mcp.tool.integration`, same outcome attrs. +- `plugin.openapi.invoke` — `plugin.openapi.method` / `path_template` / + `base_url`, and since PR #992 `http.status_code`. +- `mcp.request` (outer) — `mcp.auth.organization_id`, + `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), + MCP client fingerprint (`mcp.client.name`…). + +**Recipe — error signatures by class (the daily-digest query):** + +```apl +['executor-cloud'] +| where _time > ago(1d) +| where ['status.code'] == "ERROR" and name == "executor.tool.execute" +| extend msg = substring(tostring(['status.message']), 0, 120) +| extend tool = tostring(['attributes.custom']['mcp.tool.name']) +| summarize n = count() by msg, tool +| sort by n desc +``` + +**Recipe — attribute errors to orgs.** Tool spans now carry +`executor.tenant` directly (post-#992). For spans from BEFORE that deploy, +join through the outer request span: + +```apl +['executor-cloud'] +| where name == "mcp.request" and isnotnull(['attributes.custom']['mcp.auth.organization_id']) +| project trace_id, org = tostring(['attributes.custom']['mcp.auth.organization_id']) +| join kind=inner ( + ['executor-cloud'] + | where ['status.code'] == "ERROR" and name == "executor.tool.execute" + | project trace_id, msg = substring(tostring(['status.message']), 0, 60) + ) on trace_id +| summarize n = count() by org, msg | sort by n desc +``` + +**Recipe — upstream failure rate per integration (post-#992 attrs):** + +```apl +['executor-cloud'] +| where _time > ago(1d) and name == "mcp.tool.dispatch" +| extend outcome = tostring(['attributes.custom']['executor.tool.outcome']) +| extend integration = tostring(['attributes.custom']['mcp.tool.integration']) +| where isnotnull(outcome) +| summarize calls = count(), fails = countif(outcome == "fail") by integration +| extend failRate = todouble(fails) / todouble(calls) +| sort by fails desc +``` + +**Known signal caveats** (audited 2026-06-12): + +- Pre-#992 spans: `ToolResult.fail` outcomes (upstream 4xx/5xx, auth + rejections) are INVISIBLE — they rode the Effect success channel with no + span marker. Don't conclude "no errors" from old data. +- Many pre-#992 ERROR spans have an EMPTY `status.message` (tagged errors + without a message field) — group those by `events` exception.type instead. +- `[object Object]` status messages are the pre-#992 formatting bug. + +## Prod database (`planetscale_mcp`) + +Read tool needs `{organization: "answer-overflow", database: "executor", +branch: "main"}`. It returns `ok: true` even when the SQL failed — check the +result text for `Error:`. Use for tenant/integration/connection facts that +spans don't carry (row sizes, config shapes, counts). + +## Product analytics (`posthog_api` / `mcp_posthog_com`) + +Browser-side events only (the ~60-event typed catalog, PR #987; server-side +events not built). The org-key `posthog_api` connection covers the REST API; +the OAuth MCP connection covers the higher-level tools. + +## Verifying a deploy's telemetry (Layer-0 canary) + +After deploying telemetry changes: run a known-failing tool call against +prod, then assert the expected attributes arrive in Axiom within ~1 min. +Absence of data looks identical to health — query for the NEW attribute +explicitly rather than eyeballing dashboards. The e2e equivalent runs on +every suite: `e2e/cloud/telemetry-contract.test.ts` via the `Telemetry` +service (motel `/api/spans/search?attr.=`). diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 9a97bed3c..a77694871 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -77,6 +77,31 @@ const r = yield * session.call("execute", { code: "return 1 + 1;" }); // human-in-the-loop: session.approvePaused(r.text) resumes a paused execution ``` +## Telemetry scenarios (cloud) + +The suite boots a motel OTLP store and points the target's real exporter at +it, so a scenario can assert on the spans the server ACTUALLY exported — +the layer where "observability silently went dark" bugs live (an attribute +stamped on a span the exporter never carries looks identical to health). + +```ts +const telemetry = yield * Telemetry; // skips when motel didn't boot +const span = + yield * + telemetry.expectSpan({ + operation: "executor.tool.execute", + attributes: { "mcp.tool.name": failAddress }, // exact match, values stringified + }); +expect(span.span.tags["executor.tool.outcome"]).toBe("fail"); +``` + +- `expectSpan` polls (~20s): exporters batch, so arrival is + eventually-consistent — "the span reaches the store, soon" IS the contract. +- Spec gotcha for fixtures: give operations explicit `tags` — tool addresses + are `group.leaf`, and an untagged op derives its group from the URL path, + so `/fail` does NOT produce a `.fail`-suffixed address. +- Prior art: `cloud/telemetry-contract.test.ts`. + ## Running ```sh diff --git a/e2e/cloud/telemetry-contract.test.ts b/e2e/cloud/telemetry-contract.test.ts new file mode 100644 index 000000000..680c15b6d --- /dev/null +++ b/e2e/cloud/telemetry-contract.test.ts @@ -0,0 +1,181 @@ +// Cloud: the telemetry contract, end to end. A tool call that hits an +// upstream error wall must be visible in the EXPORTED spans — not just +// handled gracefully for the caller. This is the regression class where the +// product silently goes dark to operators: `ToolResult.fail` rides the +// Effect success channel (a healthy-looking span), and an attribute stamped +// on the wrong span simply never arrives in the trace store, which looks +// identical to health. So the assertion runs against the OTLP store the dev +// stack actually exported to (the suite's motel — the same exporter layer +// that ships prod spans to Axiom), driving the whole production topology: +// HTTP API → execution engine → sandbox → OpenAPI invoke → a real upstream +// returning 502 → span batch → OTLP export. +// +// Pins two regressions found live in prod (2026-06-12): http.status_code was +// annotated inside the inner `OpenApi.invoke` span so the `plugin.openapi. +// invoke` span queries target carried it on 0 of ~19.5k spans; and failed +// tool calls were indistinguishable from successes on `executor.tool.execute`. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target, Telemetry } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Two operations: /ok answers 200, /fail answers 502 — the success and + * expected-upstream-failure outcome classes the telemetry must separate. */ +const upstreamSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Telemetry Upstream", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/ok": { + get: { + operationId: "ok", + summary: "Succeeds", + tags: ["probe"], + responses: { "200": { description: "" } }, + }, + }, + "/fail": { + get: { + operationId: "fail", + summary: "Always 502", + tags: ["probe"], + responses: { "200": { description: "" } }, + }, + }, + }, + }); + +/** A real upstream on 127.0.0.1: /ok → 200 JSON, anything else → 502 JSON. */ +const serveUpstream = Effect.acquireRelease( + Effect.callback<{ readonly baseUrl: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + const ok = request.url?.startsWith("/ok") ?? false; + response.writeHead(ok ? 200 : 502, { "content-type": "application/json" }); + response.end(ok ? '{"fine":true}' : '{"error":{"message":"bad gateway"}}'); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + baseUrl: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), +); + +scenario( + "Telemetry · a failing tool call is visible in the exported spans", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const telemetry = yield* Telemetry; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + + const upstream = yield* serveUpstream; + + // Identifier-safe slug: it becomes a property path in the sandbox code. + const slug = IntegrationSlug.make(`telscn${randomBytes(4).toString("hex")}`); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: upstreamSpec(upstream.baseUrl) }, + slug, + baseUrl: upstream.baseUrl, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("apiKey"), + value: "telemetry-scenario-token", + }, + }); + + const tools = yield* client.tools.list({ query: {} }); + const addressOf = (op: string) => { + const tool = tools.find( + (entry) => + String(entry.integration) === String(slug) && String(entry.address).endsWith(`.${op}`), + ); + expect(tool, `the ${op} tool is in the catalog`).toBeDefined(); + return String(tool!.address); + }; + const failAddress = addressOf("fail"); + const okAddress = addressOf("ok"); + + // Drive both outcome classes through the full production path. The + // failing call still completes for the caller — that is exactly why + // the exported span is the only place an operator can see it. + for (const address of [okAddress, failAddress]) { + const execution = yield* client.executions.execute({ + payload: { code: `return await ${address}({});` }, + }); + expect(execution.status, `the ${address} execution completes`).toBe("completed"); + } + + // The failure: outcome attributes on the tool span... + const failSpan = yield* telemetry.expectSpan({ + operation: "executor.tool.execute", + attributes: { "mcp.tool.name": failAddress }, + }); + expect(failSpan.span.tags, "a failed tool call is marked on the exported span").toMatchObject( + { + "executor.tool.outcome": "fail", + "executor.tool.error_code": "upstream_http_error", + "executor.tool.error_status": "502", + }, + ); + expect( + failSpan.span.tags["executor.tenant"], + "the span carries tenant attribution (no trace-join needed to ask 'whose error?')", + ).toBeTruthy(); + + // ...and the upstream status on the HTTP span queries actually target. + const invokeSpan = yield* telemetry.expectSpan({ + operation: "plugin.openapi.invoke", + attributes: { "plugin.openapi.base_url": upstream.baseUrl, "http.status_code": "502" }, + }); + expect( + invokeSpan.span.tags["plugin.openapi.method"], + "the invoke span names the method", + ).toBe("GET"); + + // The success is distinguishable from the failure. + const okSpan = yield* telemetry.expectSpan({ + operation: "executor.tool.execute", + attributes: { "mcp.tool.name": okAddress }, + }); + expect(okSpan.span.tags["executor.tool.outcome"], "a successful call is marked ok").toBe( + "ok", + ); + }), + ), +); diff --git a/e2e/setup/cloud.globalsetup.ts b/e2e/setup/cloud.globalsetup.ts index 59c4201cd..a49ba17e9 100644 --- a/e2e/setup/cloud.globalsetup.ts +++ b/e2e/setup/cloud.globalsetup.ts @@ -28,6 +28,10 @@ export default async function setup(): Promise<(() => Promise) | void> { // Suite-owned trace store — every run captures distributed traces. const motel = await bootMotel(); + // Publish to the test workers (they inherit this process's env): scenarios + // that assert on exported spans yield the Telemetry service, which exists + // only when this is set. No motel → those scenarios skip, never fail. + if (motel) process.env.E2E_MOTEL_URL = motel.url; const publicUrl = `http://127.0.0.1:${ports.E2E_CLOUD_PORT!}`; let booted; diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts index 4e018ab8e..cb233c237 100644 --- a/e2e/src/scenario.ts +++ b/e2e/src/scenario.ts @@ -24,6 +24,7 @@ import { makeApiSurface } from "./surfaces/api"; import { makeBrowserSurface } from "./surfaces/browser"; import { makeCliSurface } from "./surfaces/cli"; import { makeMcpSurface } from "./surfaces/mcp"; +import { makeTelemetrySurface } from "./surfaces/telemetry"; import { completeOAuthConsent, hasOpenCode, makeOpenCodeHome, warmUp } from "./clients/opencode"; import { Api, @@ -35,6 +36,7 @@ import { Restart, RunDir, Target, + Telemetry, TtlControl, } from "./services"; import { buildManifest } from "./viewer/manifest"; @@ -62,7 +64,8 @@ type AllServices = | Billing | OpenCode | TtlControl - | Restart; + | Restart + | Telemetry; /** * What this target on this host can provide. Services beyond the base are @@ -94,6 +97,9 @@ const contextFor = (target: TargetShape, dir: string): Context.Context()("e2e/mcp-oauth") {} /** Marker: billing limits are enforced on this target. */ export class Billing extends Context.Service()("e2e/billing") {} +/** Query the suite's OTLP trace store for spans the target actually exported + * (present when the suite booted motel — E2E_MOTEL_URL). */ +export class Telemetry extends Context.Service()("e2e/telemetry") {} + /** The real OpenCode binary, hermetically driveable (present when installed on this host). */ export interface OpenCodeClient { readonly makeHome: typeof makeOpenCodeHome; diff --git a/e2e/src/surfaces/telemetry.ts b/e2e/src/surfaces/telemetry.ts new file mode 100644 index 000000000..eb3549a12 --- /dev/null +++ b/e2e/src/surfaces/telemetry.ts @@ -0,0 +1,75 @@ +// Telemetry surface: query the suite's motel OTLP store for the spans the +// target ACTUALLY exported. This is how a scenario asserts the observability +// contract end-to-end — not "did the code create a span object" but "did the +// span leave the server, with the attributes production queries depend on". +// The dangerous regression mode here is silence (an attribute stamped on the +// wrong span, an error riding the success channel): absent data looks exactly +// like health, so the contract has to be pinned where the data is read. +import { Effect, Schedule } from "effect"; + +/** One exported span, as motel's /api/spans/search returns it. `tags` is the + * span's attributes with every value stringified. */ +export interface ExportedSpan { + readonly traceId: string; + readonly rootOperationName: string; + readonly span: { + readonly spanId: string; + readonly operationName: string; + readonly status: "ok" | "error"; + readonly durationMs: number; + readonly tags: Readonly>; + }; +} + +export interface SpanQuery { + /** Exact-match attribute filters (motel `attr.=`). */ + readonly attributes?: Readonly>; + readonly operation?: string; + readonly traceId?: string; +} + +export interface TelemetrySurface { + /** One-shot search against the trace store. */ + readonly searchSpans: (query: SpanQuery) => Effect.Effect; + /** Search until at least one span matches. Exporters batch (the app + * flushes ~1s after the request), so arrival is eventually-consistent — + * polling IS the contract: "the span reaches the store, soon". */ + readonly expectSpan: (query: SpanQuery) => Effect.Effect; +} + +export const makeTelemetrySurface = (motelUrl: string): TelemetrySurface => { + const searchSpans = (query: SpanQuery) => + Effect.gen(function* () { + const params = new URLSearchParams({ lookback: "15m", limit: "100" }); + if (query.operation) params.set("operation", query.operation); + if (query.traceId) params.set("traceId", query.traceId); + for (const [key, value] of Object.entries(query.attributes ?? {})) { + params.set(`attr.${key}`, value); + } + const response = yield* Effect.promise(() => fetch(`${motelUrl}/api/spans/search?${params}`)); + if (!response.ok) { + return yield* Effect.fail( + `motel span search responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + const body = (yield* Effect.promise(() => response.json())) as { + readonly data?: readonly ExportedSpan[]; + }; + return body.data ?? []; + }); + + return { + searchSpans, + expectSpan: (query) => + searchSpans(query).pipe( + Effect.filterOrFail( + (spans) => spans.length > 0, + () => `no exported span matched ${JSON.stringify(query)}`, + ), + Effect.map((spans) => spans[0]!), + // ~20s ceiling (40 × 500ms): BatchSpanProcessor flushes ~1s after the + // request and the dev stack drains on waitUntil; slower is a real bug. + Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))), + ), + }; +}; diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 619170b61..4e3448095 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -9,6 +9,7 @@ import type { ToolSchemaView, } from "@executor-js/sdk/core"; import { + annotateToolResultOutcome, authToolFailure, isToolResult, ToolResult, @@ -282,6 +283,10 @@ export const makeExecutorToolInvoker = ( // raw success value and wrapped — keeps the sandbox-facing contract // uniform without forcing every tiny test plugin to import // `ToolResult.ok`. + // Expected failures resolve through the success channel, so without the + // outcome annotation the dispatch span reads as healthy even when the + // caller hit an upstream error or auth wall. + yield* annotateToolResultOutcome(result); if (isToolResult(result)) { return result; } diff --git a/packages/core/sdk/src/elicitation.ts b/packages/core/sdk/src/elicitation.ts index 87b409698..213c9c623 100644 --- a/packages/core/sdk/src/elicitation.ts +++ b/packages/core/sdk/src/elicitation.ts @@ -62,4 +62,10 @@ export class ElicitationDeclinedError extends Schema.TaggedErrorClass()( "ToolNotFoundError", { address: ToolAddress, suggestions: Schema.optional(Schema.Array(ToolAddress)), }, -) {} +) { + override get message(): string { + return `Tool not found: ${this.address}`; + } +} export class ToolInvocationError extends Schema.TaggedErrorClass()( "ToolInvocationError", @@ -38,7 +49,11 @@ export class ToolBlockedError extends Schema.TaggedErrorClass( address: ToolAddress, pattern: Schema.String, }, -) {} +) { + override get message(): string { + return `Tool blocked by policy "${this.pattern}": ${this.address}`; + } +} /** Tool row exists but its owning plugin isn't loaded in this executor config. */ export class PluginNotLoadedError extends Schema.TaggedErrorClass()( @@ -47,13 +62,21 @@ export class PluginNotLoadedError extends Schema.TaggedErrorClass()("NoHandlerError", { address: ToolAddress, pluginId: Schema.String, -}) {} +}) { + override get message(): string { + return `Plugin "${this.pluginId}" has no invokeTool handler for tool: ${this.address}`; + } +} // --------------------------------------------------------------------------- // Integration / connection lifecycle @@ -62,7 +85,11 @@ export class NoHandlerError extends Schema.TaggedErrorClass()("N export class IntegrationNotFoundError extends Schema.TaggedErrorClass()( "IntegrationNotFoundError", { slug: IntegrationSlug }, -) {} +) { + override get message(): string { + return `Integration not found: ${this.slug}`; + } +} /** An "add integration" operation targeted a slug (namespace) that is already * registered. The core `integrations.register` primitive upserts by design @@ -73,14 +100,22 @@ export class IntegrationAlreadyExistsError extends Schema.TaggedErrorClass()( "IntegrationRemovalNotAllowedError", { slug: IntegrationSlug }, -) {} +) { + override get message(): string { + return `Integration cannot be removed (declared statically by a plugin): ${this.slug}`; + } +} export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", @@ -89,7 +124,11 @@ export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "CredentialProviderNotRegisteredError", { provider: ProviderKey }, -) {} +) { + override get message(): string { + return `Credential provider not registered: ${this.provider}`; + } +} /** A connection's value could not be resolved — the provider returned nothing, * or an OAuth token refresh failed and the user must re-auth. */ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index c8d578bf8..2413d5549 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option, Predicate, Schema } from "effect"; +import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { fumadb } from "@executor-js/fumadb"; import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; @@ -139,6 +139,7 @@ import { type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; import { connectionIdentifier } from "./connection-name-identifier"; +import { annotateToolResultOutcome } from "./tool-result"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; @@ -2719,10 +2720,20 @@ export const createExecutor = => { const handler = pickHandler(options); return Effect.gen(function* () { + // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry const formatInvocationCauseMessage = (cause: unknown): string => { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public execute error message wrapping for unknown plugin failures - return cause instanceof Error ? cause.message : String(cause); + if (cause instanceof Error && cause.message.length > 0) return cause.message; + // Non-Error / empty-message causes: `String(plainObject)` renders + // "[object Object]", which is what telemetry then shows as the only + // label for the failure. Prefer the tag, else stringify structurally. + if (typeof cause === "object" && cause !== null) { + const tag = (cause as { readonly _tag?: unknown })._tag; + if (typeof tag === "string") return tag; + return Inspectable.toStringUnknown(cause, 0); + } + return String(cause); }; + // oxlint-enable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check const wrapInvocationError = ( effect: Effect.Effect, ): Effect.Effect => @@ -2877,8 +2888,18 @@ export const createExecutor = => isUnknownToolResult(value); + +/** + * Annotate the current span with the outcome of a tool invocation. + * + * `ToolResult.fail` rides the Effect *success* channel by design (expected + * failures are values, not defects), which means the tracer records those + * spans as healthy. Without this, "user keeps hitting 4xx walls" is invisible + * to telemetry — the exact class of signal that lets us catch product issues + * before they're reported. Stamped attributes: + * + * - `executor.tool.outcome` — "ok" | "fail" (always, on ToolResults) + * - `executor.tool.error_code` — ToolError.code (fail only) + * - `executor.tool.error_status` — upstream HTTP status (fail, when present) + * + * Codes/statuses are enumerable identifiers, never user content — safe span + * attributes. Non-ToolResult values (raw success payloads) annotate "ok". + */ +export const annotateToolResultOutcome = (value: unknown): Effect.Effect => { + if (isToolResult(value) && !value.ok) { + return Effect.annotateCurrentSpan({ + "executor.tool.outcome": "fail", + "executor.tool.error_code": value.error.code, + ...(value.error.status != null ? { "executor.tool.error_status": value.error.status } : {}), + }); + } + return Effect.annotateCurrentSpan({ "executor.tool.outcome": "ok" }); +}; diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index df36cf13e..48fe06bf7 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -625,6 +625,11 @@ export const invokeWithLayer = ( return invoke(operation, args, resolvedHeaders, sourceQueryParams).pipe( Effect.provide(clientWithBaseUrl), + // `invoke` annotates http.status_code on ITS span (`OpenApi.invoke`, + // via Effect.fn) — annotateCurrentSpan inside it never reaches this + // wrapper span. Stamp the status here too so queries against + // `plugin.openapi.invoke` see the upstream outcome directly. + Effect.tap((result) => Effect.annotateCurrentSpan({ "http.status_code": result.status })), Effect.withSpan("plugin.openapi.invoke", { attributes: { "plugin.openapi.method": operation.method.toUpperCase(),