1import * as appsignals from "@distilled.cloud/aws/application-signals";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasAlchemyTags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export interface ServiceLevelObjectiveProps {
18 /**
19 * Name of the SLO (up to 127 characters, pattern
20 * `[0-9A-Za-z][-._A-Za-z0-9 ]*`). Changing the name replaces the SLO —
21 * the API has no rename operation.
22 * @default ${app}-${stage}-${id}
23 */
24 sloName?: string;
25 /**
26 * A human-readable description of the SLO. Shown in the CloudWatch
27 * console next to the SLO.
28 */
29 description?: string;
30 /**
31 * Period-based service level indicator: a CloudWatch metric (via
32 * `SliMetricConfig.MetricDataQueries` for any metric or math expression,
33 * or the `KeyAttributes`/`OperationName` shorthand for services discovered
34 * by Application Signals), a `MetricThreshold`, and a `ComparisonOperator`.
35 *
36 * Exactly one of `sliConfig` or `requestBasedSliConfig` must be provided.
37 * Switching between them replaces the SLO — the evaluation type of an
38 * existing SLO cannot be changed.
39 */
40 sliConfig?: appsignals.ServiceLevelIndicatorConfig;
41 /**
42 * Request-based service level indicator: `TotalRequestCountMetric` plus a
43 * `GoodCountMetric` or `BadCountMetric` under
44 * `RequestBasedSliMetricConfig.MonitoredRequestCountMetric`.
45 *
46 * Exactly one of `sliConfig` or `requestBasedSliConfig` must be provided.
47 * Switching between them replaces the SLO.
48 */
49 requestBasedSliConfig?: appsignals.RequestBasedServiceLevelIndicatorConfig;
50 /**
51 * The attainment goal for the SLO: the `Interval` (rolling or calendar),
52 * the `AttainmentGoal` percentage, and the `WarningThreshold`.
53 * @default rolling 7-day interval, 99% attainment, 30% warning threshold
54 */
55 goal?: appsignals.Goal;
56 /**
57 * Burn-rate windows to compute for the SLO. Each entry creates a burn-rate
58 * metric over the given look-back window.
59 */
60 burnRateConfigurations?: appsignals.BurnRateConfiguration[];
61 /**
62 * Tags to apply to the SLO. Merged with internal Alchemy tags.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface ServiceLevelObjective extends Resource<
68 "AWS.ApplicationSignals.ServiceLevelObjective",
69 ServiceLevelObjectiveProps,
70 {
71 /**
72 * Name of the service level objective.
73 */
74 sloName: string;
75 /**
76 * ARN of the service level objective.
77 */
78 sloArn: string;
79 /**
80 * How the SLO is evaluated (`PeriodBased` or `RequestBased`).
81 */
82 evaluationType: appsignals.EvaluationType | undefined;
83 },
84 never,
85 Providers
86> {}
87
88/**
89 * A CloudWatch Application Signals service level objective (SLO) that tracks
90 * an attainment goal against a service level indicator — any CloudWatch
91 * metric or metric-math expression, or a service operation discovered by
92 * Application Signals.
93 * ### Creating Service Level Objectives
94 * **Example:** Period-based SLO on a CloudWatch metric
95 * ```typescript
96 * import * as ApplicationSignals from "alchemy/AWS/ApplicationSignals";
97 *
98 * const slo = yield* ApplicationSignals.ServiceLevelObjective("ApiLatency", {
99 * description: "p99 latency under 2 seconds",
100 * sliConfig: {
101 * SliMetricConfig: {
102 * MetricDataQueries: [
103 * {
104 * Id: "m1",
105 * MetricStat: {
106 * Metric: {
107 * Namespace: "AWS/Lambda",
108 * MetricName: "Duration",
109 * Dimensions: [{ Name: "FunctionName", Value: "my-api" }],
110 * },
111 * Period: 60,
112 * Stat: "p99",
113 * },
114 * ReturnData: true,
115 * },
116 * ],
117 * },
118 * MetricThreshold: 2000,
119 * ComparisonOperator: "LessThanOrEqualTo",
120 * },
121 * goal: {
122 * Interval: { RollingInterval: { DurationUnit: "DAY", Duration: 7 } },
123 * AttainmentGoal: 99,
124 * WarningThreshold: 50,
125 * },
126 * });
127 * ```
128 *
129 * **Example:** Request-based SLO
130 * ```typescript
131 * const slo = yield* ApplicationSignals.ServiceLevelObjective("Availability", {
132 * requestBasedSliConfig: {
133 * RequestBasedSliMetricConfig: {
134 * TotalRequestCountMetric: [
135 * {
136 * Id: "total",
137 * MetricStat: {
138 * Metric: { Namespace: "AWS/ApplicationELB", MetricName: "RequestCount" },
139 * Period: 60,
140 * Stat: "Sum",
141 * },
142 * ReturnData: true,
143 * },
144 * ],
145 * MonitoredRequestCountMetric: {
146 * BadCountMetric: [
147 * {
148 * Id: "bad",
149 * MetricStat: {
150 * Metric: { Namespace: "AWS/ApplicationELB", MetricName: "HTTPCode_Target_5XX_Count" },
151 * Period: 60,
152 * Stat: "Sum",
153 * },
154 * ReturnData: true,
155 * },
156 * ],
157 * },
158 * },
159 * },
160 * goal: { AttainmentGoal: 99.9 },
161 * });
162 * ```
163 *
164 * @resource
165 */
166export const ServiceLevelObjective = Resource<ServiceLevelObjective>(
167 "AWS.ApplicationSignals.ServiceLevelObjective",
168);
169
170/**
171 * Rebuild `Date` instances that the engine's state serialization flattened
172 * to ISO strings (calendar intervals carry a `StartTime` timestamp).
173 */
174const normalizeGoal = (
175 goal: appsignals.Goal | undefined,
176): appsignals.Goal | undefined => {
177 const interval = goal?.Interval;
178 if (interval && "CalendarInterval" in interval && interval.CalendarInterval) {
179 return {
180 ...goal,
181 Interval: {
182 CalendarInterval: {
183 ...interval.CalendarInterval,
184 StartTime: new Date(interval.CalendarInterval.StartTime),
185 },
186 },
187 };
188 }
189 return goal;
190};
191
192const isPlainObject = (value: unknown): value is Record<string, unknown> =>
193 typeof value === "object" &&
194 value !== null &&
195 !Array.isArray(value) &&
196 !(value instanceof Date);
197
198const toTime = (value: unknown): number =>
199 value instanceof Date
200 ? value.getTime()
201 : new Date(value as string | number).getTime();
202
203/**
204 * Structural subset comparison: every DEFINED field of `desired` must
205 * deep-equal the corresponding field of `observed`. Extra observed fields
206 * (server-side defaults the API echoes back, e.g. `ReturnData`, resolved
207 * account ids) are ignored, so a reconcile whose desired state matches the
208 * cloud skips the update call entirely.
209 */
210const subsetMatches = (desired: unknown, observed: unknown): boolean => {
211 if (desired === undefined) return true;
212 if (desired instanceof Date || observed instanceof Date) {
213 return toTime(desired) === toTime(observed);
214 }
215 if (Array.isArray(desired)) {
216 return (
217 Array.isArray(observed) &&
218 observed.length === desired.length &&
219 desired.every((item, index) => subsetMatches(item, observed[index]))
220 );
221 }
222 if (isPlainObject(desired)) {
223 return (
224 isPlainObject(observed) &&
225 Object.entries(desired).every(([key, value]) =>
226 subsetMatches(value, observed[key]),
227 )
228 );
229 }
230 return desired === observed;
231};
232
233/** The mutable aspects of the SLO, in `UpdateServiceLevelObjective` shape. */
234const desiredState = (news: ServiceLevelObjectiveProps) => ({
235 Description: news.description,
236 SliConfig: news.sliConfig,
237 RequestBasedSliConfig: news.requestBasedSliConfig,
238 Goal: normalizeGoal(news.goal),
239 BurnRateConfigurations: news.burnRateConfigurations,
240});
241
242/** Project the observed SLO into the same shape as {@link desiredState}. */
243const observedState = (slo: appsignals.ServiceLevelObjective) => ({
244 Description: slo.Description,
245 SliConfig: slo.Sli && {
246 SliMetricConfig: slo.Sli.SliMetric,
247 MetricThreshold: slo.Sli.MetricThreshold,
248 ComparisonOperator: slo.Sli.ComparisonOperator,
249 },
250 RequestBasedSliConfig: slo.RequestBasedSli && {
251 RequestBasedSliMetricConfig: slo.RequestBasedSli.RequestBasedSliMetric,
252 MetricThreshold: slo.RequestBasedSli.MetricThreshold,
253 ComparisonOperator: slo.RequestBasedSli.ComparisonOperator,
254 },
255 Goal: slo.Goal,
256 BurnRateConfigurations: slo.BurnRateConfigurations,
257});
258
259export const ServiceLevelObjectiveProvider = () =>
260 Provider.effect(
261 ServiceLevelObjective,
262 Effect.gen(function* () {
263 const createSloName = Effect.fn(function* (
264 id: string,
265 props: Pick<ServiceLevelObjectiveProps, "sloName">,
266 ) {
267 // SLO names are limited to 127 characters.
268 return (
269 props.sloName ?? (yield* createPhysicalName({ id, maxLength: 127 }))
270 );
271 });
272
273 // `Id` accepts the SLO name or ARN interchangeably.
274 const observeSlo = (id: string) =>
275 appsignals.getServiceLevelObjective({ Id: id }).pipe(
276 Effect.map((r) => r.Slo),
277 Effect.catchTag("ResourceNotFoundException", () =>
278 Effect.succeed(undefined),
279 ),
280 );
281
282 const observedTags = (sloArn: string) =>
283 appsignals.listTagsForResource({ ResourceArn: sloArn }).pipe(
284 Effect.map((r) =>
285 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
286 ),
287 Effect.catchTag("ResourceNotFoundException", () =>
288 Effect.succeed({} as Record<string, string>),
289 ),
290 );
291
292 return ServiceLevelObjective.Provider.of({
293 stables: ["sloName", "sloArn"],
294 list: () =>
295 Effect.gen(function* () {
296 const summaries = yield* appsignals.listServiceLevelObjectives
297 .items({})
298 .pipe(Stream.runCollect);
299 return Array.from(summaries).map((slo) => ({
300 sloName: slo.Name,
301 sloArn: slo.Arn,
302 evaluationType: slo.EvaluationType,
303 }));
304 }),
305 read: Effect.fn(function* ({ id, olds, output }) {
306 const sloName =
307 output?.sloName ?? (yield* createSloName(id, olds ?? {}));
308 const found = yield* observeSlo(sloName);
309 if (!found) return undefined;
310 const attrs = {
311 sloName: found.Name,
312 sloArn: found.Arn,
313 evaluationType: found.EvaluationType,
314 };
315 const tags = yield* observedTags(found.Arn);
316 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
317 }),
318 diff: Effect.fn(function* ({ id, news, olds }) {
319 if (!isResolved(news)) return undefined;
320 const oldName = yield* createSloName(id, olds ?? {});
321 const newName = yield* createSloName(id, news ?? {});
322 if (oldName !== newName) {
323 return { action: "replace" } as const;
324 }
325 // An SLO cannot switch between period-based and request-based
326 // evaluation — swapping the SLI config kind replaces the SLO.
327 const oldKind = olds?.requestBasedSliConfig
328 ? "RequestBased"
329 : "PeriodBased";
330 const newKind = news?.requestBasedSliConfig
331 ? "RequestBased"
332 : "PeriodBased";
333 if (oldKind !== newKind) {
334 return { action: "replace" } as const;
335 }
336 // fall through: engine default update logic for mutable fields
337 }),
338 reconcile: Effect.fn(function* ({ id, news, output, session }) {
339 const sloName = output?.sloName ?? (yield* createSloName(id, news));
340 const internalTags = yield* createInternalTags(id);
341 const desiredTags = { ...news.tags, ...internalTags };
342 const desired = desiredState(news);
343
344 // 1. OBSERVE — cloud state is authoritative; `output` is only a
345 // cache for the physical name.
346 let live = yield* observeSlo(sloName);
347
348 if (live === undefined) {
349 // 2. ENSURE — create when missing; a concurrent create surfaces
350 // as the typed ConflictException, which we treat as a race
351 // and re-observe.
352 live = yield* appsignals
353 .createServiceLevelObjective({
354 Name: sloName,
355 ...desired,
356 Tags: createTagsList(desiredTags),
357 })
358 .pipe(
359 Effect.map((r) => r.Slo),
360 Effect.catchTag("ConflictException", () => observeSlo(sloName)),
361 );
362 } else if (!subsetMatches(desired, observedState(live))) {
363 // 3. SYNC — converge every mutable aspect with a single PATCH;
364 // skipped entirely when the observed state already satisfies
365 // the desired state (update retains omitted parameters).
366 live = yield* appsignals
367 .updateServiceLevelObjective({ Id: sloName, ...desired })
368 .pipe(Effect.map((r) => r.Slo));
369 }
370
371 if (live === undefined) {
372 return yield* Effect.fail(
373 new Error(`failed to reconcile SLO '${sloName}'`),
374 );
375 }
376
377 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
378 // converges (create-time Tags only apply on first create).
379 const currentTags = yield* observedTags(live.Arn);
380 const { upsert, removed } = diffTags(currentTags, desiredTags);
381 if (upsert.length > 0) {
382 yield* appsignals.tagResource({
383 ResourceArn: live.Arn,
384 Tags: upsert,
385 });
386 }
387 if (removed.length > 0) {
388 yield* appsignals.untagResource({
389 ResourceArn: live.Arn,
390 TagKeys: removed,
391 });
392 }
393
394 yield* session.note(sloName);
395 return {
396 sloName: live.Name,
397 sloArn: live.Arn,
398 evaluationType: live.EvaluationType,
399 };
400 }),
401 delete: Effect.fn(function* ({ output }) {
402 yield* appsignals
403 .deleteServiceLevelObjective({ Id: output.sloName })
404 .pipe(
405 // Idempotent delete — already-gone is success.
406 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
407 );
408 }),
409 });
410 }),
411 );