1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 encodeDescription,
16 hasOwnershipMarker,
17 jsonEqual,
18 parentOf,
19 parseMarker,
20} from "./ownership.ts";
21
22const MAX_TYPE_LENGTH = 100;
23const CUSTOM_PREFIX = "custom.googleapis.com/";
24const DEFAULT_METRIC_KIND = "GAUGE";
25const DEFAULT_VALUE_TYPE = "DOUBLE";
26const DEFAULT_UNIT = "1";
27
28export type MetricKind = "GAUGE" | "DELTA" | "CUMULATIVE" | (string & {});
29
30export type MetricValueType =
31 | "BOOL"
32 | "INT64"
33 | "DOUBLE"
34 | "STRING"
35 | "DISTRIBUTION"
36 | "MONEY"
37 | (string & {});
38
39export type MetricLabelDescriptor = {
40 /**
41 * Label key. Must match `[a-zA-Z][a-zA-Z0-9_]*` and be at most 100
42 * characters.
43 */
44 key?: string;
45 /** Human-readable description of the label. */
46 description?: string;
47 /**
48 * Label value type.
49 * @default "STRING"
50 */
51 valueType?: "STRING" | "BOOL" | "INT64" | (string & {});
52};
53
54export type MetricDescriptorMetadata = {
55 /** Sampling period of written data points (for example `"60s"`). */
56 samplePeriod?: string;
57 /** Ingest delay after which points are guaranteed available. */
58 ingestDelay?: string;
59 /** Hierarchy levels that emit this metric (`PROJECT`, `FOLDER`, …). */
60 timeSeriesResourceHierarchyLevel?: string[];
61 /** Deprecated launch stage; prefer the top-level `launchStage`. */
62 launchStage?: string;
63};
64
65export type MetricDescriptorProps = {
66 /**
67 * Metric type, including DNS prefix (for example
68 * `custom.googleapis.com/invoice/paid/amount`). If omitted, a unique
69 * `custom.googleapis.com/` type is generated from the stack, stage,
70 * and logical id. Immutable — changing it replaces the descriptor.
71 */
72 type?: string;
73 /**
74 * Whether the metric records a point-in-time value or a change.
75 * Custom metrics support `GAUGE` and `CUMULATIVE` (`DELTA` is
76 * rejected). Immutable after create — changing it replaces the
77 * descriptor.
78 * @default "GAUGE"
79 */
80 metricKind?: MetricKind;
81 /**
82 * Measurement type. Immutable after create — changing it replaces
83 * the descriptor.
84 * @default "DOUBLE"
85 */
86 valueType?: MetricValueType;
87 /**
88 * Unit of the stored value (`"1"`, `"s"`, `"By"`, …).
89 * @default "1"
90 */
91 unit?: string;
92 /**
93 * Concise display name shown in Monitoring UIs.
94 */
95 displayName?: string;
96 /**
97 * Detailed description. Metric descriptors have no resource labels,
98 * so Alchemy stamps ownership into this field for `list` / nuke.
99 */
100 description?: string;
101 /**
102 * Time-series labels. New keys may be added; existing keys cannot be
103 * removed or have their value type changed without replacing the
104 * descriptor.
105 */
106 labels?: MetricLabelDescriptor[];
107 /**
108 * Optional launch stage of the metric definition.
109 */
110 launchStage?: string;
111 /**
112 * Optional usage metadata (sample period, ingest delay).
113 */
114 metadata?: MetricDescriptorMetadata;
115};
116
117export type MetricDescriptor = Resource<
118 "GCP.Monitoring.MetricDescriptor",
119 MetricDescriptorProps,
120 {
121 /** Full resource name `projects/{project}/metricDescriptors/{type}`. */
122 name: string;
123 /** Metric type, including DNS prefix. */
124 type: string;
125 /** Project id. */
126 project: string;
127 /** Metric kind (`GAUGE`, `DELTA`, `CUMULATIVE`). */
128 metricKind: string | undefined;
129 /** Value type (`DOUBLE`, `INT64`, …). */
130 valueType: string | undefined;
131 /** Unit of the stored value. */
132 unit: string | undefined;
133 /** Concise display name. */
134 displayName: string | undefined;
135 /** User description with the Alchemy ownership prefix stripped. */
136 description: string | undefined;
137 /** Time-series label descriptors. */
138 labels: MetricLabelDescriptor[];
139 /** Launch stage, if set. */
140 launchStage: string | undefined;
141 /** Usage metadata, if set. */
142 metadata: MetricDescriptorMetadata | undefined;
143 /** Compatible monitored resource types, if the API reports them. */
144 monitoredResourceTypes: ReadonlyArray<string>;
145 },
146 never,
147 Providers
148>;
149
150/**
151 * A Cloud Monitoring metric descriptor — the schema for a custom or
152 * external metric type.
153 *
154 * Descriptors have no resource labels. Alchemy stamps ownership into
155 * `description` (`[alchemy alchemy-stack=… alchemy-stage=…
156 * alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find them. `type`,
157 * `metricKind`, and `valueType` are immutable — changing them replaces
158 * the descriptor. Display name, description, unit, metadata, and added
159 * labels update in place via create-as-upsert.
160 *
161 * ### Creating a Descriptor
162 * **Example:** Generated custom gauge
163 * ```typescript
164 * const metric = yield* GCP.Monitoring.MetricDescriptor("Paid", {
165 * displayName: "Invoice paid amount",
166 * description: "amount collected per invoice",
167 * });
168 * ```
169 *
170 * **Example:** Explicit type, kind, and labels
171 * ```typescript
172 * const metric = yield* GCP.Monitoring.MetricDescriptor("Paid", {
173 * type: "custom.googleapis.com/invoice/paid/amount",
174 * metricKind: "DELTA",
175 * valueType: "INT64",
176 * unit: "1",
177 * labels: [{ key: "currency", valueType: "STRING" }],
178 * });
179 * ```
180 *
181 * ### Updating a Descriptor
182 * **Example:** Change the display name and unit
183 * ```typescript
184 * const metric = yield* GCP.Monitoring.MetricDescriptor("Paid", {
185 * displayName: "Invoice paid (cents)",
186 * unit: "1",
187 * description: "amount collected per invoice in cents",
188 * });
189 * ```
190 *
191 * @resource
192 * @category Monitoring
193 */
194export const MetricDescriptor = Resource<MetricDescriptor>(
195 "GCP.Monitoring.MetricDescriptor",
196);
197
198export class MetricDescriptorNotResolved extends Data.TaggedError(
199 "GCP.Monitoring.MetricDescriptorNotResolved",
200)<{
201 name: string;
202}> {}
203
204const METRIC_DESCRIPTORS = "/metricDescriptors/";
205
206const typeOf = (descriptor: monitoring.MetricDescriptor) => {
207 if (descriptor.type !== undefined && descriptor.type.length > 0) {
208 return descriptor.type;
209 }
210 const name = descriptor.name ?? "";
211 const at = name.indexOf(METRIC_DESCRIPTORS);
212 return at >= 0 ? name.slice(at + METRIC_DESCRIPTORS.length) : name;
213};
214
215const resourceName = (project: string, type: string) =>
216 `projects/${project}/metricDescriptors/${type}`;
217
218const qualifyType = (type: string) =>
219 type.includes(".googleapis.com/")
220 ? type
221 : `${CUSTOM_PREFIX}${type.replace(/^\/+/, "")}`;
222
223const toType = (id: string, type: string | undefined, existing?: string) =>
224 Effect.gen(function* () {
225 if (type !== undefined) return qualifyType(type);
226 if (existing !== undefined) return existing;
227 const generated = yield* createPhysicalName({
228 id,
229 maxLength: MAX_TYPE_LENGTH,
230 lowercase: true,
231 delimiter: "_",
232 });
233 const metricName = generated.replace(/-/g, "_").replace(/_+/g, "_");
234 const named = /^[a-z]/.test(metricName) ? metricName : `m_${metricName}`;
235 return qualifyType(named);
236 });
237
238const toLabels = (
239 labels: monitoring.LabelDescriptorList | undefined,
240): MetricLabelDescriptor[] =>
241 (labels ?? []).flatMap((label) =>
242 label.key
243 ? [
244 {
245 key: label.key,
246 description: label.description,
247 valueType: label.valueType,
248 },
249 ]
250 : [],
251 );
252
253const toMetadata = (
254 metadata: monitoring.MetricDescriptorMetadata | undefined,
255): MetricDescriptorMetadata | undefined => {
256 if (metadata === undefined) return undefined;
257 return {
258 samplePeriod: metadata.samplePeriod,
259 ingestDelay: metadata.ingestDelay,
260 timeSeriesResourceHierarchyLevel: metadata.timeSeriesResourceHierarchyLevel,
261 launchStage: metadata.launchStage,
262 };
263};
264
265const toAttrs = (descriptor: monitoring.MetricDescriptor, project: string) => {
266 const type = typeOf(descriptor);
267 const parsed = parseMarker(descriptor.description);
268 return {
269 name: descriptor.name?.includes(METRIC_DESCRIPTORS)
270 ? descriptor.name
271 : resourceName(project, type),
272 type,
273 project,
274 metricKind: descriptor.metricKind,
275 valueType: descriptor.valueType,
276 unit: descriptor.unit,
277 displayName: descriptor.displayName,
278 description: parsed.rest,
279 labels: toLabels(descriptor.labels),
280 launchStage: descriptor.launchStage,
281 metadata: toMetadata(descriptor.metadata),
282 monitoredResourceTypes: descriptor.monitoredResourceTypes ?? [],
283 };
284};
285
286const canonLabels = (labels: MetricLabelDescriptor[] | undefined) =>
287 [...(labels ?? [])]
288 .map((label) => ({
289 key: label.key ?? "",
290 description: label.description ?? "",
291 valueType: (label.valueType ?? "STRING").toUpperCase(),
292 }))
293 .filter((label) => label.key.length > 0)
294 .sort((left, right) => left.key.localeCompare(right.key));
295
296const labelsRemoved = (
297 observed: MetricLabelDescriptor[],
298 desired: MetricLabelDescriptor[] | undefined,
299) => {
300 if (desired === undefined) return false;
301 const next = new Set(canonLabels(desired).map((label) => label.key));
302 return canonLabels(observed).some((label) => !next.has(label.key));
303};
304
305const toBody = (
306 type: string,
307 news: MetricDescriptorProps,
308 ownership: Record<string, string>,
309): monitoring.MetricDescriptor => ({
310 type,
311 metricKind: news.metricKind ?? DEFAULT_METRIC_KIND,
312 valueType: news.valueType ?? DEFAULT_VALUE_TYPE,
313 unit: news.unit ?? DEFAULT_UNIT,
314 displayName: news.displayName,
315 description: encodeDescription(ownership, news.description),
316 labels: news.labels as monitoring.LabelDescriptorList | undefined,
317 launchStage: news.launchStage,
318 metadata: news.metadata as monitoring.MetricDescriptorMetadata | undefined,
319});
320
321const getByName = (name: string) =>
322 monitoring
323 .getProjectsMetricDescriptors({ name })
324 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
325
326const waitUntilSynced = (
327 name: string,
328 desired: { displayName?: string; description: string },
329) =>
330 getByName(name).pipe(
331 Effect.flatMap((value) => {
332 if (value === undefined) {
333 return Effect.fail(new MetricDescriptorNotResolved({ name }));
334 }
335 const displayOk =
336 desired.displayName === undefined ||
337 (value.displayName ?? "") === desired.displayName;
338 const descriptionOk = (value.description ?? "") === desired.description;
339 return displayOk && descriptionOk
340 ? Effect.succeed(value)
341 : Effect.fail(new MetricDescriptorNotResolved({ name }));
342 }),
343 // Descriptor writes take up to a minute to become readable.
344 Effect.retry({
345 times: 30,
346 schedule: Schedule.spaced("2 seconds"),
347 while: (error) =>
348 error._tag === "GCP.Monitoring.MetricDescriptorNotResolved",
349 }),
350 );
351
352const listCustom = (project: string) =>
353 monitoring.listProjectsMetricDescriptors
354 .pages({
355 name: parentOf(project),
356 pageSize: 1000,
357 filter: `metric.type = starts_with("${CUSTOM_PREFIX}")`,
358 })
359 .pipe(
360 Stream.flatMap((page) =>
361 Stream.fromIterable(page.metricDescriptors ?? []),
362 ),
363 Stream.runCollect,
364 Effect.map((chunk) => Array.from(chunk)),
365 Effect.catchTag("NotFound", () =>
366 Effect.succeed([] as monitoring.MetricDescriptor[]),
367 ),
368 );
369
370const listOwned = (project: string) =>
371 Effect.gen(function* () {
372 const descriptors = yield* listCustom(project);
373 return descriptors
374 .filter((descriptor) => hasOwnershipMarker(descriptor.description))
375 .map((descriptor) => toAttrs(descriptor, project));
376 });
377
378const findOwned = (project: string, id: string) =>
379 Effect.gen(function* () {
380 const descriptors = yield* listCustom(project);
381 for (const descriptor of descriptors) {
382 if (
383 yield* hasAlchemyLabels(id, parseMarker(descriptor.description).labels)
384 ) {
385 return descriptor;
386 }
387 }
388 return undefined;
389 });
390
391const observe = (project: string, id: string, name: string | undefined) =>
392 Effect.gen(function* () {
393 if (name !== undefined) {
394 const existing = yield* getByName(name);
395 if (existing !== undefined) return existing;
396 }
397 return yield* findOwned(project, id);
398 });
399
400export const MetricDescriptorProvider = () =>
401 Provider.succeed(MetricDescriptor, {
402 stables: ["name", "type", "project"],
403
404 diff: Effect.fn(function* ({ news, olds, output }) {
405 if (!isResolved(news)) return undefined;
406 const previousType = olds?.type ?? output?.type;
407 const typeChanged =
408 news.type !== undefined &&
409 previousType !== undefined &&
410 qualifyType(news.type) !== previousType;
411
412 const previousKind = (
413 olds?.metricKind ?? output?.metricKind
414 )?.toUpperCase();
415 const nextKind = news.metricKind?.toUpperCase();
416 const kindChanged =
417 nextKind !== undefined &&
418 previousKind !== undefined &&
419 nextKind !== previousKind;
420
421 const previousValue = (
422 olds?.valueType ?? output?.valueType
423 )?.toUpperCase();
424 const nextValue = news.valueType?.toUpperCase();
425 const valueChanged =
426 nextValue !== undefined &&
427 previousValue !== undefined &&
428 nextValue !== previousValue;
429
430 const removed = labelsRemoved(output?.labels ?? [], news.labels);
431
432 if (!typeChanged && !kindChanged && !valueChanged && !removed) {
433 return undefined;
434 }
435 return {
436 action: "replace" as const,
437 deleteFirst: !typeChanged,
438 };
439 }),
440
441 read: Effect.fn(function* ({ id, output }) {
442 const env = yield* GcpEnvironment.current;
443 const existing = yield* observe(env.project, id, output?.name);
444 if (existing === undefined) return undefined;
445 const attrs = toAttrs(existing, env.project);
446 return (yield* hasAlchemyLabels(
447 id,
448 parseMarker(existing.description).labels,
449 ))
450 ? attrs
451 : Unowned(attrs);
452 }),
453
454 list: () =>
455 Effect.gen(function* () {
456 const env = yield* GcpEnvironment.current;
457 return yield* listOwned(env.project);
458 }),
459
460 reconcile: Effect.fn(function* ({ id, news, output }) {
461 const env = yield* GcpEnvironment.current;
462 const type = yield* toType(id, news.type, output?.type);
463 const name = resourceName(env.project, type);
464 const ownership = yield* createInternalLabels(id);
465 const desiredDescription = encodeDescription(ownership, news.description);
466 const desiredKind = (
467 news.metricKind ?? DEFAULT_METRIC_KIND
468 ).toUpperCase();
469 const desiredValue = (news.valueType ?? DEFAULT_VALUE_TYPE).toUpperCase();
470 const desiredUnit = news.unit ?? DEFAULT_UNIT;
471
472 let current = yield* observe(env.project, id, output?.name ?? name);
473
474 const upsert = () =>
475 monitoring
476 .createProjectsMetricDescriptors({
477 name: parentOf(env.project),
478 body: toBody(type, news, ownership),
479 })
480 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
481
482 if (current === undefined) {
483 current = (yield* upsert()) ?? undefined;
484 }
485
486 if (current === undefined) {
487 return yield* new MetricDescriptorNotResolved({ name });
488 }
489
490 const observedLabels = toLabels(current.labels);
491 const needsUpdate =
492 (current.displayName ?? "") !== (news.displayName ?? "") ||
493 (current.description ?? "") !== desiredDescription ||
494 (current.unit ?? DEFAULT_UNIT) !== desiredUnit ||
495 (current.metricKind ?? "").toUpperCase() !== desiredKind ||
496 (current.valueType ?? "").toUpperCase() !== desiredValue ||
497 (news.launchStage !== undefined &&
498 (current.launchStage ?? "") !== news.launchStage) ||
499 (news.metadata !== undefined &&
500 !jsonEqual(toMetadata(current.metadata) ?? null, news.metadata)) ||
501 (news.labels !== undefined &&
502 !jsonEqual(canonLabels(observedLabels), canonLabels(news.labels)));
503
504 if (needsUpdate) {
505 current = (yield* upsert()) ?? current;
506 }
507
508 const latest = yield* waitUntilSynced(
509 current.name?.includes(METRIC_DESCRIPTORS) ? current.name : name,
510 {
511 displayName: news.displayName,
512 description: desiredDescription,
513 },
514 );
515 return toAttrs(latest, env.project);
516 }),
517
518 delete: Effect.fn(function* ({ output }) {
519 yield* monitoring
520 .deleteProjectsMetricDescriptors({ name: output.name })
521 .pipe(Effect.catchTag("NotFound", () => Effect.void));
522 }),
523 });