1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
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 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20import {
21 jsonEqual,
22 lastSegment,
23 parentOf,
24 toEmptyObject,
25} from "./ownership.ts";
26
27const MAX_SLO_ID_LENGTH = 63;
28const DEFAULT_ROLLING_PERIOD = "86400s";
29
30export type LatencyCriteria = {
31 /** Requests at or below this latency count as good. */
32 threshold?: string;
33};
34
35export type BasicSli = {
36 /** Locations this SLI applies to. Omitted means all. */
37 location?: string[];
38 /** Availability SLI (successful request count). */
39 availability?: Record<string, never>;
40 /** Latency SLI against `threshold`. */
41 latency?: LatencyCriteria;
42 /** RPC methods this SLI applies to. Omitted means all. */
43 method?: string[];
44 /** API versions this SLI applies to. Omitted means all. */
45 version?: string[];
46};
47
48export type ValueRange = {
49 /** Inclusive maximum. Omit (or use Infinity) for an open upper bound. */
50 max?: number;
51 /** Inclusive minimum. Omit (or use -Infinity) for an open lower bound. */
52 min?: number;
53};
54
55export type MetricRange = {
56 /** Range of values considered good. */
57 range?: ValueRange;
58 /** Monitoring filter selecting a GAUGE INT64 or DOUBLE time series. */
59 timeSeries?: string;
60};
61
62export type DistributionCut = {
63 /** Range of distribution bucket values considered good. */
64 range?: ValueRange;
65 /**
66 * Monitoring filter selecting a DISTRIBUTION DELTA or CUMULATIVE
67 * time series.
68 */
69 distributionFilter?: string;
70};
71
72export type TimeSeriesRatio = {
73 /** Filter quantifying good service. */
74 goodServiceFilter?: string;
75 /** Filter quantifying bad service. */
76 badServiceFilter?: string;
77 /** Filter quantifying total demanded service. */
78 totalServiceFilter?: string;
79};
80
81export type RequestBasedSli = {
82 /** Good service is the count of distribution values in `range`. */
83 distributionCut?: DistributionCut;
84 /** Good / total ratio from a pair of time series. */
85 goodTotalRatio?: TimeSeriesRatio;
86};
87
88export type PerformanceThreshold = {
89 /** Window is good when performance is at least this fraction. */
90 threshold?: number;
91 /** Request-based SLI used to judge the window. */
92 performance?: RequestBasedSli;
93 /** Basic SLI used to judge the window. */
94 basicSliPerformance?: BasicSli;
95};
96
97export type WindowsBasedSli = {
98 /**
99 * Window duration. Must be an integer fraction of a day and at least
100 * 60s.
101 */
102 windowPeriod?: string;
103 /** Window is good when the summed metric is in `range`. */
104 metricSumInRange?: MetricRange;
105 /** BOOL time series; the window is good if any true value appears. */
106 goodBadMetricFilter?: string;
107 /** Window is good when performance is above `threshold`. */
108 goodTotalRatioThreshold?: PerformanceThreshold;
109 /** Window is good when the mean metric is in `range`. */
110 metricMeanInRange?: MetricRange;
111};
112
113export type ServiceLevelIndicator = {
114 /** Basic SLI on a well-known service type. */
115 basicSli?: BasicSli;
116 /** Windows-based SLI. */
117 windowsBased?: WindowsBasedSli;
118 /** Request-based SLI. */
119 requestBased?: RequestBasedSli;
120};
121
122export type CalendarPeriod =
123 | "DAY"
124 | "WEEK"
125 | "FORTNIGHT"
126 | "MONTH"
127 | (string & {});
128
129export type ServicesServiceLevelObjectiveProps = {
130 /**
131 * Parent service resource name
132 * (`projects/{project}/services/{service}`) or service id. Immutable
133 * — changing it replaces the SLO.
134 */
135 service: string;
136 /**
137 * SLO id (the last path segment). If omitted, a unique id is
138 * generated from the stack, stage, and logical id. Must match
139 * `[a-zA-Z0-9-_:.]`. Immutable — changing it replaces the SLO.
140 */
141 serviceLevelObjectiveId?: string;
142 /**
143 * Human-readable display name. If omitted, the SLO id is used.
144 */
145 displayName?: string;
146 /**
147 * Fraction of service that must be good (`0 < goal <= 0.9999`).
148 */
149 goal: number;
150 /**
151 * Rolling window (for example `"86400s"`). Mutually exclusive with
152 * `calendarPeriod`. Defaults to `"86400s"` when neither is set.
153 */
154 rollingPeriod?: string;
155 /**
156 * Calendar period (`DAY`, `WEEK`, `FORTNIGHT`, `MONTH`). Mutually
157 * exclusive with `rollingPeriod`.
158 */
159 calendarPeriod?: CalendarPeriod;
160 /**
161 * Service-level indicator defining good service.
162 */
163 serviceLevelIndicator: ServiceLevelIndicator;
164 /**
165 * User labels. Alchemy ownership labels are merged in automatically.
166 */
167 labels?: Record<string, string>;
168};
169
170export type ServicesServiceLevelObjective = Resource<
171 "GCP.Monitoring.ServicesServiceLevelObjective",
172 ServicesServiceLevelObjectiveProps,
173 {
174 /** Full resource name `projects/{project}/services/{service}/serviceLevelObjectives/{slo}`. */
175 name: string;
176 /** SLO id (last path segment). */
177 serviceLevelObjectiveId: string;
178 /** Parent service resource name. */
179 service: string;
180 /** Parent service id. */
181 serviceId: string;
182 /** Project id. */
183 project: string;
184 /** Human-readable display name. */
185 displayName: string | undefined;
186 /** Target good-service fraction. */
187 goal: number;
188 /** Rolling window, if this SLO is rolling. */
189 rollingPeriod: string | undefined;
190 /** Calendar period, if this SLO is calendar-based. */
191 calendarPeriod: string | undefined;
192 /** Service-level indicator. */
193 serviceLevelIndicator: ServiceLevelIndicator | undefined;
194 /** User labels (Alchemy ownership labels stripped). */
195 labels: Record<string, string>;
196 },
197 never,
198 Providers
199>;
200
201/**
202 * A Cloud Monitoring service-level objective under a Monitoring
203 * Service.
204 *
205 * Alchemy stamps ownership into `userLabels` so `list` /
206 * `pnpm nuke:gcp` can find them. Changing `service` or
207 * `serviceLevelObjectiveId` replaces the SLO. Goal, period, SLI,
208 * display name, and labels update in place.
209 *
210 * ### Creating an SLO
211 * **Example:** Rolling request-latency SLO on a custom service
212 * ```typescript
213 * const checkout = yield* GCP.Monitoring.Service("Checkout", {});
214 * const slo = yield* GCP.Monitoring.ServicesServiceLevelObjective(
215 * "Latency",
216 * {
217 * service: checkout.name,
218 * goal: 0.99,
219 * rollingPeriod: "86400s",
220 * serviceLevelIndicator: {
221 * requestBased: {
222 * distributionCut: {
223 * distributionFilter:
224 * 'metric.type="serviceruntime.googleapis.com/api/request_latencies" AND resource.type="consumed_api"',
225 * range: { max: 500 },
226 * },
227 * },
228 * },
229 * },
230 * );
231 * ```
232 *
233 * ### Updating an SLO
234 * **Example:** Lower the goal and switch to a calendar month
235 * ```typescript
236 * const slo = yield* GCP.Monitoring.ServicesServiceLevelObjective(
237 * "Latency",
238 * {
239 * service: checkout.name,
240 * goal: 0.95,
241 * calendarPeriod: "MONTH",
242 * serviceLevelIndicator: {
243 * requestBased: {
244 * distributionCut: {
245 * distributionFilter:
246 * 'metric.type="serviceruntime.googleapis.com/api/request_latencies" AND resource.type="consumed_api"',
247 * range: { max: 500 },
248 * },
249 * },
250 * },
251 * },
252 * );
253 * ```
254 *
255 * @resource
256 * @category Monitoring
257 */
258export const ServicesServiceLevelObjective =
259 Resource<ServicesServiceLevelObjective>(
260 "GCP.Monitoring.ServicesServiceLevelObjective",
261 );
262
263export class ServicesServiceLevelObjectiveNotResolved extends Data.TaggedError(
264 "GCP.Monitoring.ServicesServiceLevelObjectiveNotResolved",
265)<{
266 name: string;
267}> {}
268
269const SERVICES = "/services/";
270
271const parseSloName = (name: string) => {
272 const parts = name.split("/").filter((part) => part.length > 0);
273 const projectsAt = parts.lastIndexOf("projects");
274 const servicesAt = parts.lastIndexOf("services");
275 const slosAt = parts.lastIndexOf("serviceLevelObjectives");
276 const project =
277 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "";
278 const serviceId =
279 servicesAt >= 0 && parts[servicesAt + 1] ? parts[servicesAt + 1]! : "";
280 const service =
281 servicesAt >= 0 ? parts.slice(0, servicesAt + 2).join("/") : "";
282 const serviceLevelObjectiveId =
283 slosAt >= 0 && parts[slosAt + 1] ? parts[slosAt + 1]! : lastSegment(name);
284 return { project, service, serviceId, serviceLevelObjectiveId };
285};
286
287const resolveService = (project: string, service: string) => {
288 if (service.includes("/")) return service;
289 return `${parentOf(project)}/services/${service}`;
290};
291
292const resourceName = (service: string, sloId: string) =>
293 `${service}/serviceLevelObjectives/${sloId}`;
294
295const userFacingLabels = (
296 labels: Record<string, string | undefined> | null | undefined,
297): Record<string, string> => stripInternalLabels(tagRecord(labels));
298
299const toId = (id: string, sloId: string | undefined, existing?: string) =>
300 Effect.gen(function* () {
301 if (sloId !== undefined) return sloId;
302 if (existing !== undefined) return existing;
303 const generated = yield* createPhysicalName({
304 id,
305 maxLength: MAX_SLO_ID_LENGTH,
306 lowercase: true,
307 });
308 return generated.replace(/-+$/g, "").slice(0, MAX_SLO_ID_LENGTH);
309 });
310
311const toBasicSli = (
312 sli: monitoring.BasicSli | undefined,
313): BasicSli | undefined => {
314 if (sli === undefined) return undefined;
315 return {
316 location: sli.location,
317 availability: toEmptyObject(sli.availability),
318 latency: sli.latency,
319 method: sli.method,
320 version: sli.version,
321 };
322};
323
324const toPerformanceThreshold = (
325 value: monitoring.PerformanceThreshold | undefined,
326): PerformanceThreshold | undefined => {
327 if (value === undefined) return undefined;
328 return {
329 threshold: value.threshold,
330 performance: value.performance,
331 basicSliPerformance: toBasicSli(value.basicSliPerformance),
332 };
333};
334
335const toWindowsBasedSli = (
336 sli: monitoring.WindowsBasedSli | undefined,
337): WindowsBasedSli | undefined => {
338 if (sli === undefined) return undefined;
339 return {
340 windowPeriod: sli.windowPeriod,
341 metricSumInRange: sli.metricSumInRange,
342 goodBadMetricFilter: sli.goodBadMetricFilter,
343 goodTotalRatioThreshold: toPerformanceThreshold(
344 sli.goodTotalRatioThreshold,
345 ),
346 metricMeanInRange: sli.metricMeanInRange,
347 };
348};
349
350const toSli = (
351 sli: monitoring.ServiceLevelIndicator | undefined,
352): ServiceLevelIndicator | undefined => {
353 if (sli === undefined) return undefined;
354 return {
355 basicSli: toBasicSli(sli.basicSli),
356 windowsBased: toWindowsBasedSli(sli.windowsBased),
357 requestBased: sli.requestBased,
358 };
359};
360
361const toAttrs = (slo: monitoring.ServiceLevelObjective, project: string) => {
362 const name = slo.name ?? "";
363 const parsed = parseSloName(name);
364 return {
365 name,
366 serviceLevelObjectiveId: parsed.serviceLevelObjectiveId,
367 service: parsed.service,
368 serviceId: parsed.serviceId,
369 project: parsed.project || project,
370 displayName: slo.displayName,
371 goal: slo.goal ?? 0,
372 rollingPeriod: slo.rollingPeriod,
373 calendarPeriod: slo.calendarPeriod,
374 serviceLevelIndicator: toSli(slo.serviceLevelIndicator),
375 labels: userFacingLabels(slo.userLabels),
376 };
377};
378
379const periodBody = (
380 news: ServicesServiceLevelObjectiveProps,
381): Pick<
382 monitoring.ServiceLevelObjective,
383 "rollingPeriod" | "calendarPeriod"
384> =>
385 news.calendarPeriod !== undefined
386 ? { calendarPeriod: news.calendarPeriod }
387 : { rollingPeriod: news.rollingPeriod ?? DEFAULT_ROLLING_PERIOD };
388
389const toBody = (
390 news: ServicesServiceLevelObjectiveProps,
391 displayName: string,
392 desiredLabels: Record<string, string>,
393): monitoring.ServiceLevelObjective => ({
394 displayName,
395 goal: news.goal,
396 ...periodBody(news),
397 serviceLevelIndicator: news.serviceLevelIndicator as
398 | monitoring.ServiceLevelIndicator
399 | undefined,
400 userLabels: desiredLabels,
401});
402
403const getByName = (name: string) =>
404 monitoring
405 .getServicesServiceLevelObjectives({ name })
406 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
407
408const listSlos = (parent: string) =>
409 monitoring.listServicesServiceLevelObjectives
410 .pages({
411 parent,
412 pageSize: 1000,
413 })
414 .pipe(
415 Stream.flatMap((page) =>
416 Stream.fromIterable(page.serviceLevelObjectives ?? []),
417 ),
418 Stream.runCollect,
419 Effect.map((chunk) => Array.from(chunk)),
420 Effect.catchTag("NotFound", () =>
421 Effect.succeed([] as monitoring.ServiceLevelObjective[]),
422 ),
423 );
424
425const listServices = (project: string) =>
426 monitoring.listServices
427 .pages({
428 parent: parentOf(project),
429 pageSize: 1000,
430 })
431 .pipe(
432 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
433 Stream.runCollect,
434 Effect.map((chunk) => Array.from(chunk)),
435 Effect.catchTag("NotFound", () => Effect.succeed([])),
436 );
437
438const listOwned = (project: string) =>
439 Effect.gen(function* () {
440 const services = yield* monitoring.listServices
441 .pages({
442 parent: parentOf(project),
443 pageSize: 1000,
444 filter: 'identifier_case = "CUSTOM"',
445 })
446 .pipe(
447 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
448 Stream.runCollect,
449 Effect.map((chunk) => Array.from(chunk)),
450 Effect.catchTag("NotFound", () => Effect.succeed([])),
451 );
452 const nested = yield* Effect.forEach(
453 services,
454 (service) =>
455 service.name !== undefined
456 ? listSlos(service.name)
457 : Effect.succeed([] as monitoring.ServiceLevelObjective[]),
458 { concurrency: 5 },
459 );
460 return nested
461 .flat()
462 .filter((slo) =>
463 Object.keys(slo.userLabels ?? {}).some((key) =>
464 key.startsWith("alchemy-"),
465 ),
466 )
467 .map((slo) => toAttrs(slo, project));
468 });
469
470const findOwned = (project: string, id: string, service: string | undefined) =>
471 Effect.gen(function* () {
472 const parents =
473 service !== undefined
474 ? [service]
475 : (yield* listServices(project)).flatMap((item) =>
476 item.name !== undefined ? [item.name] : [],
477 );
478 for (const parent of parents) {
479 const slos = yield* listSlos(parent);
480 for (const slo of slos) {
481 if (yield* hasAlchemyLabels(id, tagRecord(slo.userLabels))) {
482 return slo;
483 }
484 }
485 }
486 return undefined;
487 });
488
489const observe = (
490 project: string,
491 id: string,
492 name: string | undefined,
493 service: string | undefined,
494) =>
495 Effect.gen(function* () {
496 if (name !== undefined) {
497 const existing = yield* getByName(name);
498 if (existing !== undefined) return existing;
499 }
500 return yield* findOwned(project, id, service);
501 });
502
503export const ServicesServiceLevelObjectiveProvider = () =>
504 Provider.succeed(ServicesServiceLevelObjective, {
505 stables: [
506 "name",
507 "serviceLevelObjectiveId",
508 "service",
509 "serviceId",
510 "project",
511 ],
512
513 diff: Effect.fn(function* ({ news, olds, output }) {
514 if (!isResolved(news)) return undefined;
515 const previousId =
516 olds?.serviceLevelObjectiveId ?? output?.serviceLevelObjectiveId;
517 const idChanged =
518 news.serviceLevelObjectiveId !== undefined &&
519 previousId !== undefined &&
520 news.serviceLevelObjectiveId !== previousId;
521 const previousService = olds?.service ?? output?.service;
522 const nextService = news.service;
523 const serviceChanged =
524 previousService !== undefined &&
525 nextService !== previousService &&
526 !previousService.endsWith(`/${nextService}`) &&
527 !nextService.endsWith(
528 `/${previousService.split(SERVICES).pop() ?? ""}`,
529 );
530 if (!idChanged && !serviceChanged) return undefined;
531 return {
532 action: "replace" as const,
533 deleteFirst: !idChanged,
534 };
535 }),
536
537 read: Effect.fn(function* ({ id, output }) {
538 const env = yield* GcpEnvironment.current;
539 const existing = yield* observe(
540 env.project,
541 id,
542 output?.name,
543 output?.service,
544 );
545 if (existing === undefined) return undefined;
546 const attrs = toAttrs(existing, env.project);
547 return (yield* hasAlchemyLabels(id, tagRecord(existing.userLabels)))
548 ? attrs
549 : Unowned(attrs);
550 }),
551
552 list: () =>
553 Effect.gen(function* () {
554 const env = yield* GcpEnvironment.current;
555 return yield* listOwned(env.project);
556 }),
557
558 reconcile: Effect.fn(function* ({ id, news, output }) {
559 const env = yield* GcpEnvironment.current;
560 const service = resolveService(env.project, news.service);
561 const sloId = yield* toId(
562 id,
563 news.serviceLevelObjectiveId,
564 output?.serviceLevelObjectiveId,
565 );
566 const name = resourceName(service, sloId);
567 const displayName = news.displayName ?? sloId;
568 const desiredLabels = {
569 ...toLabels(news.labels),
570 ...(yield* createInternalLabels(id)),
571 };
572 const desiredPeriod = periodBody(news);
573
574 let current = yield* observe(
575 env.project,
576 id,
577 output?.name ?? name,
578 service,
579 );
580
581 const body = toBody(news, displayName, desiredLabels);
582
583 if (current === undefined) {
584 const created = yield* monitoring
585 .createServicesServiceLevelObjectives({
586 parent: service,
587 serviceLevelObjectiveId: sloId,
588 body,
589 })
590 .pipe(
591 Effect.catchTag("Conflict", () =>
592 getByName(name).pipe(
593 Effect.flatMap((existing) =>
594 existing !== undefined
595 ? Effect.succeed(existing)
596 : findOwned(env.project, id, service),
597 ),
598 ),
599 ),
600 );
601 current = created ?? undefined;
602 }
603
604 if (current === undefined) {
605 return yield* new ServicesServiceLevelObjectiveNotResolved({ name });
606 }
607
608 const resource = current.name ?? name;
609 const observedLabels = tagRecord(current.userLabels);
610 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
611 const labelsChanged = upsert.length > 0 || removed.length > 0;
612 const displayNameChanged = (current.displayName ?? "") !== displayName;
613 const goalChanged = (current.goal ?? 0) !== news.goal;
614 const rollingChanged =
615 (current.rollingPeriod ?? "") !== (desiredPeriod.rollingPeriod ?? "");
616 const calendarChanged =
617 (current.calendarPeriod ?? "") !== (desiredPeriod.calendarPeriod ?? "");
618 const sliChanged = !jsonEqual(
619 toSli(current.serviceLevelIndicator) ?? null,
620 news.serviceLevelIndicator,
621 );
622
623 const updateMask = [
624 displayNameChanged ? "display_name" : undefined,
625 labelsChanged ? "user_labels" : undefined,
626 goalChanged ? "goal" : undefined,
627 rollingChanged ? "rolling_period" : undefined,
628 calendarChanged ? "calendar_period" : undefined,
629 sliChanged ? "service_level_indicator" : undefined,
630 ].filter((field): field is string => field !== undefined);
631
632 if (updateMask.length > 0) {
633 current = yield* monitoring.patchServicesServiceLevelObjectives({
634 name: resource,
635 updateMask: updateMask.join(","),
636 body: {
637 ...body,
638 name: resource,
639 },
640 });
641 }
642
643 return toAttrs(current, env.project);
644 }),
645
646 delete: Effect.fn(function* ({ output }) {
647 yield* monitoring
648 .deleteServicesServiceLevelObjectives({ name: output.name })
649 .pipe(Effect.catchTag("NotFound", () => Effect.void));
650 }),
651 });