Skip to content

Commit c28e710

Browse files
authored
Merge pull request #282 from RhysSullivan/rs/typed-error-edge-model
feat(sdk): typed errors propagate to one edge mapping (StorageError, InternalError, Telemetry)
2 parents c64a159 + 4d95b13 commit c28e710

75 files changed

Lines changed: 2357 additions & 1017 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/cloud/src/api/protected-layers.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform";
77
import { Layer } from "effect";
88

9-
import { CoreExecutorApi } from "@executor/api";
9+
import {
10+
CoreExecutorApi,
11+
InternalError,
12+
observabilityMiddleware,
13+
} from "@executor/api";
1014
import { CoreHandlers } from "@executor/api/server";
1115
import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
1216
import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
@@ -18,12 +22,16 @@ import { UserStoreService } from "../auth/context";
1822
import { WorkOSAuth } from "../auth/workos";
1923
import { AutumnService } from "../services/autumn";
2024
import { DbService } from "../services/db";
25+
import { ErrorCaptureLive } from "../observability";
2126

2227
export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
2328
.add(McpGroup)
2429
.add(GraphqlGroup)
30+
.addError(InternalError)
2531
.middleware(OrgAuth);
2632

33+
const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
34+
2735
const DbLive = DbService.Live;
2836
const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
2937

@@ -47,6 +55,13 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll(
4755
GraphqlHandlers,
4856
);
4957

58+
// `ErrorCaptureLive` is provided above the handler + middleware layers
59+
// so the `withCapture` translation path (typed-channel `StorageError →
60+
// InternalError(traceId)`) AND the observability middleware's defect
61+
// catchall both see the same Sentry-backed implementation.
5062
export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
51-
Layer.provide(Layer.merge(ProtectedCloudApiHandlers, OrgAuthLive)),
63+
Layer.provide(
64+
Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive),
65+
),
66+
Layer.provide(ErrorCaptureLive),
5267
);

‎apps/cloud/src/api/protected.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const createProtectedApp = (organizationId: string, organizationName: string) =>
3434
Effect.gen(function* () {
3535
const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
3636

37+
// Handlers wrap their own bodies with `capture(...)` — the edge
38+
// translation lives per-handler, not at service construction. See
39+
// notes/error-handling.md.
3740
const requestServices = Layer.mergeAll(
3841
Layer.succeed(ExecutorService, executor),
3942
Layer.succeed(ExecutionEngineService, engine),

‎apps/cloud/src/observability.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// ---------------------------------------------------------------------------
2+
// Cloud-app implementation of the shared `ErrorCapture` service. This is the
3+
// only file in the cloud-app that imports `@sentry/cloudflare` for error
4+
// capture — handlers, plugin SDKs, and storage code all stay
5+
// Sentry-agnostic and request the `ErrorCapture` tag instead.
6+
//
7+
// `withObservability` (in @executor/api) wraps every handler effect; when
8+
// it sees an unmapped cause it asks `ErrorCapture.captureException` for a
9+
// trace id and fails with `InternalError({ traceId })`. The client gets
10+
// the opaque id, we get the full cause + stack in Sentry.
11+
// ---------------------------------------------------------------------------
12+
13+
import * as Sentry from "@sentry/cloudflare";
14+
import { Cause, Effect, Layer } from "effect";
15+
16+
import { ErrorCapture } from "@executor/api";
17+
18+
export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
19+
ErrorCapture,
20+
ErrorCapture.of({
21+
captureException: (cause) =>
22+
Effect.sync(() => {
23+
const squashed = Cause.squash(cause);
24+
const eventId = Sentry.captureException(squashed, (scope) => {
25+
scope.setExtra("cause", Cause.pretty(cause));
26+
return scope;
27+
});
28+
return eventId ?? "";
29+
}),
30+
}),
31+
);

‎apps/cloud/src/services/executor.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,9 @@ export const createScopedExecutor = (
7878
createdAt: new Date(),
7979
});
8080

81+
// The executor surface returns raw `StorageFailure`; translation to
82+
// the opaque `InternalError({ traceId })` happens at the HTTP edge
83+
// via `withCapture` (see `api/protected-layers.ts`). That's
84+
// where `ErrorCaptureLive` (Sentry) gets wired in.
8185
return yield* createExecutor({ scope, adapter, blobs, plugins });
8286
});

