Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/alerting/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ServiceMapRollupService,
TinybirdOrgTokenService,
WarehouseQueryService,
summarizeCause,
withPgConnectionScope,
} from "@maple/api/alerting"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
Expand Down Expand Up @@ -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,
}),
),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/alerting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/chat/turn-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -190,7 +191,7 @@ const compactIfNeeded = (
Effect.annotateLogs({
sessionId: input.sessionId,
messageId: input.messageId,
cause: Cause.pretty(cause),
cause: summarizeCause(cause),
}),
),
),
Expand Down Expand Up @@ -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),
}),
),
),
Expand Down
15 changes: 9 additions & 6 deletions apps/api/src/http/server-error-span.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<Http5xxResponseError>()(
"@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})`
}
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/mcp/lib/inspect-widget.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -765,7 +766,7 @@ export const inspectWidget = Effect.fn("inspectWidget")(
Effect.catchCause((cause) =>
Effect.succeed<InspectionOutcome>({
kind: "inspection_error",
message: Cause.pretty(cause),
message: summarizeCause(cause),
}),
),
)
Expand Down
21 changes: 13 additions & 8 deletions apps/api/src/mcp/tools/llm-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): 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)
}

/**
Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/platform/DatabaseLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatabaseError>()("@maple/api/lib/DatabaseError", {
message: Schema.String,
cause: Schema.Unknown,
cause: Schema.Defect({ excludeCause: true }),
}) {}

export interface DatabaseApi {
Expand Down
9 changes: 5 additions & 4 deletions apps/api/src/platform/EmailService.ts
Original file line number Diff line number Diff line change
@@ -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<EmailDeliveryError>()(
"@maple/api/platform/EmailDeliveryError",
{ message: Schema.String },
) {}

export interface EmailServiceApi {
readonly isConfigured: boolean
Expand Down
9 changes: 5 additions & 4 deletions apps/api/src/platform/Env.ts
Original file line number Diff line number Diff line change
@@ -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<EnvValidationError>()(
"@maple/api/lib/EnvValidationError",
{ message: Schema.String },
) {}

export interface EnvConfig {
readonly PORT: number
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/platform/describe-cause.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
36 changes: 36 additions & 0 deletions apps/api/src/platform/describe-cause.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<unknown>): string => {
const errors = Cause.prettyErrors(cause)
if (errors.length === 0) return "empty cause"
return cap(errors.map((error) => `${error.name}: ${error.message}`).join("; "))
}
3 changes: 2 additions & 1 deletion apps/api/src/routes/internal/chat.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
}),
),
),
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/routes/v1/integrations.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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),
}),
),
),
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/services/alerts/AlertsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -3355,7 +3355,7 @@ export class AlertsService extends Context.Service<AlertsService, AlertsServiceA
Effect.annotateLogs({
orgId,
rowCount: checks.length,
cause: Cause.pretty(cause),
cause: summarizeCause(cause),
}),
),
),
Expand Down
Loading
Loading