| 1 | import * as Duration from "effect/Duration"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Metric from "effect/Metric"; |
| 5 | |
| 6 | /** |
| 7 | * Counter for resource lifecycle operations. Tagged per call with |
| 8 | * `resource_type`, `op` (`precreate`/`create`/`update`/`delete`/`read`), |
| 9 | * and `status` (`success`/`error`). |
| 10 | */ |
| 11 | export const resourceCounter = Metric.counter("alchemy.resource.operations", { |
| 12 | description: |
| 13 | "Number of resource lifecycle operations dispatched by alchemy apply.", |
| 14 | incremental: true, |
| 15 | }); |
| 16 | |
| 17 | /** |
| 18 | * Histogram of how long each lifecycle operation takes. |
| 19 | */ |
| 20 | export const resourceDuration = Metric.timer("alchemy.resource.duration", { |
| 21 | description: |
| 22 | "Wall-clock duration of resource lifecycle operations dispatched by alchemy apply.", |
| 23 | }); |
| 24 | |
| 25 | /** |
| 26 | * Counter for CLI command invocations. |
| 27 | */ |
| 28 | export const cliCounter = Metric.counter("alchemy.cli.invocations", { |
| 29 | description: "Number of alchemy CLI command invocations.", |
| 30 | incremental: true, |
| 31 | }); |
| 32 | |
| 33 | /** |
| 34 | * Counter for Cloudflare State Store bootstrap/deploy operations. |
| 35 | * Tagged per call with `op` (e.g. `deploy`) and `status` |
| 36 | * (`success`/`error`) so that the deploy success rate of the |
| 37 | * state store itself can be tracked separately from regular |
| 38 | * resource lifecycle ops. |
| 39 | */ |
| 40 | export const stateStoreCounter = Metric.counter( |
| 41 | "alchemy.state_store.operations", |
| 42 | { |
| 43 | description: |
| 44 | "Number of Cloudflare State Store deploy/bootstrap operations dispatched by alchemy.", |
| 45 | incremental: true, |
| 46 | }, |
| 47 | ); |
| 48 | |
| 49 | export type StateStoreOp = "deploy"; |
| 50 | |
| 51 | /** |
| 52 | * Counter for State store layer construction. Tagged with `id` (the |
| 53 | * `StateService.id` slug) so we can answer "how many distinct projects |
| 54 | * are using each backend" from the corresponding `state_store.init` |
| 55 | * spans. Open-ended on purpose: third-party state stores get counted |
| 56 | * automatically by setting their `StateService.id`. |
| 57 | */ |
| 58 | export const stateStoreInitCounter = Metric.counter( |
| 59 | "alchemy.state_store.inits", |
| 60 | { |
| 61 | description: "Number of times a State store layer is constructed.", |
| 62 | incremental: true, |
| 63 | }, |
| 64 | ); |
| 65 | |
| 66 | /** |
| 67 | * Wraps a resource lifecycle Effect to record a counter + timer entry, |
| 68 | * tagged with `resource_type`, `op`, and `status` (`success`/`error`). |
| 69 | * |
| 70 | * Usage: |
| 71 | * ```ts |
| 72 | * provider.reconcile(input).pipe(recordResourceOp(node.resource.Type, "create")) |
| 73 | * ``` |
| 74 | */ |
| 75 | export const recordResourceOp = |
| 76 | (resourceType: string, op: ResourceOp) => |
| 77 | <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => |
| 78 | Effect.suspend(() => { |
| 79 | const startNs = process.hrtime.bigint(); |
| 80 | const baseAttrs = { resource_type: resourceType, op } as const; |
| 81 | return self.pipe( |
| 82 | Effect.onExit((exit) => |
| 83 | recordOutcome( |
| 84 | baseAttrs, |
| 85 | Exit.isSuccess(exit) ? "success" : "error", |
| 86 | elapsed(startNs), |
| 87 | ), |
| 88 | ), |
| 89 | ); |
| 90 | }); |
| 91 | |
| 92 | /** |
| 93 | * Wraps a CLI command Effect to bump {@link cliCounter} with the |
| 94 | * outcome (`success`/`error`). |
| 95 | */ |
| 96 | export const recordCli = |
| 97 | (command: string) => |
| 98 | <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => |
| 99 | self.pipe( |
| 100 | Effect.onExit((exit) => |
| 101 | Metric.update( |
| 102 | Metric.withAttributes(cliCounter, { |
| 103 | command, |
| 104 | status: Exit.isSuccess(exit) ? "success" : "error", |
| 105 | }), |
| 106 | 1, |
| 107 | ), |
| 108 | ), |
| 109 | ); |
| 110 | |
| 111 | /** |
| 112 | * Wraps a Cloudflare State Store deploy/bootstrap Effect to bump |
| 113 | * {@link stateStoreCounter} with the outcome (`success`/`error`), |
| 114 | * tagged with the given `op`. |
| 115 | */ |
| 116 | export const recordStateStoreOp = |
| 117 | (op: StateStoreOp) => |
| 118 | <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => |
| 119 | self.pipe( |
| 120 | Effect.onExit((exit) => |
| 121 | Metric.update( |
| 122 | Metric.withAttributes(stateStoreCounter, { |
| 123 | op, |
| 124 | status: Exit.isSuccess(exit) ? "success" : "error", |
| 125 | }), |
| 126 | 1, |
| 127 | ), |
| 128 | ), |
| 129 | ); |
| 130 | |
| 131 | /** |
| 132 | * Wraps a State store construction Effect to: |
| 133 | * |
| 134 | * 1. Bump {@link stateStoreInitCounter} tagged with `id`. |
| 135 | * 2. Open a `state_store.init` span carrying |
| 136 | * `alchemy.state_store.id` so Axiom (which can't query metric |
| 137 | * datasets via APL) can group projects by backend. |
| 138 | * |
| 139 | * The `id` is read off the constructed `StateService.id` field, so any |
| 140 | * third-party state-store implementation gets tracked just by setting |
| 141 | * a stable slug there. Apply at every `Layer.effect(State, …)` site |
| 142 | * exactly once. |
| 143 | */ |
| 144 | export const recordStateStoreInit = <A extends { readonly id: string }, E, R>( |
| 145 | self: Effect.Effect<A, E, R>, |
| 146 | ): Effect.Effect<A, E, R> => |
| 147 | // The debug lines bracket the first phase of a deploy that can block on |
| 148 | // credential resolution (see `Auth/Lock.ts`). Without them a stalled |
| 149 | // deploy produces no output at all, at any log level, and there is |
| 150 | // nothing to tell "hung" apart from "slow". |
| 151 | Effect.logDebug("state store: initializing").pipe( |
| 152 | Effect.andThen(self), |
| 153 | Effect.tap((service) => |
| 154 | Effect.all( |
| 155 | [ |
| 156 | Effect.logDebug(`state store: ready (${service.id})`), |
| 157 | Metric.update( |
| 158 | Metric.withAttributes(stateStoreInitCounter, { id: service.id }), |
| 159 | 1, |
| 160 | ), |
| 161 | Effect.annotateCurrentSpan("alchemy.state_store.id", service.id), |
| 162 | ], |
| 163 | { discard: true }, |
| 164 | ), |
| 165 | ), |
| 166 | Effect.withSpan("state_store.init"), |
| 167 | ); |
| 168 | |
| 169 | export type ResourceOp = "precreate" | "create" | "update" | "delete" | "read"; |
| 170 | |
| 171 | const elapsed = (startNs: bigint): Duration.Duration => |
| 172 | Duration.nanos(process.hrtime.bigint() - startNs); |
| 173 | |
| 174 | const recordOutcome = ( |
| 175 | baseAttrs: { resource_type: string; op: ResourceOp }, |
| 176 | status: "success" | "error", |
| 177 | duration: Duration.Duration, |
| 178 | ): Effect.Effect<void> => { |
| 179 | const attrs = { ...baseAttrs, status }; |
| 180 | return Effect.all( |
| 181 | [ |
| 182 | Metric.update(Metric.withAttributes(resourceCounter, attrs), 1), |
| 183 | Metric.update(Metric.withAttributes(resourceDuration, attrs), duration), |
| 184 | ], |
| 185 | { discard: true }, |
| 186 | ); |
| 187 | }; |