History

refactor(cloud): fold execution usage tracking into AutumnService

`makeTrackExecutionUsage` lived in its own `services/autumn-tracker.ts` only to dodge the `api/layers.ts` → `@tanstack/react-start` import cycle that breaks vitest-pool-workers. It baked fire-and-forget semantics + error logging into the function shape — callers got back a `(id) => void` and couldn't compose or test the billing call. Moves the tracker onto the service itself as `AutumnService.trackExecution` (Effect shape, error channel `never`), matching the existing `use` method pattern. Callers now `Effect.runFork(autumn.trackExecution(id))` at the single boundary in `execution-stack.ts`, which is honest about the fire-and-forget choice happening at that layer — not in the service. - `services/autumn.ts`: adds `trackExecution` to `IAutumnService`. Errors are caught and logged; the returned Effect never fails. Wrapped in an `autumn.trackExecution` span so traces pick it up the same way the existing `use` calls do. - `services/execution-stack.ts`: inlines the `Effect.runFork` at the sole caller; drops the `autumn-tracker` import. - `api/autumn.ts`: drops the re-export that pointed at the old file. - Deletes `services/autumn-tracker.ts`.

Rhys Sullivan committed 6 months ago

Commit af8a33a1 parent a6b58b74 files · +27 −49Browse files

Unified · Split

4 files
  1. apps/cloud/src/api/autumn.ts +0 −6
  2. apps/cloud/src/services/autumn-tracker.ts +0 −38
  3. apps/cloud/src/services/autumn.ts +26 −3
  4. apps/cloud/src/services/execution-stack.ts +1 −2
apps/cloud/src/api/autumn.ts+0−6
@@ -8,12 +8,6 @@ import { AutumnService } from "../services/autumn"; · 7 unmodified lines
8import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";8import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
9import { SharedServices } from "./layers";9import { SharedServices } from "./layers";
10 10
11// Re-export so existing callers don't have to change their import path.
12// The actual implementation moved to `services/autumn-tracker.ts` to break
13// a DO → api/* → tanstack-start import cycle that crashed module load
14// under vitest-pool-workers.
15export { makeTrackExecutionUsage } from "../services/autumn-tracker";
16
17const handleAutumnRequestEffect = Effect.gen(function* () {11const handleAutumnRequestEffect = Effect.gen(function* () {
18 const request = yield* HttpServerRequest.HttpServerRequest;12 const request = yield* HttpServerRequest.HttpServerRequest;
19 const webRequest = yield* Effect.mapError(13 const webRequest = yield* Effect.mapError(
apps/cloud/src/services/autumn-tracker.ts+0−38
@@ -1,38 +0,0 @@
1// ---------------------------------------------------------------------------
2// Fire-and-forget execution usage tracker against Autumn.
3// ---------------------------------------------------------------------------
4//
5// Lives in `services/` rather than `api/` because callers on the Durable
6// Object path (`mcp-session.ts` → `execution-stack.ts`) transitively pull
7// it in, and anything under `api/` eventually re-exports from
8// `api/layers.ts`, which imports `auth/handlers.ts` → `@tanstack/react-start`
9// — that pulls the `#tanstack-start-plugin-adapters` subpath specifier
10// into Vite's resolver and breaks vitest-pool-workers module load.
11//
12// This file has exactly one responsibility, no HTTP concerns, and imports
13// nothing from `api/`.
14// ---------------------------------------------------------------------------
15
16import { Effect } from "effect";
17
18import type { AutumnService } from "./autumn";
19
20export const makeTrackExecutionUsage = (autumn: AutumnService["Type"]) => {
21 return (organizationId: string): void => {
22 autumn
23 .use((client) =>
24 client.track({
25 customerId: organizationId,
26 featureId: "executions",
27 value: 1,
28 }),
29 )
30 .pipe(
31 Effect.catchAll((err) => {
32 console.error("[billing] track failed:", err);
33 return Effect.void;
34 }),
35 Effect.runFork,
36 );
37 };
38};
apps/cloud/src/services/autumn.ts+26−3
@@ -21,6 +21,13 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 20 unmodified lines
21 21
22export type IAutumnService = Readonly<{22export type IAutumnService = Readonly<{
23 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;23 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;
24 /**
25 * Fire-and-forget-safe execution usage tracker. Errors are caught and
26 * logged; the returned Effect never fails. Callers typically
27 * `Effect.runFork` it at the boundary so the billing call can't stall a
28 * user-facing request.
29 */
30 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
24}>;31}>;
25 32
26// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
@@ -31,9 +38,13 @@ const make = Effect.sync(() => { · 4 unmodified lines
31 const secretKey = server.AUTUMN_SECRET_KEY;38 const secretKey = server.AUTUMN_SECRET_KEY;
32 39
33 if (!secretKey) {40 if (!secretKey) {
41 const notConfigured = Effect.die(
42 new Error("Autumn not configured — AUTUMN_SECRET_KEY is empty"),
43 );
34 return {44 return {
35 use: () => Effect.die(new Error("Autumn not configured — AUTUMN_SECRET_KEY is empty")),45 use: () => notConfigured,
36 } as IAutumnService;46 trackExecution: () => Effect.void,
47 } satisfies IAutumnService;
37 }48 }
38 49
39 const client = new Autumn({ secretKey });50 const client = new Autumn({ secretKey });
@@ -44,7 +55,19 @@ const make = Effect.sync(() => { · 4 unmodified lines
44 catch: (cause) => new AutumnError({ cause }),55 catch: (cause) => new AutumnError({ cause }),
45 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));56 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));
46 57
47 return { use } satisfies IAutumnService;58 const trackExecution = (organizationId: string) =>
59 use((c) =>
60 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
61 ).pipe(
62 Effect.asVoid,
63 Effect.tapError((err) =>
64 Effect.sync(() => console.error("[billing] track failed:", err)),
65 ),
66 Effect.orElse(() => Effect.void),
67 Effect.withSpan("autumn.trackExecution"),
68 );
69
70 return { use, trackExecution } satisfies IAutumnService;
48});71});
49 72
50export class AutumnService extends Context.Tag("@executor/cloud/AutumnService")<73export class AutumnService extends Context.Tag("@executor/cloud/AutumnService")<
apps/cloud/src/services/execution-stack.ts+1−2
@@ -12,7 +12,6 @@ import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; · 11 unmodified lines
12 12
13import { withExecutionUsageTracking } from "../api/execution-usage";13import { withExecutionUsageTracking } from "../api/execution-usage";
14import { AutumnService } from "./autumn";14import { AutumnService } from "./autumn";
15import { makeTrackExecutionUsage } from "./autumn-tracker";
16import { createScopedExecutor } from "./executor";15import { createScopedExecutor } from "./executor";
17 16
18export const makeExecutionStack = (organizationId: string, organizationName: string) =>17export const makeExecutionStack = (organizationId: string, organizationName: string) =>
@@ -23,7 +22,7 @@ export const makeExecutionStack = (organizationId: string, organizationName: str · 4 unmodified lines
23 const engine = withExecutionUsageTracking(22 const engine = withExecutionUsageTracking(
24 organizationId,23 organizationId,
25 createExecutionEngine({ executor, codeExecutor }),24 createExecutionEngine({ executor, codeExecutor }),
26 makeTrackExecutionUsage(autumn),25 (orgId) => Effect.runFork(autumn.trackExecution(orgId)),
27 );26 );
28 return { executor, engine };27 return { executor, engine };
29 });28 });