1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as im from "@distilled.cloud/aws/internetmonitor";
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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface MonitorProps {
15 /**
16 * Name of the monitor. Must be 1-255 characters of letters, digits,
17 * hyphens (-), periods (.) and underscores (_). If omitted, a
18 * deterministic physical name is generated. Changing the name replaces
19 * the monitor.
20 */
21 monitorName?: string;
22 /**
23 * ARNs of the resources to monitor — VPCs, Network Load Balancers,
24 * CloudFront distributions, or Amazon WorkSpaces directories. Added and
25 * removed in place via UpdateMonitor.
26 * @default [] — no resources are monitored until some are added
27 */
28 resources?: string[];
29 /**
30 * The maximum number of city-networks (client locations and ASNs,
31 * typically ISPs) to monitor for your resources. Caps the total traffic
32 * that Internet Monitor monitors (and therefore the cost). You must set
33 * either this or `trafficPercentageToMonitor`.
34 */
35 maxCityNetworksToMonitor?: number;
36 /**
37 * The percentage of the internet-facing traffic for your application to
38 * monitor. You must set either this or `maxCityNetworksToMonitor`.
39 */
40 trafficPercentageToMonitor?: number;
41 /**
42 * Publish internet measurements for the monitor to an Amazon S3 bucket
43 * (in addition to CloudWatch Logs).
44 */
45 internetMeasurementsLogDelivery?: im.InternetMeasurementsLogDelivery;
46 /**
47 * Health-event thresholds — the percentage of overall traffic impact at
48 * which the monitor creates availability or performance health events.
49 * @default AWS creates health events at a 95% threshold
50 */
51 healthEventsConfig?: im.HealthEventsConfig;
52 /**
53 * Desired state of the monitor — `"ACTIVE"` (monitoring) or
54 * `"INACTIVE"` (paused). Toggled in place via UpdateMonitor.
55 * @default "ACTIVE"
56 */
57 status?: "ACTIVE" | "INACTIVE";
58 /**
59 * User-defined tags for the monitor.
60 */
61 tags?: Record<string, string>;
62}
63
64export interface Monitor extends Resource<
65 "AWS.InternetMonitor.Monitor",
66 MonitorProps,
67 {
68 /** The name of the monitor. */
69 monitorName: string;
70 /** The ARN of the monitor. */
71 monitorArn: string;
72 /** The current status of the monitor (`ACTIVE`, `INACTIVE`, ...). */
73 status: string;
74 /** The health-event data-processing status of the monitor. */
75 processingStatus: string | undefined;
76 /** The ARNs of the resources the monitor watches. */
77 resources: string[];
78 /** The effective cap on monitored city-networks. */
79 maxCityNetworksToMonitor: number | undefined;
80 /** The effective percentage of traffic monitored. */
81 trafficPercentageToMonitor: number | undefined;
82 /** The tags applied to the monitor. */
83 tags: Record<string, string>;
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An Amazon CloudWatch Internet Monitor **monitor** — measures internet
91 * availability and performance between your AWS-hosted application and your
92 * end users' city-networks (client locations and ASNs, typically ISPs).
93 *
94 * A monitor is built from the application resources you add to it: VPCs,
95 * Network Load Balancers, CloudFront distributions, or WorkSpaces
96 * directories. Cost is controlled by capping the number of monitored
97 * city-networks (`maxCityNetworksToMonitor`) or the percentage of traffic
98 * monitored (`trafficPercentageToMonitor`).
99 *
100 * ### Creating a Monitor
101 * **Example:** Monitor for a VPC
102 * ```typescript
103 * import * as InternetMonitor from "alchemy/AWS/InternetMonitor";
104 *
105 * const monitor = yield* InternetMonitor.Monitor("AppMonitor", {
106 * resources: [`arn:aws:ec2:us-east-1:123456789012:vpc/${vpc.vpcId}`],
107 * maxCityNetworksToMonitor: 100,
108 * });
109 * ```
110 *
111 * **Example:** Monitor a percentage of traffic
112 * ```typescript
113 * const monitor = yield* InternetMonitor.Monitor("AppMonitor", {
114 * resources: [cloudfrontDistributionArn],
115 * trafficPercentageToMonitor: 50,
116 * });
117 * ```
118 *
119 * ### Health Events
120 * **Example:** Custom health-event thresholds
121 * ```typescript
122 * const monitor = yield* InternetMonitor.Monitor("AppMonitor", {
123 * resources: [vpcArn],
124 * maxCityNetworksToMonitor: 100,
125 * healthEventsConfig: {
126 * AvailabilityScoreThreshold: 90,
127 * PerformanceScoreThreshold: 90,
128 * },
129 * });
130 * ```
131 *
132 * ### Log Delivery
133 * **Example:** Publish measurements to S3
134 * ```typescript
135 * const monitor = yield* InternetMonitor.Monitor("AppMonitor", {
136 * resources: [vpcArn],
137 * maxCityNetworksToMonitor: 100,
138 * internetMeasurementsLogDelivery: {
139 * S3Config: {
140 * BucketName: bucket.bucketName,
141 * LogDeliveryStatus: "ENABLED",
142 * },
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 */
149export const Monitor = Resource<Monitor>("AWS.InternetMonitor.Monitor");
150
151/** Normalize the wire tag map (values may be undefined) to a plain record. */
152const toTagRecord = (
153 tags: { [key: string]: string | undefined } | undefined,
154): Record<string, string> =>
155 Object.fromEntries(
156 Object.entries(tags ?? {}).filter(
157 (entry): entry is [string, string] => entry[1] !== undefined,
158 ),
159 );
160
161const sameJson = (a: unknown, b: unknown): boolean =>
162 JSON.stringify(a) === JSON.stringify(b);
163
164export const MonitorProvider = () =>
165 Provider.effect(
166 Monitor,
167 Effect.gen(function* () {
168 const createName = Effect.fn(function* (id: string, props: MonitorProps) {
169 return props.monitorName ?? (yield* createPhysicalName({ id }));
170 });
171
172 const readMonitor = Effect.fn(function* (name: string) {
173 return yield* im
174 .getMonitor({ MonitorName: name })
175 .pipe(
176 Effect.catchTag("ResourceNotFoundException", () =>
177 Effect.succeed(undefined),
178 ),
179 );
180 });
181
182 // Bounded wait for the monitor to leave PENDING (create and updates
183 // both transition PENDING -> ACTIVE/INACTIVE, typically in seconds).
184 const waitUntilSettled = Effect.fn(function* (name: string) {
185 const policy = Schedule.max([
186 Schedule.fixed("10 seconds"),
187 Schedule.recurs(8),
188 ]);
189 return yield* readMonitor(name).pipe(
190 Effect.flatMap((monitor) => {
191 if (monitor === undefined) {
192 return Effect.fail(
193 new Error(`Internet Monitor monitor '${name}' not found`),
194 );
195 }
196 if (monitor.Status === "PENDING") {
197 return Effect.fail(
198 new Error(
199 `Internet Monitor monitor '${name}' is still PENDING`,
200 ),
201 );
202 }
203 return Effect.succeed(monitor);
204 }),
205 Effect.retry({ schedule: policy }),
206 );
207 });
208
209 const toAttrs = (monitor: im.GetMonitorOutput) => ({
210 monitorName: monitor.MonitorName,
211 monitorArn: monitor.MonitorArn,
212 status: monitor.Status,
213 processingStatus: monitor.ProcessingStatus,
214 resources: [...monitor.Resources],
215 maxCityNetworksToMonitor: monitor.MaxCityNetworksToMonitor,
216 trafficPercentageToMonitor: monitor.TrafficPercentageToMonitor,
217 tags: toTagRecord(monitor.Tags),
218 });
219
220 return Monitor.Provider.of({
221 stables: ["monitorName", "monitorArn"],
222
223 list: () =>
224 im.listMonitors.items({}).pipe(
225 Stream.runCollect,
226 Effect.flatMap((monitors) =>
227 Effect.forEach(
228 Array.from(monitors),
229 (monitor) =>
230 im.getMonitor({ MonitorName: monitor.MonitorName }).pipe(
231 Effect.map(toAttrs),
232 // Tolerate delete races between list and get.
233 Effect.catchTag("ResourceNotFoundException", () =>
234 Effect.succeed(undefined),
235 ),
236 ),
237 { concurrency: 4 },
238 ),
239 ),
240 Effect.map((attrs) =>
241 attrs.flatMap((a) => (a === undefined ? [] : [a])),
242 ),
243 ),
244
245 read: Effect.fn(function* ({ id, olds, output }) {
246 const name =
247 output?.monitorName ?? (yield* createName(id, olds ?? {}));
248 const monitor = yield* readMonitor(name);
249 if (monitor === undefined) return undefined;
250 const attrs = toAttrs(monitor);
251 return (yield* hasAlchemyTags(id, attrs.tags))
252 ? attrs
253 : Unowned(attrs);
254 }),
255
256 diff: Effect.fn(function* ({ id, news, olds }) {
257 if (!isResolved(news)) return undefined;
258 // The name is the only immutable property; everything else is
259 // updatable in place via UpdateMonitor.
260 if ((yield* createName(id, olds)) !== (yield* createName(id, news))) {
261 return { action: "replace" } as const;
262 }
263 return undefined;
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output, session }) {
267 const props = news!;
268 const name = output?.monitorName ?? (yield* createName(id, props));
269 const internalTags = yield* createInternalTags(id);
270 const desiredTags = { ...props.tags, ...internalTags };
271 const desiredStatus = props.status ?? "ACTIVE";
272
273 // 1. Observe — cloud state is authoritative.
274 let observed = yield* readMonitor(name);
275
276 // 2. Ensure — create if missing; a ConflictException means a peer
277 // created the same-named monitor concurrently, so re-observe.
278 if (observed === undefined) {
279 yield* im
280 .createMonitor({
281 MonitorName: name,
282 Resources: props.resources,
283 MaxCityNetworksToMonitor: props.maxCityNetworksToMonitor,
284 TrafficPercentageToMonitor: props.trafficPercentageToMonitor,
285 InternetMeasurementsLogDelivery:
286 props.internetMeasurementsLogDelivery,
287 HealthEventsConfig: props.healthEventsConfig,
288 Tags: desiredTags,
289 })
290 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
291 }
292
293 // Creation (and prior updates) surface as PENDING; wait (bounded)
294 // so UpdateMonitor is not rejected mid-transition.
295 observed = yield* waitUntilSettled(name);
296
297 // 3. Sync — compute the update delta from OBSERVED state and
298 // apply a single UpdateMonitor only when something changed.
299 const update: im.UpdateMonitorInput = { MonitorName: name };
300 let mutated = false;
301
302 if (props.resources !== undefined) {
303 const observedResources = observed.Resources;
304 const toAdd = props.resources.filter(
305 (arn) => !observedResources.includes(arn),
306 );
307 const toRemove = observedResources.filter(
308 (arn) => !props.resources!.includes(arn),
309 );
310 if (toAdd.length > 0) {
311 update.ResourcesToAdd = toAdd;
312 mutated = true;
313 }
314 if (toRemove.length > 0) {
315 update.ResourcesToRemove = toRemove;
316 mutated = true;
317 }
318 }
319 if (
320 props.maxCityNetworksToMonitor !== undefined &&
321 props.maxCityNetworksToMonitor !== observed.MaxCityNetworksToMonitor
322 ) {
323 update.MaxCityNetworksToMonitor = props.maxCityNetworksToMonitor;
324 mutated = true;
325 }
326 if (
327 props.trafficPercentageToMonitor !== undefined &&
328 props.trafficPercentageToMonitor !==
329 observed.TrafficPercentageToMonitor
330 ) {
331 update.TrafficPercentageToMonitor =
332 props.trafficPercentageToMonitor;
333 mutated = true;
334 }
335 if (
336 props.internetMeasurementsLogDelivery !== undefined &&
337 !sameJson(
338 props.internetMeasurementsLogDelivery,
339 observed.InternetMeasurementsLogDelivery,
340 )
341 ) {
342 update.InternetMeasurementsLogDelivery =
343 props.internetMeasurementsLogDelivery;
344 mutated = true;
345 }
346 if (
347 props.healthEventsConfig !== undefined &&
348 !sameJson(props.healthEventsConfig, observed.HealthEventsConfig)
349 ) {
350 update.HealthEventsConfig = props.healthEventsConfig;
351 mutated = true;
352 }
353 if (
354 observed.Status !== desiredStatus &&
355 (observed.Status === "ACTIVE" || observed.Status === "INACTIVE")
356 ) {
357 update.Status = desiredStatus;
358 mutated = true;
359 }
360 if (mutated) {
361 yield* im.updateMonitor(update);
362 observed = yield* waitUntilSettled(name);
363 }
364
365 // 3b. Sync tags — diff against OBSERVED cloud tags.
366 const observedTags = toTagRecord(observed.Tags);
367 const { removed, upsert } = diffTags(observedTags, desiredTags);
368 if (upsert.length > 0) {
369 yield* im.tagResource({
370 ResourceArn: observed.MonitorArn,
371 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
372 });
373 }
374 if (removed.length > 0) {
375 yield* im.untagResource({
376 ResourceArn: observed.MonitorArn,
377 TagKeys: removed,
378 });
379 }
380
381 yield* session.note(name);
382 observed = (yield* readMonitor(name)) ?? observed;
383 return toAttrs(observed);
384 }),
385
386 delete: Effect.fn(function* ({ output }) {
387 const name = output.monitorName;
388 // DeleteMonitor rejects monitors that are not INACTIVE
389 // ("must be in inactive state before deletion") — deactivate
390 // first, then wait (bounded) for the transition to land.
391 const observed = yield* readMonitor(name);
392 if (observed === undefined) return;
393 if (observed.Status !== "INACTIVE") {
394 yield* im
395 .updateMonitor({ MonitorName: name, Status: "INACTIVE" })
396 .pipe(
397 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
398 );
399 yield* readMonitor(name).pipe(
400 Effect.flatMap((monitor) =>
401 monitor !== undefined && monitor.Status !== "INACTIVE"
402 ? Effect.fail(
403 new Error(
404 `Internet Monitor monitor '${name}' is not yet INACTIVE (status: ${monitor.Status})`,
405 ),
406 )
407 : Effect.succeed(monitor),
408 ),
409 Effect.retry({
410 schedule: Schedule.max([
411 Schedule.fixed("5 seconds"),
412 Schedule.recurs(10),
413 ]),
414 }),
415 );
416 }
417 yield* im
418 .deleteMonitor({ MonitorName: name })
419 .pipe(
420 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
421 );
422
423 // Internet Monitor auto-creates per-monitor CloudWatch log groups
424 // (/aws/internet-monitor/{name}/{byCity,byCountry,byMetro,
425 // bySubdivision}) that survive DeleteMonitor — reap them so the
426 // monitor leaves no orphans (same doctrine as the Lambda Function
427 // /aws/lambda/{name} log-group reap).
428 const logGroupPrefix = `/aws/internet-monitor/${name}`;
429 const reapLogGroups = logs
430 .describeLogGroups({ logGroupNamePrefix: logGroupPrefix })
431 .pipe(
432 Effect.map((r) => r.logGroups ?? []),
433 Effect.flatMap((groups) =>
434 Effect.forEach(
435 groups.flatMap((g) =>
436 // Exact-prefix guard: never reap a sibling monitor whose
437 // name merely starts with ours.
438 g.logGroupName !== undefined &&
439 (g.logGroupName === logGroupPrefix ||
440 g.logGroupName.startsWith(`${logGroupPrefix}/`))
441 ? [g.logGroupName]
442 : [],
443 ),
444 (logGroupName) =>
445 logs
446 .deleteLogGroup({ logGroupName })
447 .pipe(
448 Effect.catchTag(
449 "ResourceNotFoundException",
450 () => Effect.void,
451 ),
452 ),
453 { concurrency: 4 },
454 ),
455 ),
456 );
457 // Log-group creation/delivery is asynchronous — a group can
458 // materialize shortly after the monitor is gone, so sweep again on
459 // a short bounded schedule (t=0s / 10s / 20s, each sweep idempotent).
460 yield* reapLogGroups.pipe(
461 Effect.repeat({
462 schedule: Schedule.spaced("10 seconds"),
463 times: 2,
464 }),
465 );
466 }),
467 });
468 }),
469 );