diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index a1fbab227..75cbbedb2 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -32,6 +32,7 @@ import { ServiceMapRollupService, TinybirdOrgTokenService, WarehouseQueryService, + summarizeCause, withPgConnectionScope, } from "@maple/api/alerting" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" @@ -230,7 +231,7 @@ export const catchTickFailure = (label: string) => ? Effect.interrupt : Effect.logError("Alerting tick failed").pipe( Effect.annotateLogs({ - "error.message": Cause.pretty(cause), + "error.message": summarizeCause(cause), "maple.alerting.tick": label, }), ), diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index c65ff698f..9a9e1f66a 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -14,6 +14,7 @@ export { ErrorIssueWorkflowService } from "./services/errors/ErrorIssueWorkflowS export { ErrorPolicyService } from "./services/errors/ErrorPolicyService" export { ErrorsService } from "./services/errors/ErrorsService" export { NotificationDispatcher } from "./services/alerts/NotificationDispatcher" +export { summarizeCause } from "@/platform/describe-cause" export { Database } from "@/platform/DatabaseLive" export { layerPg } from "@/platform/DatabasePgLive" export { withPgConnectionScope } from "@/platform/pg-connection-scope" diff --git a/apps/api/src/chat/turn-runner.ts b/apps/api/src/chat/turn-runner.ts index 8c1def24a..12d2407d3 100644 --- a/apps/api/src/chat/turn-runner.ts +++ b/apps/api/src/chat/turn-runner.ts @@ -31,6 +31,7 @@ import { LLM, Message, type Model } from "@maple/llm" import { Cause, Effect, Layer, ManagedRuntime, Stream } from "effect" import type { ChatSession } from "./ChatSession" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" const telemetry = MapleCloudflareSDK.make({ serviceName: "maple-api", @@ -190,7 +191,7 @@ const compactIfNeeded = ( Effect.annotateLogs({ sessionId: input.sessionId, messageId: input.messageId, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), @@ -332,7 +333,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis Effect.annotateLogs({ sessionId: input.sessionId, messageId: input.messageId, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/http/server-error-span.ts b/apps/api/src/http/server-error-span.ts index 5c0c6d3f1..c990a7a8c 100644 --- a/apps/api/src/http/server-error-span.ts +++ b/apps/api/src/http/server-error-span.ts @@ -1,4 +1,4 @@ -import { Cause, Context, Data, Effect } from "effect" +import { Cause, Context, Effect, Schema } from "effect" import { HttpMiddleware } from "effect/unstable/http" import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" @@ -39,11 +39,14 @@ import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" * would receive the raw two-reason cause; the `Die(response)` reason is * skipped by reporters via `HttpServerResponse`'s `ErrorReporter.ignore` flag. */ -export class Http5xxResponseError extends Data.TaggedError("@maple/api/http/Http5xxResponseError")<{ - readonly status: number - readonly method: string - readonly path: string -}> { +export class Http5xxResponseError extends Schema.TaggedError()( + "@maple/api/http/Http5xxResponseError", + { + status: Schema.Number, + method: Schema.String, + path: Schema.String, + }, +) { override get message(): string { return `HTTP ${this.status} (${this.method} ${this.path})` } diff --git a/apps/api/src/mcp/lib/inspect-widget.ts b/apps/api/src/mcp/lib/inspect-widget.ts index f186f3fb2..2f626d668 100644 --- a/apps/api/src/mcp/lib/inspect-widget.ts +++ b/apps/api/src/mcp/lib/inspect-widget.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Option, Result, Schema } from "effect" +import { Effect, Exit, Option, Result, Schema } from "effect" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { @@ -45,6 +45,7 @@ import { RAW_SQL_ENDPOINT, } from "@maple/widgets/dashboard" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" // `RAW_SQL_ENDPOINT` and `QUERY_SHAPE_ENDPOINTS` are used here as LABELS, not as // dispatch keys — dispatch goes through `dataSourceRawSql` / `dataSourceQuerySet`, @@ -531,7 +532,7 @@ export const inspectWidget = Effect.fn("inspectWidget")( // carries a `message`. A defect (no typed failure) falls back to the // pretty-printed cause. const failure = Option.getOrUndefined(Exit.findErrorOption(exit)) - const errorMessage = failure ? failure.message : Cause.pretty(exit.cause) + const errorMessage = failure ? failure.message : summarizeCause(exit.cause) return { queryId: draft.id, queryName: draft.name, @@ -765,7 +766,7 @@ export const inspectWidget = Effect.fn("inspectWidget")( Effect.catchCause((cause) => Effect.succeed({ kind: "inspection_error", - message: Cause.pretty(cause), + message: summarizeCause(cause), }), ), ) diff --git a/apps/api/src/mcp/tools/llm-tools.ts b/apps/api/src/mcp/tools/llm-tools.ts index 69ab7a755..d0942ed21 100644 --- a/apps/api/src/mcp/tools/llm-tools.ts +++ b/apps/api/src/mcp/tools/llm-tools.ts @@ -48,15 +48,20 @@ const cap = (message: string): string => ? message : `${message.slice(0, MAX_FAILURE_MESSAGE_CHARS)}…[truncated]` +/** + * Typed failures only. A defect is an internal breakage the model can do + * nothing with, and its message is the kind of thing this function exists to + * keep out of the transcript — so `Die` reasons are filtered out before + * rendering rather than summarized. + * + * `Cause.prettyErrors` does the narrowing: it resolves `message` through the + * same `toString`/JSON fallbacks Effect uses everywhere, so a failure that is + * not an `Error` still reads as something rather than "the tool failed". + */ export const summarizeToolFailure = (cause: Cause.Cause): string => { - const failure = cause.reasons.find(Cause.isFailReason) - const error: unknown = failure?.error - if (error instanceof Error) return cap(error.message) - if (error && typeof error === "object" && "message" in error) { - const message = (error as { message?: unknown }).message - if (typeof message === "string") return cap(message) - } - return "the tool failed" + const failures = Cause.prettyErrors(Cause.fromReasons(cause.reasons.filter(Cause.isFailReason))) + const first = failures[0] + return first === undefined ? "the tool failed" : cap(first.message) } /** diff --git a/apps/api/src/platform/DatabaseLive.ts b/apps/api/src/platform/DatabaseLive.ts index 01b268f2f..f46d86689 100644 --- a/apps/api/src/platform/DatabaseLive.ts +++ b/apps/api/src/platform/DatabaseLive.ts @@ -6,9 +6,20 @@ import { updateCurrentSpanName } from "./span-name" export type DatabaseClient = MaplePgClient +/** + * `cause` is the driver's own error, kept for `postgres-errors.ts` to read the + * `code`/SQLSTATE off. It is `Schema.Defect()` rather than `Schema.Unknown` for + * the reason the convention gives: `Unknown` has no encoded form, so anything + * that serialized a `DatabaseError` serialized the raw postgres.js object — + * host, port, driver options and all. `Defect()` encodes an `Error` to its + * `name` and `message`, and `excludeCause: true` stops at the driver error + * instead of walking into the socket error underneath it. `toDatabaseError` + * already lifts the root cause's message into `message`, so the diagnostic half + * survives the narrowing. + */ export class DatabaseError extends Schema.TaggedError()("@maple/api/lib/DatabaseError", { message: Schema.String, - cause: Schema.Unknown, + cause: Schema.Defect({ excludeCause: true }), }) {} export interface DatabaseApi { diff --git a/apps/api/src/platform/EmailService.ts b/apps/api/src/platform/EmailService.ts index 46da9796e..80ada586c 100644 --- a/apps/api/src/platform/EmailService.ts +++ b/apps/api/src/platform/EmailService.ts @@ -1,10 +1,11 @@ import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" -import { Context, Data, Duration, Effect, Layer } from "effect" +import { Context, Duration, Effect, Layer, Schema } from "effect" import { Env } from "./Env" -class EmailDeliveryError extends Data.TaggedError("@maple/api/platform/EmailDeliveryError")<{ - readonly message: string -}> {} +class EmailDeliveryError extends Schema.TaggedError()( + "@maple/api/platform/EmailDeliveryError", + { message: Schema.String }, +) {} export interface EmailServiceApi { readonly isConfigured: boolean diff --git a/apps/api/src/platform/Env.ts b/apps/api/src/platform/Env.ts index dc265c876..06e3e5479 100644 --- a/apps/api/src/platform/Env.ts +++ b/apps/api/src/platform/Env.ts @@ -1,10 +1,11 @@ import { optionalRedacted, optionalString, stringWithDefault } from "@maple/effect-cloudflare/config-helpers" -import { Config, Context, Data, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" /** Fatal misconfiguration discovered at startup — surfaces as a tagged defect in the Cause. */ -class EnvValidationError extends Data.TaggedError("@maple/api/lib/EnvValidationError")<{ - readonly message: string -}> {} +class EnvValidationError extends Schema.TaggedError()( + "@maple/api/lib/EnvValidationError", + { message: Schema.String }, +) {} export interface EnvConfig { readonly PORT: number diff --git a/apps/api/src/platform/describe-cause.test.ts b/apps/api/src/platform/describe-cause.test.ts new file mode 100644 index 000000000..cad89f997 --- /dev/null +++ b/apps/api/src/platform/describe-cause.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause } from "effect" +import { toDatabaseError } from "./DatabaseLive" +import { summarizeCause } from "./describe-cause" + +/** + * A postgres.js connection failure, shaped as the driver actually raises it: + * the machine-readable class on `code`, and the socket error hung off `cause` + * with the host and port it was dialing. + */ +const driverConnectionError = (): Error => + Object.assign(new Error("write CONNECT_TIMEOUT"), { + code: "CONNECT_TIMEOUT", + cause: Object.assign(new Error("connect ETIMEDOUT 10.0.4.19:5432"), { + code: "ETIMEDOUT", + address: "10.0.4.19", + port: 5432, + }), + }) + +describe("summarizeCause", () => { + it("names a tagged failure by its tag", () => { + const summary = summarizeCause(Cause.fail(toDatabaseError(new Error("relation does not exist")))) + assert.strictEqual(summary, "@maple/api/lib/DatabaseError: relation does not exist") + }) + + it("keeps the raw driver object out of the annotation", () => { + const summary = summarizeCause(Cause.fail(toDatabaseError(driverConnectionError()))) + // `toDatabaseError` deliberately lifts the root cause's *message* into its + // own, and that stays: which host timed out is the whole diagnostic. What + // must not follow it is the driver object itself — its `code`, `address` + // and `port` properties, and every frame of its stack. + assert.include(summary, "write CONNECT_TIMEOUT") + assert.include(summary, "connect ETIMEDOUT") + assert.notInclude(summary, "address") + assert.notInclude(summary, "[cause]") + }) + + it("carries no stack frames", () => { + const summary = summarizeCause(Cause.fail(new Error("boom"))) + assert.strictEqual(summary, "Error: boom") + }) + + it("names a defect by its constructor", () => { + assert.strictEqual( + summarizeCause(Cause.die(new TypeError("x is not a function"))), + "TypeError: x is not a function", + ) + }) + + it("reports every reason of a multi-failure cause", () => { + const cause = Cause.fromReasons([ + ...Cause.fail(new Error("first")).reasons, + ...Cause.die(new Error("second")).reasons, + ]) + assert.strictEqual(summarizeCause(cause), "Error: first; Error: second") + }) + + it("caps a statement a warehouse error inlined whole", () => { + const summary = summarizeCause(Cause.fail(new Error(`syntax error near ${"SELECT x, ".repeat(200)}`))) + assert.isBelow(summary.length, 550) + assert.isTrue(summary.endsWith("…[truncated]")) + }) + + it("stays legible for a failure that is not an Error", () => { + // Effect's own normalizer covers these, which is the reason this module + // does not hand-roll the narrowing: a thrown string, number, or bare + // object all still produce something groupable. + assert.strictEqual(summarizeCause(Cause.fail("plain string failure")), "Error: plain string failure") + assert.strictEqual(summarizeCause(Cause.fail(42)), "Error: 42") + assert.strictEqual(summarizeCause(Cause.fail({ message: "objish" })), "Error: objish") + }) + + it("says something rather than nothing for an empty cause", () => { + assert.strictEqual(summarizeCause(Cause.fromReasons([])), "empty cause") + }) +}) diff --git a/apps/api/src/platform/describe-cause.ts b/apps/api/src/platform/describe-cause.ts index 26c92ad8b..8dc3fa90c 100644 --- a/apps/api/src/platform/describe-cause.ts +++ b/apps/api/src/platform/describe-cause.ts @@ -1,3 +1,5 @@ +import { Cause } from "effect" + /** Render an unknown nested cause for logs and serialized persistence errors. */ export const describeCause = (cause: unknown): string | undefined => { if (cause == null) return undefined @@ -9,3 +11,37 @@ export const describeCause = (cause: unknown): string | undefined => { return String(cause) } } + +/** + * "One line" is a convention no error's author agreed to: a ClickHouse syntax + * error arrives with the whole offending statement inlined, and a retry loop + * writes it once per attempt. + */ +const MAX_CAUSE_CHARS = 500 + +const cap = (message: string): string => + message.length <= MAX_CAUSE_CHARS ? message : `${message.slice(0, MAX_CAUSE_CHARS)}…[truncated]` + +/** + * A bounded, one-line rendering of a cause, safe to hand `Effect.annotateLogs`. + * + * `Cause.pretty` is the reflex here and it is the wrong tool for a log line: it + * renders every reason's stack frames and walks `Error.cause` chains inline, so + * a `DatabaseError` — whose `cause` is the raw postgres.js error — drags the + * driver's options into the annotation, and a warehouse failure drags in the + * statement that failed. Neither told an operator anything the tag and the + * span's `error.type` did not, and both are billed by the byte. + * + * `Cause.prettyErrors` is the same normalizer `Cause.pretty` builds on, stopped + * one step earlier: it hands back an `Error` per reason with `name` resolved to + * the tag (`@maple/api/lib/DatabaseError`) and `message` resolved through the + * same fallbacks — `toString`, then JSON — that make a thrown string, number or + * bare object legible. Reading only those two leaves the stack and the nested + * cause behind. The SDK's tracer builds its `exception` events off the same + * call, so a log line and its span agree on what the failure was called. + */ +export const summarizeCause = (cause: Cause.Cause): string => { + const errors = Cause.prettyErrors(cause) + if (errors.length === 0) return "empty cause" + return cap(errors.map((error) => `${error.name}: ${error.message}`).join("; ")) +} diff --git a/apps/api/src/routes/internal/chat.http.ts b/apps/api/src/routes/internal/chat.http.ts index f71b36d56..13e8c0cdd 100644 --- a/apps/api/src/routes/internal/chat.http.ts +++ b/apps/api/src/routes/internal/chat.http.ts @@ -16,6 +16,7 @@ import { mapleToolCatalog } from "@/mcp/tools/registry" import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" import { McpToolExecutor } from "@/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" const executionDefect = (tool: string, defect: unknown) => Effect.logError("Chat approval tool execution defect").pipe( @@ -147,7 +148,7 @@ export const HttpChatLive = HttpApiBuilder.group(MapleInternalApi, "chat", (hand sessionId: payload.sessionId ?? "(none)", messageId: payload.messageId ?? "(none)", toolCallId: payload.toolCallId ?? "(none)", - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index 780b77ff9..6cd17131f 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -41,7 +41,7 @@ import { import { cloudflareAnalyticsState } from "@maple/db" import { EdgeCacheService } from "@maple/cache" import { and, eq } from "drizzle-orm" -import { Cause, Effect, Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { graphqlQuery } from "@/services/integrations/CloudflareApi" @@ -63,6 +63,7 @@ import { GithubConnectService } from "@/services/integrations/vcs/vendor/github/ import { VcsCommitService } from "@/services/integrations/vcs/VcsCommitService" import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { requireAdmin as requireAdminRole } from "@/services/auth/auth" +import { summarizeCause } from "@/platform/describe-cause" const asExternalUserId = Schema.decodeUnknownSync(ExternalUserId) const asUserId = Schema.decodeUnknownSync(UserId) @@ -1034,7 +1035,7 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => Effect.catchCause((cause) => Effect.logWarning("cloudflare post-connect state reset failed", { orgId: result.orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index 1746a5c49..de51b720d 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -59,7 +59,6 @@ import { import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm" import { Array as Arr, - Cause, Chunk, Effect, HashSet, @@ -116,6 +115,7 @@ import { type NormalizedRule, } from "./AlertRuleModel" import { mapSignalUnit, resolveSignalDisplay } from "./alert-signal-display" +import { summarizeCause } from "@/platform/describe-cause" export { AlertRuntime, type AlertRuntimeApi } from "./AlertRuntime" @@ -3355,7 +3355,7 @@ export class AlertsService extends Context.Service>( ANOMALY_ACTIVE_ORGS_CACHE_BUCKET, @@ -2109,14 +2110,14 @@ const make = Effect.gen(function* () { ).pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } else { yield* Effect.logError("Anomaly tick failed for org").pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/alerts/EscalationService.ts b/apps/api/src/services/alerts/EscalationService.ts index f74783a1f..875b9f7ef 100644 --- a/apps/api/src/services/alerts/EscalationService.ts +++ b/apps/api/src/services/alerts/EscalationService.ts @@ -33,6 +33,7 @@ import { Env } from "@/platform/Env" import { evaluateEscalationPolicy } from "@/services/alerts/escalation-policy" import { makePersistenceError } from "./alert-persistence" import { NotificationDispatcher, type NotificationRequest } from "./NotificationDispatcher" +import { summarizeCause } from "@/platform/describe-cause" const ESCALATIONS_PER_TICK = 50 const MAX_ATTEMPTS = 3 @@ -297,7 +298,7 @@ const make: Effect.Effect()("@maple/api/ Cause.hasInterruptsOnly(cause) ? Effect.interrupt : Effect.logWarning("Failed to seed digest subscriptions").pipe( - Effect.annotateLogs({ error: Cause.pretty(cause) }), + Effect.annotateLogs({ error: summarizeCause(cause) }), ), ), ) @@ -818,7 +819,7 @@ export class DigestService extends Context.Service()("@maple/api/ Effect.annotateLogs({ subscriptionId: sub.id, orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), @@ -850,14 +851,14 @@ export class DigestService extends Context.Service()("@maple/api/ ).pipe( Effect.annotateLogs({ orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } else { yield* Effect.logError("Digest failed for org").pipe( Effect.annotateLogs({ orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/errors/ErrorActorsService.ts b/apps/api/src/services/errors/ErrorActorsService.ts index d916f9cd5..afa555247 100644 --- a/apps/api/src/services/errors/ErrorActorsService.ts +++ b/apps/api/src/services/errors/ErrorActorsService.ts @@ -12,11 +12,12 @@ import { } from "@maple/domain/http" import { actors, type ActorInsert, type ActorRow } from "@maple/db" import { and, desc, eq, inArray } from "drizzle-orm" -import { Cause, Clock, Context, Effect, Layer, Option, Schema } from "effect" +import { Clock, Context, Effect, Layer, Option, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { msToDate } from "@/platform/time" import { isReservedAgentName, SYSTEM_ERRORS_AGENT_NAME } from "@/services/auth/system-actors" import { makeErrorDatabaseExecute } from "./error-persistence" +import { summarizeCause } from "@/platform/describe-cause" const decodeActorIdSync = Schema.decodeUnknownSync(ActorIdSchema) const decodeActorDateTimeSync = Schema.decodeUnknownSync(ActorDocument.fields.lastActiveAt) @@ -117,7 +118,7 @@ const make: Effect.Effect = Effect.gen(f ).pipe( Effect.tapCause((cause) => Effect.logWarning("ErrorsService.touchActor failed to update lastActiveAt").pipe( - Effect.annotateLogs({ orgId, actorId, cause: Cause.pretty(cause) }), + Effect.annotateLogs({ orgId, actorId, cause: summarizeCause(cause) }), ), ), Effect.ignore, diff --git a/apps/api/src/services/errors/ErrorsService.ts b/apps/api/src/services/errors/ErrorsService.ts index 6b02537b8..c854fba71 100644 --- a/apps/api/src/services/errors/ErrorsService.ts +++ b/apps/api/src/services/errors/ErrorsService.ts @@ -59,6 +59,7 @@ import { import { ErrorIssueWorkflowService, type ErrorIssueWorkflowPublicApi } from "./ErrorIssueWorkflowService" import { ErrorPolicyService, type ErrorPolicyPublicApi } from "./ErrorPolicyService" import { makeErrorDatabaseExecute, makePersistenceError } from "./error-persistence" +import { summarizeCause } from "@/platform/describe-cause" export { describeCause, makePersistenceError } from "./error-persistence" @@ -329,7 +330,7 @@ const make: Effect.Effect< : Effect.gen(function* () { yield* Effect.logWarning( "Error active-org discovery failed; reusing last-known active set", - ).pipe(Effect.annotateLogs({ error: Cause.pretty(cause) })) + ).pipe(Effect.annotateLogs({ error: summarizeCause(cause) })) const cached = yield* edgeCache .rawGet>( ACTIVE_ORGS_CACHE_BUCKET, @@ -1363,13 +1364,13 @@ const make: Effect.Effect< yield* Effect.logInfo( "Org warehouse rejected queries with a config-class error; quarantined", ).pipe( - Effect.annotateLogs({ orgId: org, error: Cause.pretty(cause) }), + Effect.annotateLogs({ orgId: org, error: summarizeCause(cause) }), ) } else { yield* Effect.logError("Error tick failed for org").pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/errors/InvestigationService.ts b/apps/api/src/services/errors/InvestigationService.ts index 75d93993d..1b7818670 100644 --- a/apps/api/src/services/errors/InvestigationService.ts +++ b/apps/api/src/services/errors/InvestigationService.ts @@ -36,7 +36,7 @@ import { } from "@maple/db" import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" +import { Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" import { trackTokenUsage } from "@/services/billing/autumn-tracker" import { applyDiagnosisWrites } from "@/services/errors/apply-diagnosis" import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@/workflows/incident-context" @@ -50,6 +50,7 @@ import { import { Database } from "@/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@/platform/db-execute" import { Env } from "@/platform/Env" +import { summarizeCause } from "@/platform/describe-cause" /** * Cloudflare Workflow binding that runs a fan-out. Named here rather than read @@ -542,7 +543,7 @@ export class InvestigationService extends Context.Service Effect.logWarning("token usage tracking failed").pipe( - Effect.annotateLogs({ investigationId: id, cause: Cause.pretty(cause) }), + Effect.annotateLogs({ investigationId: id, cause: summarizeCause(cause) }), ), ), ) diff --git a/apps/api/src/services/errors/ai-triage-enqueue.ts b/apps/api/src/services/errors/ai-triage-enqueue.ts index b8bba3919..20ec7fe86 100644 --- a/apps/api/src/services/errors/ai-triage-enqueue.ts +++ b/apps/api/src/services/errors/ai-triage-enqueue.ts @@ -12,7 +12,7 @@ import { import { InvestigationId, IsoDateTimeString } from "@maple/domain/primitives" import { aiTriageSettings, investigations } from "@maple/db" import { and, eq, lt } from "drizzle-orm" -import { Cause, Clock, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" +import { Clock, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" import { encodeChatTurnTenant } from "@maple/domain/chat-session" import { Database } from "@/platform/DatabaseLive" import { isChatSessionNamespace } from "@/chat/session" @@ -26,6 +26,7 @@ import { staleTimeoutMessage, } from "@/services/errors/investigation-stale" import { UserId } from "@maple/domain/primitives" +import { summarizeCause } from "@/platform/describe-cause" /** Identity an autonomous investigation turn runs as — the same one the internal MCP RPC uses. */ const internalServiceUserId = Schema.decodeSync(UserId)("internal-service") @@ -387,7 +388,7 @@ export const maybeEnqueueTriage: ( Effect.annotateLogs({ orgId: input.orgId, investigationId, - error: Cause.pretty(created.cause), + error: summarizeCause(created.cause), }), ) yield* markFailed("start_failed: the investigation fan-out could not be started; retry") @@ -408,7 +409,7 @@ export const maybeEnqueueTriage: ( orgId: input.orgId, incidentKind: input.incidentKind, incidentId: input.incidentId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), Effect.as({ enqueued: false, reason: "error" as const }), ), diff --git a/apps/api/src/services/errors/issue-hub.ts b/apps/api/src/services/errors/issue-hub.ts index a6931708e..fb9ca3b35 100644 --- a/apps/api/src/services/errors/issue-hub.ts +++ b/apps/api/src/services/errors/issue-hub.ts @@ -9,11 +9,12 @@ import { } from "@maple/domain/primitives" import { actors, alertIncidents, errorIssues, errorIssueEvents, type ErrorIssueRow } from "@maple/db" import { and, eq, sql } from "drizzle-orm" -import { Cause, Clock, Effect, Option, Redacted, Schema } from "effect" +import { Clock, Effect, Option, Redacted, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { maybeEnqueueTriage } from "./ai-triage-enqueue" import { issueSeverityFromAlert } from "./severity-map" import { SYSTEM_ALERTS_AGENT_NAME } from "@/services/auth/system-actors" +import { summarizeCause } from "@/platform/describe-cause" /** * Issue-hub glue: alert incidents create/refresh `error_issues` rows @@ -344,7 +345,7 @@ export const upsertAlertIssue: ( orgId: input.orgId, ruleId: input.ruleId, incidentId: input.incidentId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) return { issueId: null, action: "error" as const } diff --git a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts index cef4bed58..fac1388e0 100644 --- a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts +++ b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts @@ -136,6 +136,7 @@ import { type SettingsResponseContract, } from "./cloudflare-analytics/queries" import * as Integrations from "@maple/query-engine-integrations" +import { summarizeCause } from "@/platform/describe-cause" /** * OAuth scopes the poller needs (space-delimited ids in `oauth_connections.scope`). Kept next to @@ -2124,7 +2125,7 @@ export class CloudflareAnalyticsService extends Context.Service< Effect.catchCause((cause) => Effect.logWarning("cloudflare-analytics lease release failed", { orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), @@ -2181,7 +2182,7 @@ export class CloudflareAnalyticsService extends Context.Service< ? Effect.interrupt : Effect.logWarning("cloudflare-analytics org poll failed", { orgId: row.orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }).pipe( // A crashed org must still appear in the rollup — otherwise perOrg // silently omits it, `skipped` undercounts, and the zero-rows warning diff --git a/apps/api/src/services/integrations/PlanetScaleService.ts b/apps/api/src/services/integrations/PlanetScaleService.ts index 958e58ed1..d6e10f479 100644 --- a/apps/api/src/services/integrations/PlanetScaleService.ts +++ b/apps/api/src/services/integrations/PlanetScaleService.ts @@ -27,6 +27,7 @@ import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { PlanetScaleOAuthService, planetScaleBearerHeader } from "@/services/auth/PlanetScaleOAuthService" import { insertPlanetScaleEvent } from "./planetscale/webhook-events" +import { summarizeCause } from "@/platform/describe-cause" /** * PlanetScale management-API poller: keeps the org's database/branch inventory @@ -908,7 +909,7 @@ export class PlanetScaleService extends Context.Service 0), ), @@ -936,7 +937,7 @@ export class PlanetScaleService extends Context.Service 0), ), @@ -1032,7 +1033,7 @@ export class PlanetScaleService extends Context.Service { failures++ diff --git a/apps/api/src/services/integrations/ScrapeTargetsService.ts b/apps/api/src/services/integrations/ScrapeTargetsService.ts index 771973103..53d76ecc2 100644 --- a/apps/api/src/services/integrations/ScrapeTargetsService.ts +++ b/apps/api/src/services/integrations/ScrapeTargetsService.ts @@ -42,6 +42,7 @@ import { planetScaleBearerHeader, type PlanetScaleAccessTokenError, } from "@/services/auth/PlanetScaleOAuthService" +import { summarizeCause } from "@/platform/describe-cause" type ScrapeTargetRow = typeof scrapeTargets.$inferSelect @@ -782,7 +783,7 @@ export class ScrapeTargetsService extends Context.Service) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning(message).pipe( + Effect.annotateLogs({ ...annotations, cause: summarizeCause(cause) }), + ), + ), + Effect.ignore, + ) + export interface IncidentPushEvent { readonly orgId: OrgId readonly eventType: AlertEventType @@ -417,7 +446,12 @@ export class MobilePushService extends Context.Service Effect.logError("[VCS] scheduled sync tick failed").pipe( - Effect.annotateLogs({ error: Cause.pretty(cause) }), + Effect.annotateLogs({ error: summarizeCause(cause) }), ), ), ), @@ -175,7 +176,7 @@ export const processBatch = (batch: MessageBatch) => Effect.flatMap(() => Effect.logError("[VCS] sync message failed").pipe( Effect.annotateLogs({ - error: Cause.pretty(cause), + error: summarizeCause(cause), attempt: message.attempts, outcome, ...(isFinalAttempt ? { exhausted: true } : undefined), diff --git a/apps/api/src/workflows/agent-pass.ts b/apps/api/src/workflows/agent-pass.ts index 58bfcf2b1..c97b951b6 100644 --- a/apps/api/src/workflows/agent-pass.ts +++ b/apps/api/src/workflows/agent-pass.ts @@ -31,6 +31,7 @@ import { runChatTurn, makeTurnUsage, type TurnCompletion, type TurnUsage } from import type { AgentDefinition } from "@/chat/agents" import { McpToolExecutor } from "@/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" export interface AgentPassInput { /** Correlation id; becomes the turn's `messageId`. */ @@ -135,7 +136,7 @@ export const runAgentPass = ( messageId: input.id, submitted: Option.isSome(answer), toolCallCount: toolCalls, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), Effect.tap(() => Effect.annotateCurrentSpan("maple.agent.recovered_failure", true), diff --git a/apps/web/src/components/app-error-boundary.tsx b/apps/web/src/components/app-error-boundary.tsx index a2decb360..c2e2bb913 100644 --- a/apps/web/src/components/app-error-boundary.tsx +++ b/apps/web/src/components/app-error-boundary.tsx @@ -2,11 +2,12 @@ * Last-resort boundary for errors outside router boundaries. It has no router * context; crash artwork geometry is coupled to `.boot-*`/`.crash-*` in styles.css. */ -import { Component, type ReactNode } from "react" +import { Component, type ErrorInfo, type ReactNode } from "react" import { buttonVariants } from "@maple/ui/components/ui/button" import { isChunkLoadError, shouldAttemptChunkReload } from "@/lib/chunk-reload" import { displayError } from "@/lib/error-messages" +import { captureException } from "@/lib/services/common/otel-layer" interface AppErrorBoundaryProps { children: ReactNode @@ -23,10 +24,25 @@ export class AppErrorBoundary extends Component { + if (!shouldReport) return + captureException(error, { + name: "browser.route_error", + attributes: { "maple.exception.source": "route_error_boundary" }, + }) + }) + const autoRetrying = useNetworkAutoRetry(isAutomaticRetryError(formatted) && !isStaleChunk, retry) const description = autoRetrying ? `${formatted.message} Retrying automatically…` : formatted.message const canRetry = formatted.recovery === "retry" || formatted.recovery === "refresh" diff --git a/apps/web/src/lib/services/common/otel-layer.ts b/apps/web/src/lib/services/common/otel-layer.ts index 22b5226a0..e21b7de9e 100644 --- a/apps/web/src/lib/services/common/otel-layer.ts +++ b/apps/web/src/lib/services/common/otel-layer.ts @@ -47,3 +47,13 @@ const telemetry = MapleFlush.make({ }) export const mapleOtelLayer = telemetry.layer + +/** + * Report an error that never went through an Effect span. + * + * The SDK's global handlers already cover uncaught throws and unhandled + * rejections. This is for the one case they cannot see: React error boundaries + * catch a render crash and, in production, swallow it — so the dashboard would + * paint its crash screen and Maple would never hear about its own outage. + */ +export const captureException = telemetry.captureException diff --git a/apps/web/src/lib/services/common/telemetry.ts b/apps/web/src/lib/services/common/telemetry.ts index d9324f897..a4800fb77 100644 --- a/apps/web/src/lib/services/common/telemetry.ts +++ b/apps/web/src/lib/services/common/telemetry.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect" +import { Effect, Schema } from "effect" import { runtime } from "./runtime" const requestUrl = (input: RequestInfo | URL): string => @@ -78,10 +78,10 @@ export const describeFetchFailure = (input: { return `Fetch failed: ${where}${status}${detail.length > 0 ? ` — ${detail}` : ""}` } -class TracedFetchError extends Data.TaggedError("@maple/web/TracedFetchError")<{ - readonly message: string - readonly cause: unknown -}> {} +class TracedFetchError extends Schema.TaggedError()("@maple/web/TracedFetchError", { + message: Schema.String, + cause: Schema.Defect(), +}) {} export const tracedFetch = ( peerService: string, diff --git a/apps/web/src/lib/services/common/v2-pagination.ts b/apps/web/src/lib/services/common/v2-pagination.ts index 50f6ff74e..d34a01d62 100644 --- a/apps/web/src/lib/services/common/v2-pagination.ts +++ b/apps/web/src/lib/services/common/v2-pagination.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect" +import { Effect, Schema } from "effect" export interface V2Page { readonly data: ReadonlyArray @@ -6,12 +6,13 @@ export interface V2Page { readonly next_cursor: string | null } -export class V2PaginationCursorLoopError extends Data.TaggedError( +export class V2PaginationCursorLoopError extends Schema.TaggedError()( "@maple/web/services/V2PaginationCursorLoopError", -)<{ - readonly cursor: string - readonly message: string -}> { + { + cursor: Schema.String, + message: Schema.String, + }, +) { static repeated(cursor: string): V2PaginationCursorLoopError { return new V2PaginationCursorLoopError({ cursor, diff --git a/lib/effect-cloudflare/src/kv-namespace.ts b/lib/effect-cloudflare/src/kv-namespace.ts index dc9c68716..254ce40c3 100644 --- a/lib/effect-cloudflare/src/kv-namespace.ts +++ b/lib/effect-cloudflare/src/kv-namespace.ts @@ -10,14 +10,17 @@ // API surface matches upstream so `yield* KVNamespace.bind(MY_KV)` is a // source-compatible call. import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import { WorkerEnvironment } from "./worker-environment.ts" -export class KVNamespaceError extends Data.TaggedError("@maple/effect-cloudflare/KVNamespaceError")<{ - message: string - cause: unknown -}> {} +export class KVNamespaceError extends Schema.TaggedError()( + "@maple/effect-cloudflare/KVNamespaceError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} /** * A reference to a KV namespace binding declared in wrangler.jsonc. diff --git a/lib/effect-cloudflare/src/r2-bucket.ts b/lib/effect-cloudflare/src/r2-bucket.ts index 01e400000..1c269cf25 100644 --- a/lib/effect-cloudflare/src/r2-bucket.ts +++ b/lib/effect-cloudflare/src/r2-bucket.ts @@ -5,16 +5,16 @@ // Account API) and keep the runtime half. `R2Bucket("MY_BUCKET")` is a // lightweight token; `R2Bucket.bind(token)` yields the client. import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Stream from "effect/Stream" import { WorkerEnvironment } from "./worker-environment.ts" -export class R2Error extends Data.TaggedError("@maple/effect-cloudflare/R2Error")<{ - message: string - cause: unknown -}> {} +export class R2Error extends Schema.TaggedError()("@maple/effect-cloudflare/R2Error", { + message: Schema.String, + cause: Schema.Defect(), +}) {} export interface R2BucketToken { readonly Type: "Cloudflare.R2Bucket" diff --git a/lib/effect-cloudflare/src/rpc.ts b/lib/effect-cloudflare/src/rpc.ts index ad541b531..d5907f7c8 100644 --- a/lib/effect-cloudflare/src/rpc.ts +++ b/lib/effect-cloudflare/src/rpc.ts @@ -9,9 +9,9 @@ import type * as cf from "@cloudflare/workers-types" import * as Cause from "effect/Cause" -import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Option from "effect/Option" +import * as Schema from "effect/Schema" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" import * as Socket from "effect/unstable/socket/Socket" @@ -41,18 +41,22 @@ export type RpcStreamEnvelope = { body: ReadableStream } -export class RpcDecodeError extends Data.TaggedError("@maple/effect-cloudflare/RpcDecodeError")<{ - readonly cause: unknown -}> { +export class RpcDecodeError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcDecodeError", + { cause: Schema.Defect() }, +) { override get message() { return this.cause instanceof Error ? this.cause.message : String(this.cause) } } -export class RpcCallError extends Data.TaggedError("@maple/effect-cloudflare/RpcCallError")<{ - readonly method: string - readonly cause: unknown -}> { +export class RpcCallError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcCallError", + { + method: Schema.String, + cause: Schema.Defect(), + }, +) { override get message() { return `RPC call to "${this.method}" failed: ${ this.cause instanceof Error ? this.cause.message : String(this.cause) @@ -60,17 +64,18 @@ export class RpcCallError extends Data.TaggedError("@maple/effect-cloudflare/Rpc } } -class RpcRemoteError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteError")<{ - readonly error: unknown -}> { +class RpcRemoteError extends Schema.TaggedError()("@maple/effect-cloudflare/RpcRemoteError", { + error: Schema.Defect(), +}) { override get message() { return remoteErrorMessage(this.error, "Remote RPC call failed") } } -export class RpcRemoteStreamError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteStreamError")<{ - readonly error: unknown -}> { +export class RpcRemoteStreamError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcRemoteStreamError", + { error: Schema.Defect() }, +) { override get message() { return remoteErrorMessage(this.error, "Remote RPC stream failed") } diff --git a/lib/unitflow/src/db/index.ts b/lib/unitflow/src/db/index.ts index 3d2e28fbc..7c140fa81 100644 --- a/lib/unitflow/src/db/index.ts +++ b/lib/unitflow/src/db/index.ts @@ -22,7 +22,7 @@ import { type InitialQueryBuilder, type QueryBuilder, } from "@tanstack/db" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as Queue from "effect/Queue" @@ -38,10 +38,10 @@ import * as Store from "../core/store.js" * - `load-timeout` — the collection sat in `loading` with no emissions for the * configured `stuckTimeoutMs` (see {@link CollectionWatchOptions}). */ -export class CollectionError extends Data.TaggedError("@unitflow/db/CollectionError")<{ - readonly reason: "load-failed" | "cleaned-up" | "load-timeout" - readonly message: string -}> {} +export class CollectionError extends Schema.TaggedError()("@unitflow/db/CollectionError", { + reason: Schema.Literals(["load-failed", "cleaned-up", "load-timeout"]), + message: Schema.String, +}) {} /** The renderable state of a watched collection. */ export type CollectionState = AsyncResult.AsyncResult, CollectionError> diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 8a4de81f5..4fca3a689 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -20,7 +20,7 @@ import { UnauthorizedError, UserId, } from "@maple/domain/http" -import { Clock, Data, Effect, Option, Redacted, Schema, SchemaGetter } from "effect" +import { Clock, Effect, Option, Redacted, Schema, SchemaGetter } from "effect" /** * A self-hosted session is bounded by TWO clocks, because the HMAC key IS the @@ -769,10 +769,10 @@ export const makeResolveMcpTenant = ( type ClerkUser = Awaited["users"]["getUser"]>> -class ClerkLookupError extends Data.TaggedError("@maple/auth/ClerkLookupError")<{ - readonly operation: string - readonly cause: unknown -}> {} +class ClerkLookupError extends Schema.TaggedError()("@maple/auth/ClerkLookupError", { + operation: Schema.String, + cause: Schema.Defect(), +}) {} const clerkLookup = ( spanName: string, diff --git a/packages/browser/README.md b/packages/browser/README.md index 038adcaf4..8e9f584d1 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -35,7 +35,35 @@ That single call: only once a session is sampled in, so a `sampleRate` below 1 costs the unsampled visitors nothing beyond the base SDK (see [Bundle size](#bundle-size)); - writes session metadata at start (`active`) and on page hide (`ended`), - including the trace ids observed during the session. + including the trace ids observed during the session; +- captures uncaught errors and unhandled promise rejections as error spans, so + browser crashes reach Maple's error tracking. + +## Errors + +Every uncaught error and unhandled rejection becomes a span with status `Error` +and an `exception` event, which is the shape Maple fingerprints — so browser +crashes group beside your server-side errors instead of in a silo. + +Errors your app _catches_ never reach the global handlers, because catching them +is what stops them. Report those explicitly: + +```ts +try { + render() +} catch (error) { + MapleBrowser.captureException(error, { name: "browser.render_error" }) +} +``` + +Opt out of the global handlers with `tracing: { captureErrors: false }` — worth +doing only when another tracker already owns them, or the same crash is recorded +twice. + +A cross-origin script reports to the browser as a bare `"Script error."` with no +stack and no filename. Those are dropped rather than recorded: they all +fingerprint to one contentless issue that buries the real ones. Add +`crossorigin` to the script tag to get the real error instead. ## Bundle size diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 2d858a84e..cf3ff3530 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -41,6 +41,12 @@ export interface MapleBrowserConfig { * sink, and disabling this avoids redundant duplicate network spans. */ readonly instrumentFetch?: boolean + /** + * Capture uncaught errors and unhandled promise rejections as error + * spans. Default true. Turn off only when another tracker already owns + * the page's global error handlers, or the same crash lands twice. + */ + readonly captureErrors?: boolean } readonly replay?: { /** Default true. */ @@ -95,6 +101,7 @@ export interface ResolvedConfig { identity: ResolvedIdentity | undefined readonly tracingEnabled: boolean readonly tracingInstrumentFetch: boolean + readonly tracingCaptureErrors: boolean readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -131,6 +138,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { identity: resolveIdentity(config), tracingEnabled: config.tracing?.enabled ?? true, tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true, + tracingCaptureErrors: config.tracing?.captureErrors ?? true, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: config.replay?.sampleRate ?? 1, maskAllInputs: config.privacy?.maskAllInputs ?? true, diff --git a/packages/browser/src/errors.test.ts b/packages/browser/src/errors.test.ts new file mode 100644 index 000000000..6c63de945 --- /dev/null +++ b/packages/browser/src/errors.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { assert, beforeEach, describe, it } from "vitest" +import { SpanStatusCode, trace } from "@opentelemetry/api" +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { captureException, setupErrorCapture } from "./errors" + +const exporter = new InMemorySpanExporter() + +const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], +}) +trace.setGlobalTracerProvider(provider) + +const exceptionEventOf = (span: ReadableSpan) => span.events.find((event) => event.name === "exception") + +beforeEach(() => { + exporter.reset() +}) + +describe("captureException", () => { + it("records an Error span with an exception event", () => { + captureException(new TypeError("x is not a function")) + + const [span] = exporter.getFinishedSpans() + assert.strictEqual(span?.name, "exception") + assert.strictEqual(span?.status.code, SpanStatusCode.ERROR) + const event = exceptionEventOf(span!) + assert.strictEqual(event?.attributes?.["exception.type"], "TypeError") + assert.strictEqual(event?.attributes?.["exception.message"], "x is not a function") + }) + + it("normalizes a non-Error rejection reason into something groupable", () => { + // A rejected promise can carry anything. It still has to produce one + // fingerprintable issue rather than throwing inside the handler. + captureException({ message: "plain object failure" }) + captureException("string failure") + + const [fromObject, fromString] = exporter.getFinishedSpans() + assert.strictEqual( + exceptionEventOf(fromObject!)?.attributes?.["exception.message"], + "plain object failure", + ) + assert.strictEqual(exceptionEventOf(fromString!)?.attributes?.["exception.message"], "string failure") + }) + + it("carries a custom name and caller attributes", () => { + captureException(new Error("boom"), { + name: "browser.uncaught_error", + attributes: { "maple.exception.source": "window.onerror" }, + }) + + const [span] = exporter.getFinishedSpans() + assert.strictEqual(span?.name, "browser.uncaught_error") + assert.strictEqual(span?.attributes["maple.exception.source"], "window.onerror") + }) +}) + +describe("setupErrorCapture", () => { + it("captures an unhandled rejection once and stops on teardown", () => { + const stop = setupErrorCapture() + const reason = new Error("rejected") + + const dispatch = () => + window.dispatchEvent( + Object.assign(new Event("unhandledrejection"), { reason, promise: Promise.resolve() }), + ) + dispatch() + // The same error object reaching a handler twice is one issue, not two. + dispatch() + assert.strictEqual(exporter.getFinishedSpans().length, 1) + assert.strictEqual(exporter.getFinishedSpans()[0]?.name, "browser.unhandled_rejection") + + stop() + exporter.reset() + window.dispatchEvent( + Object.assign(new Event("unhandledrejection"), { + reason: new Error("after teardown"), + promise: Promise.resolve(), + }), + ) + assert.strictEqual(exporter.getFinishedSpans().length, 0) + }) + + it("drops an opaque cross-origin script error", () => { + const stop = setupErrorCapture() + // No error object, no filename — "Script error." carries nothing + // actionable and would fingerprint into one issue that buries the rest. + window.dispatchEvent(new ErrorEvent("error", { message: "Script error.", filename: "" })) + assert.strictEqual(exporter.getFinishedSpans().length, 0) + stop() + }) +}) diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts new file mode 100644 index 000000000..b25903b01 --- /dev/null +++ b/packages/browser/src/errors.ts @@ -0,0 +1,110 @@ +// Uncaught-error capture. +// +// Everything else this SDK exports traces something it was asked to trace: a +// fetch, a session, a custom event. An error thrown outside all of that — a +// framework render crash, a throw in an event handler, a floating rejected +// promise — had no path into Maple at all, which left the one signal a customer +// most wants from a browser SDK missing. +// +// Each error becomes a one-off span carrying an `exception` event and status +// Error. That is the shape `error_events_mv` fingerprints on, so these arrive in +// error tracking beside server-side errors rather than in a separate silo. +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api" +import { SDK_NAME, SDK_VERSION } from "./version" + +export interface CaptureExceptionOptions { + /** Span name. Default `"exception"`. */ + readonly name?: string | undefined + /** Extra span attributes. */ + readonly attributes?: Record | undefined +} + +const asError = (value: unknown): Error => { + if (value instanceof Error) return value + if (typeof value === "string") return new Error(value) + if (typeof value === "object" && value !== null) { + const message = (value as { readonly message?: unknown }).message + if (typeof message === "string") return new Error(message) + } + // A rejected promise can carry literally anything. `String` keeps a number or + // a boolean legible; an unrenderable object still produces one grouped issue + // rather than throwing inside the error handler. + try { + return new Error(String(value)) + } catch { + return new Error("Unknown error") + } +} + +/** + * Record an error that no span was watching. Safe before `init()` — without a + * registered provider the OTel API hands back a no-op tracer and this does + * nothing. + */ +export function captureException(error: unknown, options: CaptureExceptionOptions = {}): void { + const normalized = asError(error) + const span = trace.getTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", { + kind: SpanKind.INTERNAL, + attributes: { + ...(typeof location !== "undefined" ? { "url.full": location.href } : undefined), + ...options.attributes, + }, + }) + span.recordException(normalized) + span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) + span.end() +} + +/** + * Register global handlers for uncaught errors and unhandled rejections. + * Returns a teardown that removes them. + */ +export function setupErrorCapture(): () => void { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") { + return () => {} + } + + // One error must not become two issues. The same throw can reach both + // handlers (a rejected promise whose reason is later rethrown), and a host + // app's own boundary may report it through `captureException` as well. + const reported = new WeakSet() + const seen = (error: unknown): boolean => { + if (typeof error !== "object" || error === null) return false + if (reported.has(error)) return true + reported.add(error) + return false + } + + const onError = (event: ErrorEvent): void => { + // A cross-origin script surfaces as a bare "Script error." with no error + // object and no usable frames. It fingerprints to one meaningless issue + // that buries the real ones; the fix is `crossorigin` on the script tag, + // not a noisier error tracker. + const error: unknown = + event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) + if (error === undefined || seen(error)) return + captureException(error, { + name: "browser.uncaught_error", + attributes: { + "maple.exception.source": "window.onerror", + ...(event.filename ? { "code.filepath": event.filename } : undefined), + ...(event.lineno ? { "code.lineno": event.lineno } : undefined), + }, + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + if (seen(event.reason)) return + captureException(event.reason, { + name: "browser.unhandled_rejection", + attributes: { "maple.exception.source": "unhandledrejection" }, + }) + } + + window.addEventListener("error", onError) + window.addEventListener("unhandledrejection", onUnhandledRejection) + return () => { + window.removeEventListener("error", onError) + window.removeEventListener("unhandledrejection", onUnhandledRejection) + } +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 57748c851..0daf7b695 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,8 +1,10 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/browser-session" +import { captureException } from "./errors" import { identify, init, type MapleBrowserHandle } from "./init" export type { IdentifyInput, MapleIdentity, TrackProps, TraitValue } from "@maple/browser-session" export type { MapleBrowserConfig } from "./config" +export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" /** @@ -35,9 +37,18 @@ export const MapleBrowser: { * starts. */ track: (name: string, props?: TrackProps) => void + /** + * Report an error your app already caught — the case the global handlers + * cannot see, because catching it is what stops it reaching them. A + * framework error boundary is the canonical caller. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `captureException` narrows it before it reaches a span. + */ + captureException: (error: unknown, options?: import("./errors").CaptureExceptionOptions) => void /** Grant or revoke consent when `privacy.requireConsent` is on. */ setConsent: (granted: boolean) => void -} = { init, identify, track, setConsent } satisfies { +} = { init, identify, track, captureException, setConsent } satisfies { init: (config: import("./config").MapleBrowserConfig) => MapleBrowserHandle /** * Attach, replace, or clear the end-user identity on the active session. @@ -50,6 +61,15 @@ export const MapleBrowser: { * starts. */ track: (name: string, props?: TrackProps) => void + /** + * Report an error your app already caught — the case the global handlers + * cannot see, because catching it is what stops it reaching them. A + * framework error boundary is the canonical caller. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `captureException` narrows it before it reaches a span. + */ + captureException: (error: unknown, options?: import("./errors").CaptureExceptionOptions) => void /** Grant or revoke consent when `privacy.requireConsent` is on. */ setConsent: (granted: boolean) => void } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 371b33031..d0854ce7e 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -25,6 +25,7 @@ import { import type { ReplaySessionHandle } from "@maple/browser-session/replay" import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" +import { setupErrorCapture } from "./errors" import { setupTracing } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -74,6 +75,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { let stopped = false let rotateOnNextStart = false let shutdownTracing: (() => Promise) | undefined + let stopErrorCapture: (() => void) | undefined // Bumped by every start and stop, so a replay chunk that lands after a // consent revoke (or a rotation) never attaches a recorder to a dead runtime. let generation = 0 @@ -95,6 +97,12 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { session.id, ) if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config) + // After `setupTracing`: the handlers span through the global provider it + // registers, so registering them first would drop the errors of the very + // first moments into a no-op tracer. + if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) { + stopErrorCapture = setupErrorCapture() + } const shared = { endpoint: config.endpoint, ingestKey: config.ingestKey, @@ -191,6 +199,8 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopped = true stopConsentListener() await stopRuntime(true) + stopErrorCapture?.() + stopErrorCapture = undefined await shutdownTracing?.() shutdownTracing = undefined setActiveTraceIdProvider(() => undefined) diff --git a/packages/browser/src/tracing.test.ts b/packages/browser/src/tracing.test.ts index 2c731558d..e71edbd4a 100644 --- a/packages/browser/src/tracing.test.ts +++ b/packages/browser/src/tracing.test.ts @@ -34,6 +34,7 @@ const CONFIG = { identity: undefined, tracingEnabled: true, tracingInstrumentFetch: false, + tracingCaptureErrors: false, replayEnabled: false, replaySampleRate: 0, maskAllInputs: true, diff --git a/packages/effect-sdk/README.md b/packages/effect-sdk/README.md index 382552dd8..1439ba98b 100644 --- a/packages/effect-sdk/README.md +++ b/packages/effect-sdk/README.md @@ -106,6 +106,35 @@ const program = Effect.log("Hello!").pipe(Effect.withSpan("hello")) Effect.runPromise(program.pipe(Effect.provide(TracerLive))) ``` +### Uncaught errors (built in) + +`MapleFlush.make` from `/client` registers `error` and `unhandledrejection` +handlers, so a throw that never went through an Effect span still reaches error +tracking. Each one becomes a span with status `Error` and an `exception` event — +the same shape a failed Effect span produces, so browser crashes group beside +server-side errors rather than in a silo. + +Turn it off with `captureGlobalErrors: false` when another tracker already owns +the page's global handlers. + +An error your app _catches_ never reaches those handlers — catching it is what +stops it. Report those explicitly; a React error boundary is the usual caller, +and without this a boundary-caught crash is invisible in production: + +```typescript +const telemetry = MapleFlush.make({ serviceName: "my-frontend", ... }) + +class ErrorBoundary extends Component { + componentDidCatch(error: unknown, info: ErrorInfo) { + telemetry.captureException(error, { + name: "browser.react_error_boundary", + attributes: { "maple.react.component_stack": info.componentStack ?? "" }, + }) + } + // … +} +``` + ### Session replay & sessions (built in) The browser presets (`Maple.layer` and `MapleFlush.make`) record **rrweb session replays by default** — no separate browser SDK needed. Every span carries a `session.id`, the session appears in Maple's Sessions UI with its linked traces, and the recording is playable next to them. diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index 035a2136e..df994146e 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -17,7 +17,7 @@ import { } from "../shared/flush-core.js" import { type LogBuffer, makeLogBuffer } from "../shared/flushable-logger.js" import { makeMetricBuffer } from "../shared/flushable-metrics.js" -import { makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" +import { type CaptureExceptionOptions, makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" import { browserDocument, browserNavigator } from "./browser-globals.js" import { type ClientReplayConfig, startClientSession } from "./replay-loader.js" import { withSessionLink } from "./session-link.js" @@ -92,6 +92,16 @@ export interface MapleClientFlushableConfig { * sampleRate 1 and inputs masked — set `{ enabled: false }` to opt out. */ readonly replay?: ClientReplayConfig | undefined + /** + * Capture uncaught errors and unhandled promise rejections from the page and + * record them as error spans. Default `true`. + * + * Without this the SDK only ever sees failures that happened *inside* an + * Effect span, which in a browser is the minority of them — a React render + * crash, a throw in an event handler and a floating rejected promise all + * bypass Effect entirely and would otherwise never reach Maple. + */ + readonly captureGlobalErrors?: boolean | undefined /** * Consent gating, persistent-visitor-id storage, and whether `identify()`'s * email reaches the warehouse. Defaults capture everything except where a @@ -108,6 +118,16 @@ export interface FlushableTelemetry { * instrumented code. */ readonly layer: Layer.Layer + /** + * Record an error that never passed through an Effect span — the escape + * hatch for the places a browser throws outside Effect. The canonical caller + * is a React error boundary, which catches the error and, unless it reports + * it here, is the reason nobody ever hears about the crash. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. It is narrowed on the way into the exception event. + */ + readonly captureException: (error: unknown, options?: CaptureExceptionOptions) => void /** Drain the buffers and POST them now (keepalive). Never rejects. */ readonly flush: () => Promise /** Remove unload listeners, stop the auto-flush timer, then do one final flush. */ @@ -260,6 +280,60 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => ;(timer as { unref?: () => void }).unref?.() } + /** + * One error reaching two paths must still be one issue. React rethrows a + * boundary-caught error in development, so a boundary that reports it *and* + * `window.onerror` would otherwise fingerprint the same crash twice. + */ + const reported = new WeakSet() + const captureException = (error: unknown, options: CaptureExceptionOptions = {}): void => { + if (typeof error === "object" && error !== null) { + if (reported.has(error)) return + reported.add(error) + } + const page = globalThis.location?.href + spans.captureException(error, { + ...options, + attributes: { + ...(page !== undefined ? { "url.full": page } : undefined), + ...options.attributes, + }, + }) + } + + const onWindowError = (event: ErrorEvent): void => { + // A cross-origin script reports as a bare "Script error." with no error + // object, no usable frames and no filename. It fingerprints to a single + // meaningless issue that buries the real ones, so it is dropped rather + // than recorded — the fix for those is CORS on the script tag, not a + // louder error tracker. + const error: unknown = + event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) + if (error === undefined) return + captureException(error, { + name: "browser.uncaught_error", + attributes: { + "maple.exception.source": "window.onerror", + ...(event.filename ? { "code.filepath": event.filename } : undefined), + ...(event.lineno ? { "code.lineno": event.lineno } : undefined), + }, + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + captureException(event.reason, { + name: "browser.unhandled_rejection", + attributes: { "maple.exception.source": "unhandledrejection" }, + }) + } + + const canCaptureGlobals = + (config.captureGlobalErrors ?? true) && typeof globalThis.addEventListener === "function" + if (canCaptureGlobals) { + globalThis.addEventListener("error", onWindowError) + globalThis.addEventListener("unhandledrejection", onUnhandledRejection) + } + const onPageHide = (): void => { void flush() } @@ -281,10 +355,14 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => globalThis.removeEventListener("pagehide", onPageHide) globalThis.removeEventListener("visibilitychange", onVisibilityChange) } + if (canCaptureGlobals) { + globalThis.removeEventListener("error", onWindowError) + globalThis.removeEventListener("unhandledrejection", onUnhandledRejection) + } stopConsentListener() await flush() await clientSession.stop() } - return { layer, flush, dispose } + return { layer, captureException, flush, dispose } } diff --git a/packages/effect-sdk/src/shared/flushable-tracer.test.ts b/packages/effect-sdk/src/shared/flushable-tracer.test.ts index 6a098c00c..19ecf3523 100644 --- a/packages/effect-sdk/src/shared/flushable-tracer.test.ts +++ b/packages/effect-sdk/src/shared/flushable-tracer.test.ts @@ -185,3 +185,60 @@ describe("makeSpanBuffer restore", () => { }), ) }) + +const attributeOf = (span: { attributes: ReadonlyArray<{ key: string; value: unknown }> }, key: string) => + span.attributes.find((attribute) => attribute.key === key)?.value + +describe("makeSpanBuffer captureException", () => { + it("records a thrown error as an Error span with an exception event", () => { + const buffer = makeSpanBuffer() + buffer.captureException(new TypeError("Cannot read properties of undefined (reading 'spans')")) + + const [span] = buffer.drain() + assert.isDefined(span) + assert.strictEqual(span?.name, "exception") + // StatusCode 2 is Error — `error_events_mv` keys off exactly this. + assert.strictEqual(span?.status.code, 2) + + const event = span?.events.find((candidate) => candidate.name === "exception") + assert.isDefined(event) + const attribute = (key: string) => event?.attributes.find((candidate) => candidate.key === key)?.value + assert.deepStrictEqual(attribute("exception.type"), { stringValue: "TypeError" }) + assert.deepStrictEqual(attribute("exception.message"), { + stringValue: "Cannot read properties of undefined (reading 'spans')", + }) + }) + + it("carries caller attributes and a custom span name", () => { + const buffer = makeSpanBuffer() + buffer.captureException(new Error("boom"), { + name: "browser.uncaught_error", + attributes: { "maple.exception.source": "window.onerror" }, + }) + + const [span] = buffer.drain() + assert.strictEqual(span?.name, "browser.uncaught_error") + assert.deepStrictEqual(attributeOf(span!, "maple.exception.source"), { + stringValue: "window.onerror", + }) + }) + + it("is not silenceable through anticipatedErrorIdentifiers", () => { + // An uncaught throw is never an anticipated 4xx. Recording it as a defect + // keeps it clear of that filter, so a caller cannot accidentally suppress + // real crashes by listing a tag. + const buffer = makeSpanBuffer({ anticipatedErrorIdentifiers: new Set(["Error", "TypeError"]) }) + buffer.captureException(new TypeError("still an error")) + + const [span] = buffer.drain() + assert.strictEqual(span?.status.code, 2) + assert.isDefined(span?.events.find((candidate) => candidate.name === "exception")) + }) + + it("stays silent while capture is disabled by consent", () => { + const buffer = makeSpanBuffer() + buffer.setDisabled(true) + buffer.captureException(new Error("boom")) + assert.strictEqual(buffer.size(), 0) + }) +}) diff --git a/packages/effect-sdk/src/shared/flushable-tracer.ts b/packages/effect-sdk/src/shared/flushable-tracer.ts index 89d393df3..cab1d594f 100644 --- a/packages/effect-sdk/src/shared/flushable-tracer.ts +++ b/packages/effect-sdk/src/shared/flushable-tracer.ts @@ -4,13 +4,40 @@ // resource, and headers are NOT baked in here — the caller (the Cloudflare, // server, or client flushable preset) resolves them and POSTs the drained // buffer on `flush`, so the layer itself can be constructed without I/O. -import { Cause, type Context, Layer, type Option, Predicate, Tracer } from "effect" +import { Cause, Context, Exit, Layer, Option, Predicate, Tracer } from "effect" import * as ErrorReporter from "effect/ErrorReporter" import * as OtlpResource from "effect/unstable/observability/OtlpResource" import type { ExtractTag } from "effect/Types" +export interface CaptureExceptionOptions { + /** Span name. Default `"exception"`. */ + readonly name?: string | undefined + /** Extra span attributes (e.g. the URL the error happened on). */ + readonly attributes?: Record | undefined +} + export interface SpanBuffer { readonly tracerLayer: Layer.Layer + /** + * Record a thrown value that never passed through an Effect span. + * + * Everything else in this buffer arrives because an Effect *span* failed — + * which means an error thrown outside Effect had no path here at all. In a + * browser that is most of them: a React render crash caught by an error + * boundary, a throw in an event handler, a rejected promise nobody awaited. + * + * The error is recorded as a one-off span carrying a `Die` cause, so it takes + * the same road as every other failure: `makeOtlpSpan` gives it status + * `Error` and an `exception` event with type/message/stacktrace, which is + * exactly the shape `error_events_mv` fingerprints on. A `Die` (not a `Fail`) + * because an uncaught throw is by definition not an anticipated failure — + * that also keeps it clear of the `anticipatedErrorIdentifiers` filter, which + * would otherwise let a caller silence real crashes by tag. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `Cause.prettyErrors` narrows it on the way into the event. + */ + readonly captureException: (error: unknown, options?: CaptureExceptionOptions) => void readonly drain: () => Array readonly restore: (items: ReadonlyArray) => void readonly setDisabled: (value: boolean) => void @@ -89,8 +116,26 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => { }, }) + const captureException = (error: unknown, captureOptions: CaptureExceptionOptions = {}): void => { + if (disabled) return + const now = BigInt(Date.now()) * 1_000_000n + const span = makeSpan({ + name: captureOptions.name ?? "exception", + parent: Option.none(), + annotations: Context.empty(), + status: { _tag: "Started", startTime: now }, + attributes: new Map(Object.entries(captureOptions.attributes ?? {})), + links: [], + sampled: true, + kind: "internal", + export: exportFn, + }) + span.end(now, Exit.failCause(Cause.die(error))) + } + return { tracerLayer: Layer.succeed(Tracer.Tracer, tracer), + captureException, drain: () => { const items = buffer buffer = []