History

Provision Autumn customers for orgs and self-heal customer_not_found

Rhys Sullivan committed 2 months ago

Commit 8f3317b1 parent 4c9e7555 files · +370 −18Browse files

Unified · Split

5 files
  1. apps/cloud/src/account/org-api-key-revoke.node.test.ts +1 −0
  2. apps/cloud/src/auth/handlers.ts +25 −2
  3. apps/cloud/src/extensions/billing/service.ts +95 −10
  4. e2e/cloud/billing-customer-provisioning.test.ts +205 −0
  5. e2e/src/surfaces/autumn.ts +44 −6
apps/cloud/src/account/org-api-key-revoke.node.test.ts+1−0
@@ -108,6 +108,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 107 unmodified lines
108 108
109const stubAutumn = Layer.succeed(AutumnService)({109const stubAutumn = Layer.succeed(AutumnService)({
110 use: () => Effect.die("revoke does not touch billing"),110 use: () => Effect.die("revoke does not touch billing"),
111 ensureCustomer: () => Effect.die("revoke does not touch billing"),
111 checkExecutionBalance: () => Effect.die("revoke does not touch billing"),112 checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
112 trackExecution: () => Effect.void,113 trackExecution: () => Effect.void,
113});114});
apps/cloud/src/auth/handlers.ts+25−2
@@ -22,6 +22,7 @@ import { env } from "cloudflare:workers"; · 21 unmodified lines
22import { WorkOSError } from "./errors";22import { WorkOSError } from "./errors";
23import { WorkOSClient } from "./workos";23import { WorkOSClient } from "./workos";
24import { AutumnService } from "../extensions/billing/service";24import { AutumnService } from "../extensions/billing/service";
25import { captureCauseEffect } from "../observability";
25import {26import {
26 hasPaidOrganizationSubscription,27 hasPaidOrganizationSubscription,
27 isOverFreeOrganizationLimit,28 isOverFreeOrganizationLimit,
@@ -415,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 387 unmodified lines
415 ),416 ),
416 { concurrency: 3 },417 { concurrency: 3 },
417 ).pipe(418 ).pipe(
418 Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),419 // Any Autumn failure here (outage or missing customer) leaves the
420 // paid/free split unknown, and the limit must fail closed.
421 Effect.mapError(() => new WorkOSError()),
419 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),422 Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
420 );423 );
421 424
@@ -431,6 +434,24 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines
431 s.upsertOrganization({ id: org.id, name: org.name }),434 s.upsertOrganization({ id: org.id, name: org.name }),
432 );435 );
433 436
437 // Provision the org's billing customer while we're the ones creating
438 // the org. Without this the first billing call an org ever makes is a
439 // non-creating one (balance check / usage track), which 404s and keeps
440 // 404ing — unlimited unbilled executions. Non-fatal: a billing blip
441 // must not block signup, and the billing seam heals a customer that
442 // is still missing later.
443 yield* autumn.ensureCustomer(org.id).pipe(
444 Effect.catch((error) =>
445 Effect.gen(function* () {
446 yield* Effect.logWarning(
447 "createOrganization: could not provision the Autumn customer",
448 { organizationId: org.id, error },
449 );
450 yield* captureCauseEffect(error);
451 }),
452 ),
453 );
454
434 // Try to attach the new org to the current session. This can fail455 // Try to attach the new org to the current session. This can fail
435 // (or silently return a session still scoped to the old org) when456 // (or silently return a session still scoped to the old org) when
436 // the caller's current session is stale — most commonly after the457 // the caller's current session is stale — most commonly after the
@@ -515,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 78 unmodified lines
515 yield* autumn536 yield* autumn
516 .use((client) => client.customers.delete({ customerId: organizationId }))537 .use((client) => client.customers.delete({ customerId: organizationId }))
517 .pipe(538 .pipe(
518 Effect.catchTag("AutumnError", (error) =>539 // Includes the "customer never existed" answer: nothing to cancel
540 // is a fine outcome for a deleted org, and it is still worth a line.
541 Effect.catch((error) =>
519 Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {542 Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {
520 organizationId,543 organizationId,
521 error,544 error,
apps/cloud/src/extensions/billing/service.ts+95−10
@@ -17,15 +17,61 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 16 unmodified lines
17 cause?: unknown;17 cause?: unknown;
18}> {}18}> {}
19 19
20/**
21 * Autumn has no customer record for the organization. Split out from
22 * `AutumnError` because it is not an outage and must not be treated like one:
23 * an outage is transient and the right answer is to fail open and page, while
24 * a missing customer is a PERMANENT provisioning gap — every subsequent
25 * balance call 404s the same way, so the org runs unbilled and unmetered
26 * forever. Callers that own an organization id repair it (see
27 * `withProvisionedCustomer`); everything else still surfaces as `AutumnError`.
28 */
29export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{
30 message: string;
31 cause?: unknown;
32}> {}
33
34export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError;
35
36// Autumn's own error code for "no such customer", carried in the JSON body of
37// a 404 (`{"message":"Customer <id> not found","code":"customer_not_found"}`).
38const CUSTOMER_NOT_FOUND_CODE = "customer_not_found";
39
40/**
41 * True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK
42 * throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match
43 * on the code rather than the status alone so an unrelated 404 (a removed
44 * endpoint, a proxy) is still reported as a genuine failure.
45 */
46const isCustomerNotFoundCause = (cause: unknown): boolean => {
47 if (typeof cause !== "object" || cause === null) return false;
48 const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown };
49 if (statusCode !== 404 || typeof body !== "string") return false;
50 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying a third-party SDK's raw response body; a body that isn't JSON simply isn't this error
51 try {
52 // oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string
53 const parsed: unknown = JSON.parse(body);
54 if (typeof parsed !== "object" || parsed === null) return false;
55 return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE;
56 } catch {
57 return false;
58 }
59};
60
20// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
21// Service interface62// Service interface
22// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
23 64
24export type IAutumnService = Readonly<{65export type IAutumnService = Readonly<{
25 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;66 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnFailure, never>;
67 /**
68 * Provision the organization's Autumn customer, creating it if Autumn has
69 * never seen it. Idempotent — safe to call on every org creation.
70 */
71 ensureCustomer: (organizationId: string) => Effect.Effect<void, AutumnFailure, never>;
26 checkExecutionBalance: (72 checkExecutionBalance: (
27 organizationId: string,73 organizationId: string,
28 ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>;74 ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>;
29 /**75 /**
30 * Fire-and-forget-safe execution usage tracker. Errors are caught and76 * Fire-and-forget-safe execution usage tracker. Errors are caught and
31 * logged; the returned Effect never fails. Callers typically77 * logged; the returned Effect never fails. Callers typically
@@ -48,6 +94,7 @@ const make = Effect.sync(() => { · 16 unmodified lines
48 );94 );
49 return {95 return {
50 use: () => notConfigured,96 use: () => notConfigured,
97 ensureCustomer: () => notConfigured,
51 checkExecutionBalance: () => notConfigured,98 checkExecutionBalance: () => notConfigured,
52 trackExecution: () => Effect.void,99 trackExecution: () => Effect.void,
53 } satisfies IAutumnService;100 } satisfies IAutumnService;
@@ -62,16 +109,53 @@ const make = Effect.sync(() => { · 8 unmodified lines
62 const use = <A>(fn: (client: Autumn) => Promise<A>) =>109 const use = <A>(fn: (client: Autumn) => Promise<A>) =>
63 Effect.tryPromise({110 Effect.tryPromise({
64 try: () => fn(client),111 try: () => fn(client),
65 catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }),112 catch: (cause): AutumnFailure =>
66 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));113 isCustomerNotFoundCause(cause)
114 ? new AutumnCustomerNotFoundError({
115 message: "Autumn has no customer for this organization",
116 cause,
117 })
118 : new AutumnError({ message: "Autumn SDK request failed", cause }),
119 // An inline arrow's `name` is "" — not nullish — so `??` left every
120 // Autumn call tracing as the bare span `autumn.`.
121 }).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`));
122
123 const ensureCustomer = (organizationId: string) =>
124 Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId })));
125
126 /**
127 * Run `operation`; if Autumn answers "no such customer", provision the
128 * organization's customer and run it ONCE more.
129 *
130 * This is the seam that closes the provisioning hole. Both billing paths use
131 * non-creating endpoints, so an organization Autumn never learned about
132 * 404s here forever: the balance gate fails open (correct for an outage,
133 * catastrophic as a steady state) and every usage track is lost. Repairing
134 * the customer makes the retry land the call — and a genuine Autumn outage
135 * still fails with `AutumnError` and still pages, unretried.
136 */
137 const withProvisionedCustomer = <A>(
138 organizationId: string,
139 operation: Effect.Effect<A, AutumnFailure>,
140 ) =>
141 operation.pipe(
142 Effect.catchTag("AutumnCustomerNotFoundError", () =>
143 Effect.gen(function* () {
144 yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true });
145 yield* ensureCustomer(organizationId);
146 return yield* operation;
147 }),
148 ),
149 );
67 150
68 const trackExecution = (organizationId: string) =>151 const trackExecution = (organizationId: string) =>
69 Effect.gen(function* () {152 Effect.gen(function* () {
70 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });153 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
71 yield* use((c) =>154 yield* withProvisionedCustomer(
72 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),155 organizationId,
156 use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),
73 ).pipe(157 ).pipe(
74 Effect.catchTag("AutumnError", (error) =>158 Effect.catch((error) =>
75 Effect.gen(function* () {159 Effect.gen(function* () {
76 // Silent billing data loss is worth paging on: autumn.trackExecution160 // Silent billing data loss is worth paging on: autumn.trackExecution
77 // is fire-and-forget so the caller doesn't handle it themselves.161 // is fire-and-forget so the caller doesn't handle it themselves.
@@ -88,13 +172,14 @@ const make = Effect.sync(() => { · 10 unmodified lines
88 const checkExecutionBalance = (organizationId: string) =>172 const checkExecutionBalance = (organizationId: string) =>
89 Effect.gen(function* () {173 Effect.gen(function* () {
90 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });174 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
91 const check = yield* use((c) =>175 const check = yield* withProvisionedCustomer(
92 c.check({ customerId: organizationId, featureId: "executions" }),176 organizationId,
177 use((c) => c.check({ customerId: organizationId, featureId: "executions" })),
93 );178 );
94 return { allowed: check.allowed };179 return { allowed: check.allowed };
95 }).pipe(Effect.withSpan("autumn.checkExecutionBalance"));180 }).pipe(Effect.withSpan("autumn.checkExecutionBalance"));
96 181
97 return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService;182 return { use, ensureCustomer, checkExecutionBalance, trackExecution } satisfies IAutumnService;
98});183});
99 184
100export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(185export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
e2e/cloud/billing-customer-provisioning.test.ts+205−0
@@ -0,0 +1,205 @@
1// Cloud-only (billing): an organization must EXIST as a customer at the billing
2// provider, or every billing call for it answers `customer_not_found` forever.
3//
4// That state is invisible from the product side and catastrophic underneath it:
5// the balance gate fails open (by design — a billing outage must not stop
6// executions), and usage tracking is fire-and-forget, so the org runs unlimited
7// executions and NONE of them reach the meter. The product looks perfectly
8// healthy while every execution is unbilled and unmetered.
9//
10// Three guarantees are pinned here, all read from Autumn's own state rather than
11// from the execution's response (which can never show any of them):
12//
13// 1. creating an organization provisions its billing customer up front,
14// 2. if a customer is missing anyway, the billing seam heals it in place and
15// the execution still lands on the meter, and
16// 3. any OTHER billing failure is left alone — the repair is scoped to the
17// "no such customer" answer, so a real provider failure is still reported
18// rather than quietly retried away.
19//
20// The missing customer is produced with the emulator's fault injector — one
21// 404 `customer_not_found` per billing endpoint — because the emulator, like
22// real Autumn's SDK flow, otherwise auto-creates customers on contact.
23import { expect } from "@effect/vitest";
24import { Effect } from "effect";
25
26import { scenario } from "../src/scenario";
27import { Autumn, Billing, Mcp, Target } from "../src/services";
28import type { Identity } from "../src/target";
29
30const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
31
32/** The org the bearer is scoped to — the Autumn customer id every billing call
33 * is made against — read from the JWT's public claims. */
34const orgIdOf = (bearer: string): string => {
35 const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {
36 readonly org_id?: string;
37 };
38 if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");
39 return claims.org_id;
40};
41
42/** A 404 `customer_not_found`, byte-for-byte the shape Autumn answers with for
43 * an organization it has no customer record for. */
44const CUSTOMER_NOT_FOUND = {
45 status: 404,
46 body: { message: "Customer not found", code: "customer_not_found" },
47} as const;
48
49/** A 404 that is NOT a missing customer — a moved route, a proxy, a gateway.
50 * Autumn carries a different code, and that code is the entire safety margin
51 * between "provision and retry" and "quietly retry away a real failure". */
52const UNRELATED_NOT_FOUND = {
53 status: 404,
54 body: { message: "Not Found", code: "not_found" },
55} as const;
56
57scenario(
58 "Billing · creating an organization provisions it as a billing customer",
59 { timeout: 180_000 },
60 Effect.gen(function* () {
61 yield* Billing;
62 const autumn = yield* Autumn;
63 const target = yield* Target;
64 const mcp = yield* Mcp;
65
66 // A brand-new user creating their first organization: none of the
67 // opportunistic billing lookups (the over-the-free-limit check, the members
68 // page's seat count, the rate limiter's paid-plan exemption) are reached on
69 // this journey, so org creation is the only chance to provision.
70 const identity = yield* target.newIdentity();
71 const bearer = yield* mcp.mintBearer(emailOf(identity));
72 const organizationId = orgIdOf(bearer);
73
74 const customerIds = yield* autumn.customerIds();
75 expect(
76 customerIds,
77 "the new organization exists as a customer at the billing provider",
78 ).toContain(organizationId);
79 }),
80);
81
82scenario(
83 "Billing · a missing billing customer is healed in place and the execution is still metered",
84 { timeout: 180_000 },
85 Effect.gen(function* () {
86 yield* Billing;
87 const autumn = yield* Autumn;
88 const target = yield* Target;
89 const mcp = yield* Mcp;
90
91 const identity = yield* target.newIdentity();
92 const bearer = yield* mcp.mintBearer(emailOf(identity));
93 const customerId = orgIdOf(bearer);
94
95 const before = yield* autumn.usageEvents({ customerId, featureId: "executions" });
96 expect(before.length, "a brand-new org starts with zero metered executions").toBe(0);
97
98 yield* Effect.gen(function* () {
99 // One 404 each: the next balance check and the next usage track both
100 // answer "this customer does not exist". A single `times: 1` fault means
101 // a seam that heals and retries gets through, while one that gives up
102 // loses the usage permanently — exactly the production failure.
103 yield* autumn.armFault({
104 match: { operationId: "balances.check" },
105 response: CUSTOMER_NOT_FOUND,
106 times: 1,
107 });
108 yield* autumn.armFault({
109 match: { operationId: "balances.track" },
110 response: CUSTOMER_NOT_FOUND,
111 times: 1,
112 });
113
114 const session = mcp.session(identity);
115 const result = yield* session.call("execute", { code: "return 6 * 7;" });
116
117 // The gate still fails open: a billing problem never blocks a customer.
118 expect(result.ok, "the execution runs despite the missing billing customer").toBe(true);
119 expect(result.text, "it returns its value").toContain("42");
120
121 // Both billing calls really did reach Autumn and really were rejected —
122 // without this the scenario could pass on a fault that never armed.
123 const checks = yield* autumn.ledgerFor("balances.check");
124 expect(
125 checks.some((entry) => entry.faulted),
126 "the balance check reached Autumn and was answered customer_not_found",
127 ).toBe(true);
128 const tracks = yield* autumn.ledgerFor("balances.track");
129 expect(
130 tracks.some((entry) => entry.faulted),
131 "the usage track reached Autumn and was answered customer_not_found",
132 ).toBe(true);
133
134 // The guarantee: the seam provisions the customer and retries, so the
135 // execution lands on the meter. Before the fix this ledger stays empty
136 // forever — the execution ran, and nobody was ever billed for it.
137 const metered = yield* autumn.expectUsage({
138 customerId,
139 featureId: "executions",
140 count: 1,
141 });
142 expect(metered.length, "the execution is metered exactly once").toBe(1);
143 expect(metered[0]?.value, "it meters a single unit").toBe(1);
144
145 // Healing the customer is what made the retry possible, so the customer
146 // must exist at the provider afterwards.
147 const customerIds = yield* autumn.customerIds();
148 expect(customerIds, "the organization has a billing customer afterwards").toContain(
149 customerId,
150 );
151 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
152 }),
153);
154
155scenario(
156 "Billing · an unrelated billing failure is reported, not retried away",
157 { timeout: 180_000 },
158 Effect.gen(function* () {
159 yield* Billing;
160 const autumn = yield* Autumn;
161 const target = yield* Target;
162 const mcp = yield* Mcp;
163
164 const identity = yield* target.newIdentity();
165 const bearer = yield* mcp.mintBearer(emailOf(identity));
166 const customerId = orgIdOf(bearer);
167
168 yield* Effect.gen(function* () {
169 // A 404 that is not "no such customer". If the seam treated every 404 as
170 // a provisioning gap it would create a customer and retry, turning a real
171 // provider failure into a silent success — and the alert never fires.
172 yield* autumn.armFault({
173 match: { operationId: "balances.track" },
174 response: UNRELATED_NOT_FOUND,
175 times: 1,
176 });
177
178 const session = mcp.session(identity);
179 const faulted = yield* session.call("execute", { code: "return 1 + 1;" });
180 expect(faulted.ok, "the execution runs — billing never blocks a customer").toBe(true);
181
182 // A second, unfaulted execution. Its usage landing is the barrier: the
183 // first execution's track (and any retry of it) is already done by the
184 // time this one is on the meter, so the counts below are settled without
185 // waiting on a clock.
186 const clean = yield* session.call("execute", { code: "return 2 + 2;" });
187 expect(clean.ok, "the follow-up execution runs too").toBe(true);
188 yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 });
189
190 const tracks = (yield* autumn.ledgerFor("balances.track")).filter(
191 (entry) => entry.customerId === customerId,
192 );
193 expect(
194 tracks.filter((entry) => entry.faulted).length,
195 "the first usage track was answered with the unrelated 404",
196 ).toBe(1);
197 // Two executions, two attempts: the failed one was reported and dropped,
198 // not repaired and replayed.
199 expect(tracks.length, "the rejected usage track is not retried").toBe(2);
200
201 const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" });
202 expect(metered.length, "only the unfaulted execution reaches the meter").toBe(1);
203 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
204 }),
205);
e2e/src/surfaces/autumn.ts+44−6
@@ -51,11 +51,19 @@ export interface LedgerEntry { · 50 unmodified lines
51 readonly method: string;51 readonly method: string;
52 readonly path: string;52 readonly path: string;
53 readonly faulted: boolean;53 readonly faulted: boolean;
54 /** The customer the request was made against, read from its body. Autumn's
55 * ledger is shared by every scenario in the run, so attempts have to be
56 * attributed to one organization before they can be counted. */
57 readonly customerId?: string;
54}58}
55 59
56export interface AutumnSurface {60export interface AutumnSurface {
57 /** One-shot read of the matching usage events from the ledger. */61 /** One-shot read of the matching usage events from the ledger. */
58 readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>;62 readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>;
63 /** Every customer id Autumn currently holds (`customers.list`). An org that
64 * was never provisioned as a billing customer is simply absent — the state
65 * in which every balance call 404s `customer_not_found` forever. */
66 readonly customerIds: () => Effect.Effect<readonly string[], unknown>;
59 /** Poll until at least `count` matching events have landed. The track is67 /** Poll until at least `count` matching events have landed. The track is
60 * forked and the worker drains it on `waitUntil` shortly after the execution68 * forked and the worker drains it on `waitUntil` shortly after the execution
61 * returns, so arrival is eventually-consistent — polling IS the contract:69 * returns, so arrival is eventually-consistent — polling IS the contract:
@@ -129,6 +137,26 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 67 unmodified lines
129 }));137 }));
130 });138 });
131 139
140 const customerIds = () =>
141 Effect.gen(function* () {
142 const response = yield* Effect.promise(() =>
143 fetch(`${autumnUrl}/v1/customers.list`, {
144 method: "POST",
145 headers: { "content-type": "application/json" },
146 body: "{}",
147 }),
148 );
149 if (!response.ok) {
150 return yield* Effect.fail(
151 `autumn customers.list responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
152 );
153 }
154 const body = (yield* Effect.promise(() => response.json())) as {
155 readonly list?: ReadonlyArray<{ readonly id?: string }>;
156 };
157 return (body.list ?? []).map((customer) => customer.id ?? "");
158 });
159
132 const settleCheckout = (sessionId: string) =>160 const settleCheckout = (sessionId: string) =>
133 Effect.gen(function* () {161 Effect.gen(function* () {
134 const response = yield* Effect.promise(() =>162 const response = yield* Effect.promise(() =>
@@ -232,20 +260,30 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 97 unmodified lines
232 readonly method?: string;260 readonly method?: string;
233 readonly path?: string;261 readonly path?: string;
234 readonly faulted?: boolean;262 readonly faulted?: boolean;
263 readonly request?: { readonly body?: unknown };
235 }>;264 }>;
236 };265 };
237 return (body.entries ?? [])266 return (body.entries ?? [])
238 .filter((entry) => entry.operationId === operationId)267 .filter((entry) => entry.operationId === operationId)
239 .map((entry) => ({268 .map((entry) => {
240 operationId: entry.operationId,269 const requestBody = entry.request?.body;
241 method: entry.method ?? "",270 const customerId =
242 path: entry.path ?? "",271 typeof requestBody === "object" && requestBody !== null
243 faulted: entry.faulted === true,272 ? (requestBody as { readonly customer_id?: unknown }).customer_id
244 }));273 : undefined;
274 return {
275 operationId: entry.operationId,
276 method: entry.method ?? "",
277 path: entry.path ?? "",
278 faulted: entry.faulted === true,
279 customerId: typeof customerId === "string" ? customerId : undefined,
280 };
281 });
245 });282 });
246 283
247 return {284 return {
248 usageEvents,285 usageEvents,
286 customerIds,
249 settleCheckout,287 settleCheckout,
250 exhaustExecutions,288 exhaustExecutions,
251 attachPlan,289 attachPlan,