‎apps/local/src/server/main.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "@effect/platform";
88
import { Context, Effect, Layer, ManagedRuntime } from "effect";
99

10-
import { addGroup } from "@executor/api";
10+
import { addGroup, observabilityMiddleware } from "@executor/api";
1111
import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
1212
import { createExecutionEngine } from "@executor/execution";
1313
import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
@@ -34,6 +34,7 @@ import {
3434
} from "@executor/plugin-graphql/api";
3535
import { getExecutor } from "./executor";
3636
import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
37+
import { ErrorCaptureLive } from "./observability";
3738

3839
// ---------------------------------------------------------------------------
3940
// Local server API — core + all plugin groups
@@ -45,6 +46,13 @@ const LocalApi = addGroup(OpenApiGroup)
4546
.add(OnePasswordGroup)
4647
.add(GraphqlGroup);
4748

49+
// `ErrorCaptureLive` logs causes to the console and returns a short
50+
// correlation id. Provided above the handler + middleware layers so
51+
// both the `withCapture` typed-channel translation AND the
52+
// `observabilityMiddleware` defect catchall see the same
53+
// implementation.
54+
const LocalObservability = observabilityMiddleware(LocalApi);
55+
4856
const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe(
4957
Layer.provide(CoreHandlers),
5058
Layer.provide(
@@ -56,6 +64,8 @@ const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe(
5664
GraphqlHandlers,
5765
),
5866
),
67+
Layer.provide(LocalObservability),
68+
Layer.provide(ErrorCaptureLive),
5969
);
6070

