diff --git a/apps/cloud/src/auth/access-token-options.ts b/apps/cloud/src/auth/access-token-options.ts index d67915ce70..8bb4c583ec 100644 --- a/apps/cloud/src/auth/access-token-options.ts +++ b/apps/cloud/src/auth/access-token-options.ts @@ -1,10 +1,10 @@ import type { JWTVerifyOptions } from "jose"; -/** - * Require expiring WorkOS tokens. Token lifetime is controlled by WorkOS via - * `exp`; do not cap the age locally, since AuthKit issues MCP access tokens - * that live for several days. - */ +/** WorkOS credentials may authorize a request for at most 24 hours after issuance. */ +export const WORKOS_ACCESS_TOKEN_MAX_AGE_SECONDS = 24 * 60 * 60; + +/** Require signed, expiring tokens and cap their effective lifetime even if the issuer sets a later exp. */ export const workosAccessTokenOptions: JWTVerifyOptions = { requiredClaims: ["exp", "iat"], + maxTokenAge: WORKOS_ACCESS_TOKEN_MAX_AGE_SECONDS, }; diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 6c3c8e3ac2..5720941d11 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -228,7 +228,7 @@ export const withServiceLogging = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.tapCause((cause) => Effect.logError(`${name} failed`, cause)), + Effect.tapCause(() => Effect.logError(`${name} failed`)), Effect.mapError(publicError), Effect.withSpan(name), ) as Effect.Effect; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index c62934948d..5e71add01e 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -510,7 +510,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { yield* Effect.logWarning( "createOrganization: could not provision the Autumn customer", - { organizationId: org.id, error }, + { organizationId: org.id }, ); yield* captureCauseEffect(error); }), @@ -620,10 +620,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { organizationId }, ), ), - Effect.tapError((error) => + Effect.tapError(() => Effect.logError( "deleteOrganization: org marked deleted but the Autumn customer could not be deleted; retry the deletion", - { organizationId, error }, + { organizationId }, ), ), Effect.mapError(() => new OrganizationDeletionIncomplete({ step: "billing" })), @@ -663,10 +663,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( s.deleteOrganizationCascade(organizationId, deletedAt), ) .pipe( - Effect.tapError((error) => + Effect.tapError(() => Effect.logError( "deleteOrganization: org marked deleted, removed from WorkOS and Autumn, but local purge failed, tenant data and secrets orphaned; retry the deletion", - { organizationId, error }, + { organizationId }, ), ), ); diff --git a/apps/cloud/src/auth/workos-events-runner.ts b/apps/cloud/src/auth/workos-events-runner.ts index e5979e48b4..9990681e97 100644 --- a/apps/cloud/src/auth/workos-events-runner.ts +++ b/apps/cloud/src/auth/workos-events-runner.ts @@ -103,7 +103,7 @@ export const runWorkOsEventsSync = (): Promise => Effect.scoped, Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logError("workos_events: sync run failed", cause); + yield* Effect.logError("workos_events: sync run failed"); yield* captureCauseEffect(cause); }), ), diff --git a/apps/cloud/src/engine/execution-gate.ts b/apps/cloud/src/engine/execution-gate.ts index 60c102663f..21d6cfd1a3 100644 --- a/apps/cloud/src/engine/execution-gate.ts +++ b/apps/cloud/src/engine/execution-gate.ts @@ -175,7 +175,7 @@ export const makeExecutionLimitGate = (checkBalance: ExecutionBalanceCheck) => { Effect.catch((error: unknown) => Effect.gen(function* () { yield* Effect.sync(() => { - console.warn("[billing] execution balance check failed open:", error); + console.warn("[billing] execution balance check failed open"); }); yield* captureCauseEffect(error); return { blocked: false } as const satisfies GateDecision; diff --git a/apps/cloud/src/engine/execution-rate-limit.ts b/apps/cloud/src/engine/execution-rate-limit.ts index 04c35f5774..3eb432e618 100644 --- a/apps/cloud/src/engine/execution-rate-limit.ts +++ b/apps/cloud/src/engine/execution-rate-limit.ts @@ -132,7 +132,7 @@ const failOpen = ( "rate_limit.check.error_tag": outcome.errorTag, }); yield* Effect.sync(() => { - console.warn("[rate-limit] execution rate limit check failed open:", error); + console.warn("[rate-limit] execution rate limit check failed open"); }); if (!outcome.timedOut) yield* captureCauseEffect(error); return { blocked: false } as const satisfies GateDecision; @@ -303,7 +303,6 @@ export const makeExecutionRateLimiter = ( `[rate-limit] exemption lookup failed for ${organizationId}; treating as ${ cached ? "last known" : "not exempt" }:`, - error, ); }); yield* captureCauseEffect(error); diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts index 7ed5f2ab19..5ca8c16c23 100644 --- a/apps/cloud/src/extensions/billing/member-seats.ts +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -64,10 +64,9 @@ export const forkReportMemberSeats = ( waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); }); }).pipe( - Effect.catch((error) => + Effect.catch(() => Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, - error, }), ), Effect.withSpan("billing.reportMemberSeats"), diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 8269727272..6eca015292 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:workers"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { autumnHandler } from "autumn-js/backend"; @@ -138,7 +138,7 @@ const handler = Effect.gen(function* () { ); if (statusCode >= 400) { - console.error("[autumn] upstream error:", statusCode, response); + console.error("[autumn] upstream error", { status: statusCode }); return yield* new HttpResponseError({ status: statusCode, code: "billing_request_failed", @@ -150,7 +150,7 @@ const handler = Effect.gen(function* () { }).pipe( Effect.catchCause((err) => { if (isServerError(err)) { - console.error("[autumn] request failed:", Cause.pretty(err)); + console.error("[autumn] request failed", { status: 500 }); } return toErrorServerResponseEffect(err); }), diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index af04a5b829..75de6e1fb6 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -182,7 +182,7 @@ const make = Effect.sync(() => { // Silent billing data loss is worth paging on: autumn.trackExecution // is fire-and-forget so the caller doesn't handle it themselves. yield* Effect.sync(() => { - console.error("[billing] track failed:", error); + console.error("[billing] track failed"); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true }); @@ -217,7 +217,7 @@ const make = Effect.sync(() => { // Silent seat drift means wrong invoices, so failures page just // like a lost execution track. yield* Effect.sync(() => { - console.error("[billing] seat sync failed:", error); + console.error("[billing] seat sync failed"); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.members.failed": true }); diff --git a/apps/cloud/src/observability/error-logging.ts b/apps/cloud/src/observability/error-logging.ts index 008bbaf653..2583534472 100644 --- a/apps/cloud/src/observability/error-logging.ts +++ b/apps/cloud/src/observability/error-logging.ts @@ -1,17 +1,6 @@ import { Cause, Effect, Option, Predicate, Result } from "effect"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; -const MAX_LOGGED_CAUSE_CHARS = 4_000; - -const truncate = (value: string): string => - value.length <= MAX_LOGGED_CAUSE_CHARS - ? value - : `${value.slice(0, MAX_LOGGED_CAUSE_CHARS)}\n...[truncated ${ - value.length - MAX_LOGGED_CAUSE_CHARS - } chars]`; - -const loggedCause = (cause: Cause.Cause): string => truncate(Cause.pretty(cause)); - const objectValue = (value: unknown, key: string): unknown => Predicate.hasProperty(value, key) ? value[key] : undefined; @@ -65,7 +54,8 @@ export const logApiErrorCause = ( path: requestPath(request), status: httpStatus(error), errorTag: errorTag(error), - cause: loggedCause(cause), + // Error causes can contain provider responses, request bodies, and secrets. + // Keep only the classification; never serialize the exception or its stack. }); }; diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index 88b88419e5..f43022c76a 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -7,7 +7,7 @@ // `withObservability` (in @executor-js/api) wraps every handler effect; when // it sees an unmapped cause it asks `ErrorCapture.captureException` for a // trace id and fails with `InternalError({ traceId })`. The client gets -// the opaque id, we get the full cause + stack in Sentry. +// the opaque id; Sentry receives a minimized diagnostic event. // --------------------------------------------------------------------------- import * as Sentry from "@sentry/cloudflare"; @@ -15,16 +15,12 @@ import type { ErrorEvent, Scope } from "@sentry/cloudflare"; import { Cause, Effect, Layer, Predicate } from "effect"; import type * as Tracer from "effect/Tracer"; +import { minimizeSentryEvent } from "./sentry-privacy"; + import { ErrorCapture } from "@executor-js/api"; import { classifyDurableObjectError } from "@executor-js/cloudflare/mcp/durable-object-errors"; import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; -// Drizzle/postgres-js include the failing SQL (params + bound values) in -// their error message. For OpenAPI source inserts that's 1MB+ of spec -// text which blows past terminal scrollback and hides the actual pg -// error. Sentry still receives the full, untruncated cause via -// `setExtra`; only the dev-console mirror is capped. -const MAX_CONSOLE_CAUSE_CHARS = 4_000; const OTEL_TRACE_ID_PATTERN = /^[0-9a-f]{32}$/; const OTEL_SPAN_ID_PATTERN = /^[0-9a-f]{16}$/; @@ -52,11 +48,6 @@ export type OtelCorrelationContext = { readonly spanId: string; }; -const truncate = (s: string): string => - s.length <= MAX_CONSOLE_CAUSE_CHARS - ? s - : `${s.slice(0, MAX_CONSOLE_CAUSE_CHARS)}\n…[truncated ${s.length - MAX_CONSOLE_CAUSE_CHARS} chars]`; - const validOtelContext = (context: OtelCorrelationContext): boolean => OTEL_TRACE_ID_PATTERN.test(context.traceId) && OTEL_SPAN_ID_PATTERN.test(context.spanId); @@ -212,7 +203,7 @@ export const beforeSendCloudEvent = ( options?: { readonly logPayload?: boolean }, ): ErrorEvent | null => { const reported = beforeSendWithOtelCorrelation(event, options); - return reported === null ? null : withStableGroupingFingerprint(reported); + return reported === null ? null : minimizeSentryEvent(withStableGroupingFingerprint(reported)); }; /** @@ -230,8 +221,8 @@ export const beforeSendCloudEvent = ( export const cloudSentryOptions = (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - enableLogs: true, - sendDefaultPii: true, + enableLogs: false, + sendDefaultPii: false, skipOpenTelemetrySetup: true, beforeSend: (event: ErrorEvent) => beforeSendCloudEvent(event, { @@ -365,7 +356,7 @@ export const ErrorCaptureLive: Layer.Layer = Layer.succeed( ErrorCapture.of({ captureException: (cause) => Effect.gen(function* () { - console.error("[api] unhandled cause:", truncate(Cause.pretty(cause))); + console.error("[api] unhandled cause", classificationTagsOf(cause)); return (yield* captureCauseEffect(cause)) ?? ""; }), }), diff --git a/apps/cloud/src/observability/redact-span-urls.ts b/apps/cloud/src/observability/redact-span-urls.ts index ced0513214..c78a966451 100644 --- a/apps/cloud/src/observability/redact-span-urls.ts +++ b/apps/cloud/src/observability/redact-span-urls.ts @@ -110,6 +110,10 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { if (name !== event.name) event.name = name; if (event.attributes === undefined) continue; for (const [key, value] of Object.entries(event.attributes)) { + if (key === "exception.message" || key === "exception.stacktrace") { + event.attributes[key] = "[REDACTED]"; + continue; + } // Event attributes permit string[] exactly as span attributes do, so // array elements get the same free-text scrub, in place. if (Array.isArray(value)) { @@ -124,8 +128,7 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { const message = span.status.message; if (typeof message === "string") { - const redacted = redactUrlsInText(message); - if (redacted !== message) span.status.message = redacted; + span.status.message = "[REDACTED]"; } } } diff --git a/apps/cloud/src/observability/sentry-privacy.ts b/apps/cloud/src/observability/sentry-privacy.ts new file mode 100644 index 0000000000..ff9cf7c6d6 --- /dev/null +++ b/apps/cloud/src/observability/sentry-privacy.ts @@ -0,0 +1,58 @@ +import type { ErrorEvent } from "@sentry/cloudflare"; + +const SAFE_TAGS = new Set([ + "otel_trace_id", + "otel_span_id", + "operation", + "reason", + "status", + "mcp.do.cause_owner", +]); + +const identifier = (value: string | undefined): string | undefined => + value !== undefined && /^[\w.$<>:/@ -]{1,160}$/.test(value) ? value : undefined; + +const sourceFile = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const path = URL.canParse(value) ? new URL(value).pathname : value.split(/[?#]/)[0]; + return path && /^\/?(?:assets\/)?[\w./-]+\.(?:js|mjs|ts|tsx)$/.test(path) ? path : undefined; +}; + +/** Keep error classification, source positions and correlation; omit all raw payloads. */ +export const minimizeSentryEvent = (event: ErrorEvent): ErrorEvent => ({ + type: undefined, + event_id: event.event_id, + timestamp: event.timestamp, + platform: event.platform, + level: event.level, + release: event.release, + environment: event.environment, + fingerprint: event.fingerprint?.map((part) => identifier(part) ?? "Error"), + tags: Object.fromEntries( + Object.entries(event.tags ?? {}).filter( + ([key, value]) => SAFE_TAGS.has(key) && identifier(String(value)) !== undefined, + ), + ), + exception: { + values: event.exception?.values?.map((exception) => ({ + type: identifier(exception.type) ?? "Error", + value: "Details omitted to protect request data", + mechanism: exception.mechanism + ? { + type: identifier(exception.mechanism.type) ?? "generic", + handled: exception.mechanism.handled, + } + : undefined, + stacktrace: { + frames: exception.stacktrace?.frames?.map((frame) => ({ + filename: sourceFile(frame.filename), + function: identifier(frame.function), + module: identifier(frame.module), + lineno: frame.lineno, + colno: frame.colno, + in_app: frame.in_app, + })), + }, + })), + }, +}); diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 4ee8b7a2b4..ac07846ebc 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -27,6 +27,7 @@ }, "observability": { "enabled": true, + "redact_query_string": true, }, "ratelimits": [ { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fdbc9b7671..0f338405cd 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -911,23 +911,6 @@ const storageFailureFromUnknown = (message: string, cause: unknown): StorageFail const pluginStorageFailure = (pluginId: string, hook: string, cause: unknown): StorageFailure => storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause); -// oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: render an arbitrary failure into one readable log field -/** One-line rendering of a failed rebuild, for the operator-facing warning. - * A `StorageError` carries the actionable detail in its `cause` (the plugin's - * own failure) while its own message only names the hook, and structural - * stringification drops a `cause` that is an `Error` — so unwrap one level and - * keep both halves. */ -const describeSyncFailure = (error: unknown): string => { - const base = - error instanceof Error && error.message.length > 0 - ? error.message - : Inspectable.toStringUnknown(error, 0); - const cause = (error as { readonly cause?: unknown } | null | undefined)?.cause; - if (cause instanceof Error && cause.message.length > 0) return `${base}: ${cause.message}`; - return base; -}; -// oxlint-enable executor/no-instanceof-error, executor/no-unknown-error-message - const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { const version = "1.0.0"; const latestSchema = fumaSchema>({ @@ -4228,11 +4211,11 @@ export const createExecutor = Effect.logWarning("executor stale tool sync scan failed", { - error: describeSyncFailure(error), + errorTag: Predicate.isTagged(error, "StorageError") ? "StorageError" : "Unknown", }), ), ), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index de49a7352e..0e71ae02f8 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -14,7 +14,7 @@ // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- -import { Duration, Effect, Exit, Layer, Match, Option, Predicate, Schema } from "effect"; +import { Cause, Duration, Effect, Exit, Layer, Match, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; @@ -1100,7 +1100,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { { owner: input.owner, client: String(input.slug), - cause, + causeKind: Cause.isCause(cause) ? "Cause" : "Error", }, ).pipe(Effect.as(false)), ), @@ -2084,7 +2084,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ) .pipe( Effect.catch((failure) => - Effect.logWarning("executor oauth expired-session sweep failed", { cause: failure }), + Effect.logWarning("executor oauth expired-session sweep failed", { + failureType: typeof failure, + }), ), ); diff --git a/packages/core/sdk/src/subject-registry.ts b/packages/core/sdk/src/subject-registry.ts index ca02cef65a..c103c13c64 100644 --- a/packages/core/sdk/src/subject-registry.ts +++ b/packages/core/sdk/src/subject-registry.ts @@ -186,7 +186,7 @@ export const touchSubject = (db: FumaDb, input: TouchSubjectInput): Effect. Effect.logWarning("executor subject touch failed", { tenant: input.tenant, externalId: input.externalId, - cause, + failureType: typeof cause, }), ), Effect.withSpan("executor.subject.touch"), diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f7c1a349f7..91562be746 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -395,11 +395,6 @@ const readDebugDefault = (): boolean => { return value === "1" || value === "true"; }; -const capabilitySnapshot = (server: McpServer) => ({ - clientCapabilities: server.server.getClientCapabilities() ?? null, - elicitationSupport: getElicitationSupport(server), -}); - class McpNativeElicitationTransportError extends Data.TaggedError( "McpNativeElicitationTransportError", )<{ @@ -835,10 +830,9 @@ const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes try { - console.error( - `[executor:mcp] execute defect correlation_id=${correlationId}`, - Cause.pretty(cause), - ); + console.error(`[executor:mcp] execute defect correlation_id=${correlationId}`, { + nativeElicitationFailed, + }); } catch { /* ignore logger failures */ } @@ -2383,9 +2377,14 @@ export const createExecutorMcpServer = ( smoke(input.code), ).pipe( Effect.catchCause((cause) => - Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { - status: "ok", - } satisfies ArtifactSmokeRenderResult), + Effect.as( + Effect.logWarning("create-artifact smoke render was unavailable", { + causeKind: Cause.isCause(cause) ? "Cause" : "Error", + }), + { + status: "ok", + } satisfies ArtifactSmokeRenderResult, + ), ), ); const renderRejection = smokeRenderRejection(smokeResult); @@ -2896,7 +2895,7 @@ export const createExecutorMcpServer = ( console.error( "[executor] MCP session mode", JSON.stringify({ - ...capabilitySnapshot(server), + elicitationSupport: getElicitationSupport(server), elicitationMode: elicitationMode.mode, resumeEnabled: elicitationMode.mode !== "native", }), diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index d484dcdfb7..6949fc621d 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -308,7 +308,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( } const response = yield* client.execute(request).pipe( - Effect.tapCause((cause) => Effect.logError("graphql introspection request failed", cause)), + Effect.tapCause(() => + Effect.logError("graphql introspection request failed", { host: requestUrl.hostname }), + ), Effect.mapError( () => new GraphqlIntrospectionError({ @@ -339,7 +341,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( } const raw = yield* response.json.pipe( - Effect.tapCause((cause) => Effect.logError("graphql introspection JSON parse failed", cause)), + Effect.tapCause(() => Effect.logError("graphql introspection JSON parse failed")), Effect.mapError( () => new GraphqlIntrospectionError({