1import * as logging from "@distilled.cloud/gcp/logging_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19const MAX_NAME_LENGTH = 100;
20const DEFAULT_METRIC_KIND = "DELTA";
21const DEFAULT_VALUE_TYPE = "INT64";
22
23export interface MetricLabel {
24 /**
25 * Label key. Must match a key in `labelExtractors` when extractors are
26 * set.
27 */
28 key: string;
29 /**
30 * Human-readable description of the label.
31 */
32 description?: string;
33 /**
34 * Label value type.
35 * @default "STRING"
36 */
37 valueType?: "STRING" | "BOOL" | "INT64";
38}
39
40export interface MetricDescriptorProps {
41 /**
42 * Whether the metric records instantaneous values or changes.
43 * Immutable after create — changing it replaces the metric.
44 * @default "DELTA"
45 */
46 metricKind?: "GAUGE" | "DELTA" | "CUMULATIVE";
47 /**
48 * Measurement type. Immutable after create — changing it replaces the
49 * metric. `DISTRIBUTION` requires `valueExtractor` and `bucketOptions`.
50 * @default "INT64"
51 */
52 valueType?: "BOOL" | "INT64" | "DOUBLE" | "STRING" | "DISTRIBUTION" | "MONEY";
53 /**
54 * Unit of the metric value (e.g. `"1"`, `"s"`, `"By"`).
55 * @default "1"
56 */
57 unit?: string;
58 /**
59 * Concise display name shown in Monitoring.
60 */
61 displayName?: string;
62 /**
63 * Time-series labels. New labels may be added; existing keys cannot be
64 * renamed or have their value type changed.
65 */
66 labels?: MetricLabel[];
67}
68
69export interface LinearBuckets {
70 /** Number of finite buckets. Must be greater than 0. */
71 numFiniteBuckets?: number;
72 /** Width of each finite bucket. Must be greater than 0. */
73 width?: number;
74 /** Lower bound of the first finite bucket. */
75 offset?: number;
76}
77
78export interface ExponentialBuckets {
79 /** Number of finite buckets. Must be greater than 0. */
80 numFiniteBuckets?: number;
81 /** Growth factor between buckets. Must be greater than 1. */
82 growthFactor?: number;
83 /** Scale of the first bucket. Must be greater than 0. */
84 scale?: number;
85}
86
87export interface ExplicitBuckets {
88 /** Monotonically increasing bucket bounds. */
89 bounds?: number[];
90}
91
92export interface MetricBucketOptions {
93 /** Linear histogram buckets. */
94 linearBuckets?: LinearBuckets;
95 /** Exponential histogram buckets. */
96 exponentialBuckets?: ExponentialBuckets;
97 /** Explicit histogram bucket bounds. */
98 explicitBuckets?: ExplicitBuckets;
99}
100
101export type MetricProps = {
102 /**
103 * Client-assigned metric id (`METRIC_ID` in
104 * `projects/{project}/metrics/{METRIC_ID}`). If omitted, a unique name
105 * is generated from the stack, stage, and logical id. Max 100 characters;
106 * letters, digits, and `_-. ,+!*'()%/`. Cannot start with `/`. Changing
107 * it replaces the metric.
108 */
109 metricId?: string;
110 /**
111 * Advanced logs filter used to match log entries. Required. Max 20,000
112 * characters.
113 */
114 filter: string;
115 /**
116 * Human-readable description. Cloud Logging metrics have no labels, so
117 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
118 * is stored in a `[alchemy …]` prefix for `list` / nuke.
119 */
120 description?: string;
121 /**
122 * When true, the metric exists but does not generate time series.
123 * @default false
124 */
125 disabled?: boolean;
126 /**
127 * Extractor used by distribution metrics (`EXTRACT(field)` or
128 * `REGEXP_EXTRACT(field, regex)`).
129 */
130 valueExtractor?: string;
131 /**
132 * Map from metric-descriptor label key to an extractor expression.
133 */
134 labelExtractors?: Record<string, string>;
135 /**
136 * Log bucket that owns this metric
137 * (`projects/{project}/locations/{location}/buckets/{bucket}`). Empty
138 * means a non-bucket logs-based metric.
139 */
140 bucketName?: string;
141 /**
142 * Metric descriptor. Defaults to a `DELTA`/`INT64` counter. `metricKind`
143 * and `valueType` are immutable after create.
144 */
145 metricDescriptor?: MetricDescriptorProps;
146 /**
147 * Histogram bucket boundaries. Required when `valueType` is
148 * `DISTRIBUTION`.
149 */
150 bucketOptions?: MetricBucketOptions;
151};
152
153export type Metric = Resource<
154 "GCP.Logging.Metric",
155 MetricProps,
156 {
157 /** Full resource name `projects/{project}/metrics/{metricId}`. */
158 name: string;
159 /** Client-assigned metric id. */
160 metricId: string;
161 /** Project id. */
162 project: string;
163 /** Logs filter. */
164 filter: string;
165 /** User description with the Alchemy ownership prefix stripped. */
166 description: string | undefined;
167 /** Whether the metric is disabled. */
168 disabled: boolean;
169 /** Distribution value extractor, if set. */
170 valueExtractor: string | undefined;
171 /** Label extractor map. */
172 labelExtractors: Record<string, string>;
173 /** Owning log bucket, if this is a bucket metric. */
174 bucketName: string | undefined;
175 /** Observed metric descriptor. */
176 metricDescriptor: MetricDescriptorProps | undefined;
177 /** Histogram bucket options, if set. */
178 bucketOptions: MetricBucketOptions | undefined;
179 /** RFC3339 creation timestamp. */
180 createTime: string | undefined;
181 /** RFC3339 last-update timestamp. */
182 updateTime: string | undefined;
183 },
184 never,
185 Providers
186>;
187
188/**
189 * A Cloud Logging logs-based metric.
190 *
191 * Cloud Logging metrics have no resource labels. Alchemy stamps ownership
192 * into the description (`[alchemy alchemy-stack=… alchemy-stage=…
193 * alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find them.
194 *
195 * `metricId`, `metricDescriptor.metricKind`, and
196 * `metricDescriptor.valueType` are immutable — changing them replaces the
197 * metric.
198 *
199 * ### Creating a Metric
200 * **Example:** Generated name, error counter
201 * ```typescript
202 * const errors = yield* GCP.Logging.Metric("Errors", {
203 * filter: "severity>=ERROR",
204 * description: "count error log entries",
205 * });
206 * ```
207 *
208 * **Example:** Explicit id, disabled counter
209 * ```typescript
210 * const warnings = yield* GCP.Logging.Metric("Warnings", {
211 * metricId: "app-warnings",
212 * filter: "severity>=WARNING",
213 * description: "count warning log entries",
214 * disabled: true,
215 * });
216 * ```
217 *
218 * ### Distribution Metrics
219 * **Example:** Histogram of a numeric payload field
220 * ```typescript
221 * const latency = yield* GCP.Logging.Metric("Latency", {
222 * filter: "jsonPayload.latency > 0",
223 * valueExtractor: "EXTRACT(jsonPayload.latency)",
224 * metricDescriptor: {
225 * metricKind: "DELTA",
226 * valueType: "DISTRIBUTION",
227 * },
228 * bucketOptions: {
229 * exponentialBuckets: {
230 * numFiniteBuckets: 8,
231 * growthFactor: 2,
232 * scale: 1,
233 * },
234 * },
235 * });
236 * ```
237 *
238 * @resource
239 * @category Logging
240 */
241export const Metric = Resource<Metric>("GCP.Logging.Metric");
242
243export class MetricNotResolved extends Data.TaggedError(
244 "GCP.Logging.MetricNotResolved",
245)<{
246 name: string;
247}> {}
248
249const lastSegment = (value: string) => {
250 const trimmed = value.replace(/\/+$/, "");
251 const parts = trimmed.split("/");
252 return parts[parts.length - 1] || trimmed;
253};
254
255const resourceName = (project: string, metricId: string) =>
256 `projects/${project}/metrics/${metricId}`;
257
258const metricIdOf = (metric: logging.LogMetric) => {
259 const raw = metric.name ?? metric.resourceName ?? "";
260 return raw.startsWith("projects/") ? lastSegment(raw) : raw;
261};
262
263const toId = (id: string, metricId: string | undefined, existing?: string) =>
264 Effect.gen(function* () {
265 return (
266 metricId ??
267 existing ??
268 (yield* createPhysicalName({
269 id,
270 maxLength: MAX_NAME_LENGTH,
271 lowercase: true,
272 }))
273 );
274 });
275
276const encodeDescription = (
277 labels: Record<string, string>,
278 description: string | undefined,
279): string => {
280 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
281 return description ? `${marker}\n${description}` : marker;
282};
283
284const parseDescription = (
285 description: string | undefined,
286): {
287 labels: Record<string, string>;
288 description: string | undefined;
289} => {
290 if (!description?.startsWith("[alchemy ")) {
291 return { labels: {}, description };
292 }
293 const end = description.indexOf("]");
294 if (end < 0) return { labels: {}, description };
295 const labels: Record<string, string> = {};
296 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
297 const eq = part.indexOf("=");
298 if (eq > 0) {
299 labels[part.slice(0, eq)] = part.slice(eq + 1);
300 }
301 }
302 const rest = description.slice(end + 1).replace(/^\n/, "");
303 return { labels, description: rest.length > 0 ? rest : undefined };
304};
305
306const hasOwnershipMarker = (description: string | undefined) =>
307 Object.keys(parseDescription(description).labels).some((key) =>
308 key.startsWith("alchemy-"),
309 );
310
311const toLabels = (
312 labels: logging.LabelDescriptorList | undefined,
313): MetricLabel[] =>
314 (labels ?? []).flatMap((label) =>
315 label.key
316 ? [
317 {
318 key: label.key,
319 description: label.description,
320 valueType: label.valueType as MetricLabel["valueType"],
321 },
322 ]
323 : [],
324 );
325
326const toDescriptor = (
327 descriptor: logging.MetricDescriptor | undefined,
328): MetricDescriptorProps | undefined => {
329 if (descriptor === undefined) return undefined;
330 const labels = toLabels(descriptor.labels);
331 return {
332 metricKind: descriptor.metricKind as MetricDescriptorProps["metricKind"],
333 valueType: descriptor.valueType as MetricDescriptorProps["valueType"],
334 unit: descriptor.unit,
335 displayName: descriptor.displayName,
336 labels: labels.length > 0 ? labels : undefined,
337 };
338};
339
340const toBucketOptions = (
341 options: logging.BucketOptions | undefined,
342): MetricBucketOptions | undefined => {
343 if (options === undefined) return undefined;
344 if (
345 options.linearBuckets === undefined &&
346 options.exponentialBuckets === undefined &&
347 options.explicitBuckets === undefined
348 ) {
349 return undefined;
350 }
351 return {
352 linearBuckets: options.linearBuckets,
353 exponentialBuckets: options.exponentialBuckets,
354 explicitBuckets: options.explicitBuckets,
355 };
356};
357
358const toAttrs = (metric: logging.LogMetric, project: string) => {
359 const metricId = metricIdOf(metric);
360 const parsed = parseDescription(metric.description);
361 return {
362 name: metric.resourceName ?? resourceName(project, metricId),
363 metricId,
364 project,
365 filter: metric.filter ?? "",
366 description: parsed.description,
367 disabled: metric.disabled === true,
368 valueExtractor: metric.valueExtractor,
369 labelExtractors: tagRecord(metric.labelExtractors),
370 bucketName: metric.bucketName,
371 metricDescriptor: toDescriptor(metric.metricDescriptor),
372 bucketOptions: toBucketOptions(metric.bucketOptions),
373 createTime: metric.createTime,
374 updateTime: metric.updateTime,
375 };
376};
377
378const recordsEqual = (
379 left: Record<string, string>,
380 right: Record<string, string>,
381) => {
382 const leftKeys = Object.keys(left).sort();
383 const rightKeys = Object.keys(right).sort();
384 return (
385 leftKeys.length === rightKeys.length &&
386 leftKeys.every(
387 (key, index) => key === rightKeys[index] && left[key] === right[key],
388 )
389 );
390};
391
392const jsonEqual = (left: unknown, right: unknown) =>
393 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
394
395const canonLabels = (labels: MetricLabel[] | undefined) =>
396 [...(labels ?? [])]
397 .map((label) => ({
398 key: label.key,
399 description: label.description ?? "",
400 valueType: (label.valueType ?? "STRING").toUpperCase(),
401 }))
402 .sort((a, b) => a.key.localeCompare(b.key));
403
404const descriptorChanged = (
405 observed: logging.MetricDescriptor | undefined,
406 desired: MetricDescriptorProps | undefined,
407) => {
408 if (desired === undefined) return false;
409 if (
410 desired.metricKind !== undefined &&
411 (observed?.metricKind ?? DEFAULT_METRIC_KIND).toUpperCase() !==
412 desired.metricKind.toUpperCase()
413 ) {
414 return true;
415 }
416 if (
417 desired.valueType !== undefined &&
418 (observed?.valueType ?? DEFAULT_VALUE_TYPE).toUpperCase() !==
419 desired.valueType.toUpperCase()
420 ) {
421 return true;
422 }
423 if (desired.unit !== undefined && (observed?.unit ?? "1") !== desired.unit) {
424 return true;
425 }
426 if (
427 desired.displayName !== undefined &&
428 (observed?.displayName ?? "") !== desired.displayName
429 ) {
430 return true;
431 }
432 if (desired.labels !== undefined) {
433 return !jsonEqual(
434 canonLabels(toLabels(observed?.labels)),
435 canonLabels(desired.labels),
436 );
437 }
438 return false;
439};
440
441const toWritableDescriptor = (
442 descriptor: MetricDescriptorProps | logging.MetricDescriptor | undefined,
443): logging.MetricDescriptor | undefined => {
444 if (descriptor === undefined) return undefined;
445 const labels = descriptor.labels as logging.LabelDescriptorList | undefined;
446 const body: logging.MetricDescriptor = {
447 metricKind: descriptor.metricKind,
448 valueType: descriptor.valueType,
449 unit: descriptor.unit,
450 displayName: descriptor.displayName,
451 labels: labels && labels.length > 0 ? labels : undefined,
452 };
453 if (
454 body.metricKind === undefined &&
455 body.valueType === undefined &&
456 body.unit === undefined &&
457 body.displayName === undefined &&
458 body.labels === undefined
459 ) {
460 return undefined;
461 }
462 return body;
463};
464
465const toBody = (
466 metricId: string,
467 news: MetricProps,
468 ownership: Record<string, string>,
469 current?: logging.LogMetric,
470): logging.LogMetric => ({
471 name: metricId,
472 filter: news.filter,
473 description: encodeDescription(ownership, news.description),
474 disabled: news.disabled === true,
475 valueExtractor: news.valueExtractor,
476 labelExtractors: news.labelExtractors,
477 bucketName: news.bucketName,
478 bucketOptions: news.bucketOptions,
479 metricDescriptor: toWritableDescriptor(
480 news.metricDescriptor ?? current?.metricDescriptor,
481 ),
482});
483
484const getByName = (metricName: string) =>
485 logging
486 .getProjectsMetrics({ metricName })
487 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
488
489export const MetricProvider = () =>
490 Provider.succeed(Metric, {
491 stables: ["name", "metricId", "project", "createTime"],
492
493 diff: Effect.fn(function* ({ news, olds, output }) {
494 if (!isResolved(news)) return undefined;
495 const previousId = olds?.metricId ?? output?.metricId;
496 const nextId = news.metricId ?? previousId;
497 const idChanged =
498 previousId !== undefined &&
499 news.metricId !== undefined &&
500 previousId !== news.metricId;
501
502 const previousKind = (
503 olds?.metricDescriptor?.metricKind ??
504 output?.metricDescriptor?.metricKind
505 )?.toUpperCase();
506 const nextKind = news.metricDescriptor?.metricKind?.toUpperCase();
507 const kindChanged =
508 nextKind !== undefined &&
509 previousKind !== undefined &&
510 nextKind !== previousKind;
511
512 const previousType = (
513 olds?.metricDescriptor?.valueType ?? output?.metricDescriptor?.valueType
514 )?.toUpperCase();
515 const nextType = news.metricDescriptor?.valueType?.toUpperCase();
516 const typeChanged =
517 nextType !== undefined &&
518 previousType !== undefined &&
519 nextType !== previousType;
520
521 if (!idChanged && !kindChanged && !typeChanged) return undefined;
522 return {
523 action: "replace" as const,
524 deleteFirst:
525 !idChanged && previousId !== undefined && nextId === previousId,
526 };
527 }),
528
529 read: Effect.fn(function* ({ id, olds, output }) {
530 const env = yield* GcpEnvironment.current;
531 const metricId = yield* toId(id, olds?.metricId, output?.metricId);
532 const name = output?.name ?? resourceName(env.project, metricId);
533 const existing = yield* getByName(name);
534 if (existing === undefined) return undefined;
535 const attrs = toAttrs(existing, env.project);
536 return (yield* hasAlchemyLabels(
537 id,
538 parseDescription(existing.description).labels,
539 ))
540 ? attrs
541 : Unowned(attrs);
542 }),
543
544 list: () =>
545 Effect.gen(function* () {
546 const env = yield* GcpEnvironment.current;
547 return yield* logging.listProjectsMetrics
548 .pages({
549 parent: `projects/${env.project}`,
550 pageSize: 1000,
551 })
552 .pipe(
553 Stream.flatMap((page) => Stream.fromIterable(page.metrics ?? [])),
554 Stream.filter((metric) => hasOwnershipMarker(metric.description)),
555 Stream.map((metric) => toAttrs(metric, env.project)),
556 Stream.runCollect,
557 Effect.map((chunk) => Array.from(chunk)),
558 );
559 }),
560
561 reconcile: Effect.fn(function* ({ id, news, output }) {
562 const env = yield* GcpEnvironment.current;
563 const metricId = yield* toId(id, news.metricId, output?.metricId);
564 const metricName = resourceName(env.project, metricId);
565 const ownership = yield* createInternalLabels(id);
566 const desiredDescription = encodeDescription(ownership, news.description);
567 const desiredDisabled = news.disabled === true;
568 const desiredExtractors = tagRecord(news.labelExtractors);
569
570 let current = yield* getByName(output?.name ?? metricName);
571
572 if (current === undefined) {
573 const created = yield* logging
574 .createProjectsMetrics({
575 parent: `projects/${env.project}`,
576 body: toBody(metricId, news, ownership),
577 })
578 .pipe(Effect.catchTag("Conflict", () => getByName(metricName)));
579 current = created ?? undefined;
580 }
581
582 if (current === undefined) {
583 return yield* new MetricNotResolved({ name: metricName });
584 }
585
586 const needsUpdate =
587 (current.description ?? "") !== desiredDescription ||
588 (current.filter ?? "") !== news.filter ||
589 (current.disabled === true) !== desiredDisabled ||
590 (current.valueExtractor ?? "") !== (news.valueExtractor ?? "") ||
591 !recordsEqual(tagRecord(current.labelExtractors), desiredExtractors) ||
592 (current.bucketName ?? "") !== (news.bucketName ?? "") ||
593 !jsonEqual(
594 toBucketOptions(current.bucketOptions) ?? null,
595 news.bucketOptions ?? null,
596 ) ||
597 descriptorChanged(current.metricDescriptor, news.metricDescriptor);
598
599 if (needsUpdate) {
600 current = yield* logging.updateProjectsMetrics({
601 metricName,
602 body: toBody(metricId, news, ownership, current),
603 });
604 }
605
606 const latest =
607 (yield* getByName(current.resourceName ?? metricName)) ?? current;
608 return toAttrs(latest, env.project);
609 }),
610
611 delete: Effect.fn(function* ({ output }) {
612 yield* logging
613 .deleteProjectsMetrics({ metricName: output.name })
614 .pipe(Effect.catchTag("NotFound", () => Effect.void));
615 }),
616 });