6171
// ---------------------------------------------------------------------------
@@ -81,6 +91,8 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => {
8191
const executor = await getExecutor();
8292
const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
8393

94+
// Handlers wrap their own bodies with `capture(...)` — the edge
95+
// translation lives per-handler, not at service construction.
8496
const pluginExtensions = Layer.mergeAll(
8597
Layer.succeed(OpenApiExtensionService, executor.openapi),
8698
Layer.succeed(McpExtensionService, executor.mcp),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// ---------------------------------------------------------------------------
2+
// Local-app `ErrorCapture` — console implementation.
3+
//
4+
// Unlike the cloud app (Sentry-backed), the CLI just prints the squashed
5+
// cause + pretty-printed structured cause to stderr and returns a short
6+
// correlation id. Operators can grep for the id in their terminal
7+
// scrollback when a user reports an opaque 500 traceId.
8+
// ---------------------------------------------------------------------------
9+
10+
import { Cause, Effect, Layer } from "effect";
11+
12+
import { ErrorCapture } from "@executor/api";
13+
14+
const nextTraceId = () =>
15+
`local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
16+
17+
export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
18+
ErrorCapture,
19+
ErrorCapture.of({
20+
captureException: (cause) =>
21+
Effect.sync(() => {
22+
const traceId = nextTraceId();
23+
const squashed = Cause.squash(cause);
24+
console.error(
25+
`[executor ${traceId}]`,
26+
squashed instanceof Error ? squashed.stack ?? squashed : squashed,
27+
);
28+
console.error(`[executor ${traceId}] cause:`, Cause.pretty(cause));
29+
return traceId;
30+
}),
31+
}),
32+
);

‎bun.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎notes/error-handling.md‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Error handling — model + plumbing
2+
3+
## Principle
4+
5+
Errors are typed values that propagate through the call graph carrying
6+
their full causal structure. **Exactly one layer at the edge** consumes
7+
them and maps them to a public response. Plugin code never imports
8+
Sentry, never calls `captureException`, never wraps handlers with
9+
`sanitize*` helpers.
10+
11+
Two error categories:
12+
13+
- **Surfaceable**: typed Schema errors with a user-actionable message
14+
(`McpOAuthError`, `OpenApiParseError`, …). Carry through to the
15+
response with their own status (4xx) and message body. Not captured
16+
to any external sink — they're normal business outcomes.
17+
- **Internal**: truly unexpected (storage failure, third-party returned
18+
garbage, sync throw inside a handler). Captured via `ErrorCapture`
19+
**at the HTTP edge only**, then propagated as the shared
20+
`InternalError({ traceId })` schema — opaque to clients, fully
21+
detailed in Sentry. One shape, one trace id, one place to look up
22+
the cause.
23+
24+
## Layering
25+
26+
```
27+
storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host
28+
StorageError StorageError InternalError ErrorCaptureLive
29+
(raw) (raw) (captured) (Sentry)
30+
```
31+
32+
The SDK stays storage-typed. The HTTP edge (`@executor/api`) is the
33+
**only** layer that translates `StorageError → InternalError` and
34+
captures the cause to telemetry. Non-HTTP consumers (CLI, Promise
35+
SDK, tests) see raw `StorageError` in the typed channel and can react
36+
however they want.
37+
38+
## Plumbing
39+
40+
### Storage layer (`@executor/storage-core`)
41+
42+
Emits two `Data.TaggedError` classes and nothing else observability-related:
43+
44+
- `StorageError({ message, cause })` — the catch-all for non-recoverable
45+
backend failures. The `cause` travels as runtime data so the HTTP
46+
edge can capture it, but it's never serialised to the wire.
47+
- `UniqueViolationError({ model? })` — typed 4xx-shaped failure plugins
48+
want to react to (e.g. "source already exists").
49+
50+
Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically
51+
can't `addError(...)` them on an HttpApi group, which enforces "these
52+
are internal types, not wire shapes".
53+
54+
`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare
55+
`Effect<X, StorageFailure>` where `StorageFailure = StorageError |
56+
UniqueViolationError`. No `Error`, no telemetry service in R.
57+
58+
Storage-core has zero observability awareness — it just emits typed
59+
values and lets consumers decide what to do with them.
60+
61+
### SDK (`@executor/sdk`)
62+
63+
The SDK is entirely observability-free.
64+
65+
- `createExecutor` requires no observability service. `R = never`.
66+
- `PluginCtx.storage`, `ctx.core.*`, `ctx.secrets.*`, `ctx.transaction`
67+
all surface raw `StorageFailure` in the typed error channel. Plugins
68+
can `Effect.catchTag("UniqueViolationError", …)` and translate to
69+
their own user-facing errors.
70+
- Executor public methods (`executor.tools.list()`,
71+
`executor.sources.refresh()`, etc.) also surface raw `StorageFailure`.
72+
73+
No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag
74+
inside the SDK. The value proposition: an SDK consumer can write a CLI,
75+
a script, a promise-based wrapper, whatever — and the typed channel
76+
shows them exactly what can go wrong.
77+
78+
### HTTP edge (`@executor/api/observability`)
79+
80+
Owns the translation, the opaque wire schema, and the capture service.
81+
82+
- `InternalError({ traceId })` — the public opaque 500 schema, with an
83+
`HttpApiSchema.annotations({ status: 500 })` annotation so the
84+
framework encodes it correctly.
85+
- `ErrorCapture` — tagged Effect service for recording unexpected
86+
causes. Shape:
87+
88+
```ts
89+
interface ErrorCaptureShape {
90+
readonly captureException: (cause: Cause<unknown>) => Effect<string>
91+
}
92+
```
93+
94+
Optional — resolved via `Effect.serviceOption`; missing service =
95+
empty trace ids. Nothing breaks if it's not wired.
96+
97+
- `capture(eff)` — the single translator. Catches `StorageError` on
98+
the typed channel, captures the cause via `ErrorCapture`, fails with
99+
`InternalError({ traceId })`. Catches `UniqueViolationError` and
100+
dies (plugins that want to surface it as a typed domain error should
101+
`Effect.catchTag` inside their own method first). Every other typed
102+
failure passes through.
103+
104+
Every handler wraps its generator body with `capture(...)`:
105+
106+
```ts
107+
.handle("probeEndpoint", ({ payload }) =>
108+
capture(Effect.gen(function* () {
109+
const ext = yield* McpExtensionService;
110+
return yield* ext.probeEndpoint(payload.endpoint);
111+
})),
112+
)
113+
```
114+
115+
One line at the top of each handler. No service-level proxy, no
116+
`Captured<T>` type gymnastics — the translation is visible right
117+
where it happens, and TypeScript rejects handlers that forget
118+
(because `StorageError` isn't in the group's `.addError` list).
119+
120+
- `observabilityMiddleware(Api)` — defect safety net. An
121+
`HttpApiBuilder.middleware` layer that wraps the HttpApp once and
122+
catches any cause that escaped the typed channel (defects,
123+
interrupts, framework bugs) via `ErrorCapture`, returning a typed
124+
`InternalError({ traceId })`. Should rarely fire when the rest of
125+
the pipeline is well-typed.
126+
127+
### Plugin SDK
128+
129+
Plugin authors write normal Effect code. Their extension method error
130+
unions look like:
131+
132+
```ts
133+
Effect.Effect<X, MyPluginTypedError | StorageError, never>
134+
```
135+
136+
Where `MyPluginTypedError` is the union of their own
137+
`Schema.TaggedError` classes (with `HttpApiSchema.annotations({ status: 4xx })`).
138+
`StorageError` is the raw storage tag — it bubbles up, and the HTTP
139+
edge translates it.
140+
141+
Plugins never provide `ErrorCapture`, never import Sentry, never see
142+
`InternalError` in their typed channel.
143+
144+
### API groups
145+
146+
Each group declares its typed errors once at the group level:
147+
148+
```ts
149+
class McpGroup extends HttpApiGroup.make("mcp")
150+
.add(endpoint1)
151+
.add(endpoint2)
152+
// …
153+
.addError(InternalError)
154+
.addError(McpOAuthError)
155+
.addError(McpConnectionError)
156+
// …
157+
{}
158+
```
159+
160+
No per-endpoint `addError`. The framework encodes each tagged error by
161+
its annotated status.
162+
163+
### Hosts
164+
165+
- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
166+
`ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
167+
layer in `protected-layers.ts` so it's available to both
168+
`observabilityMiddleware` (defect catchall) AND the per-handler
169+
`capture(...)` translation. Service tags (`ExecutorService`,
170+
`McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app
171+
just does `Layer.succeed(McpExtensionService, executor.mcp)` —
172+
handlers do the translation themselves. (Distinct from
173+
`apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span
174+
bridge — "telemetry" in the tracing sense.)
175+
- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a
176+
console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`)
177+
that prints the squashed cause + pretty cause to stderr and returns a
178+
short correlation id.
179+
- **Tests / Promise SDK / examples** — non-HTTP consumers see raw
180+
`StorageError` / `StorageFailure` in the SDK's typed channel and
181+
can match on it directly.
182+
183+
### Anti-patterns
184+
185+
- `Effect.orDie` at handler boundaries — silently turns recoverable
186+
failures into 500s with no telemetry (defects bypass typed-channel
187+
encoding).
188+
- Per-plugin `*InternalError` types — clients can't tell which plugin
189+
emitted a 500 anyway. Use the shared `InternalError`.
190+
- `sanitize*` helpers in handler files that `catchAllCause` + map to a
191+
generic 500 — same swallowing problem in disguise. Prefer wrapping
192+
the handler's `Effect.gen` body with `capture(...)`.
193+
- SDK code importing `Sentry.captureException` or referencing
194+
`InternalError` / `ErrorCapture` — translation lives strictly in
195+
`@executor/api`. If the SDK imports it, the layering is wrong.

0 commit comments

Comments
 (0)