1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * 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 */
11export 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 */
20export 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 */
28export 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 */
40export 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
49export 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 */
58export 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 */
75export 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 */
96export 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 */
116export 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 */
144export 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
169export type ResourceOp = "precreate" | "create" | "update" | "delete" | "read";
170
171const elapsed = (startNs: bigint): Duration.Duration =>
172 Duration.nanos(process.hrtime.bigint() - startNs);
173
174const 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};