| 1 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 2 | import * as im from "@distilled.cloud/aws/internetmonitor"; |
| 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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | export 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 | |
| 64 | export 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 | */ |
| 149 | export const Monitor = Resource<Monitor>("AWS.InternetMonitor.Monitor"); |
| 150 | |
| 151 | /** Normalize the wire tag map (values may be undefined) to a plain record. */ |
| 152 | const 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 | |
| 161 | const sameJson = (a: unknown, b: unknown): boolean => |
| 162 | JSON.stringify(a) === JSON.stringify(b); |
| 163 | |
| 164 | export 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 | ); |