| 1 | import * as monitoring from "@distilled.cloud/gcp/monitoring_v3"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { GcpEnvironment } from "../Environment.ts"; |
| 12 | import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | import { |
| 15 | encodeDescription, |
| 16 | hasOwnershipMarker, |
| 17 | jsonEqual, |
| 18 | parentOf, |
| 19 | parseMarker, |
| 20 | } from "./ownership.ts"; |
| 21 | |
| 22 | const MAX_TYPE_LENGTH = 100; |
| 23 | const CUSTOM_PREFIX = "custom.googleapis.com/"; |
| 24 | const DEFAULT_METRIC_KIND = "GAUGE"; |
| 25 | const DEFAULT_VALUE_TYPE = "DOUBLE"; |
| 26 | const DEFAULT_UNIT = "1"; |
| 27 | |
| 28 | export type MetricKind = "GAUGE" | "DELTA" | "CUMULATIVE" | (string & {}); |
| 29 | |
| 30 | export type MetricValueType = |
| 31 | | "BOOL" |
| 32 | | "INT64" |
| 33 | | "DOUBLE" |
| 34 | | "STRING" |
| 35 | | "DISTRIBUTION" |
| 36 | | "MONEY" |
| 37 | | (string & {}); |
| 38 | |
| 39 | export 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 | |
| 54 | export 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 | |
| 65 | export 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 | |
| 117 | export 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 | */ |
| 194 | export const MetricDescriptor = Resource<MetricDescriptor>( |
| 195 | "GCP.Monitoring.MetricDescriptor", |
| 196 | ); |
| 197 | |
| 198 | export class MetricDescriptorNotResolved extends Data.TaggedError( |
| 199 | "GCP.Monitoring.MetricDescriptorNotResolved", |
| 200 | )<{ |
| 201 | name: string; |
| 202 | }> {} |
| 203 | |
| 204 | const METRIC_DESCRIPTORS = "/metricDescriptors/"; |
| 205 | |
| 206 | const 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 | |
| 215 | const resourceName = (project: string, type: string) => |
| 216 | `projects/${project}/metricDescriptors/${type}`; |
| 217 | |
| 218 | const qualifyType = (type: string) => |
| 219 | type.includes(".googleapis.com/") |
| 220 | ? type |
| 221 | : `${CUSTOM_PREFIX}${type.replace(/^\/+/, "")}`; |
| 222 | |
| 223 | const 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 | |
| 238 | const 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 | |
| 253 | const 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 | |
| 265 | const 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 | |
| 286 | const 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 | |
| 296 | const 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 | |
| 305 | const 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 | |
| 321 | const getByName = (name: string) => |
| 322 | monitoring |
| 323 | .getProjectsMetricDescriptors({ name }) |
| 324 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 325 | |
| 326 | const 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 | |
| 352 | const 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 | |
| 370 | const 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 | |
| 378 | const 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 | |
| 391 | const 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 | |
| 400 | export 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 | }); |