1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as rum from "@distilled.cloud/aws/rum";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import type { Providers } from "../Providers.ts";
15
16/**
17 * Configuration for how the CloudWatch RUM web client collects data from
18 * your application's user sessions.
19 */
20export interface AppMonitorConfiguration {
21 /**
22 * The ID of the Amazon Cognito identity pool used to authorize the sending
23 * of data to RUM.
24 */
25 identityPoolId?: string;
26 /**
27 * Pages in your application excluded from RUM data collection. You can't
28 * specify both `excludedPages` and `includedPages`.
29 */
30 excludedPages?: string[];
31 /**
32 * If your application has pages that RUM should collect data from and
33 * others it should not, list the pages to collect here. You can't specify
34 * both `excludedPages` and `includedPages`.
35 */
36 includedPages?: string[];
37 /**
38 * Pages to be displayed with a "favorite" icon in the CloudWatch RUM
39 * console.
40 */
41 favoritePages?: string[];
42 /**
43 * The portion of user sessions to sample for RUM data collection, between
44 * 0 and 1 (e.g. `0.1` samples 10% of sessions).
45 * @default 0.1
46 */
47 sessionSampleRate?: number;
48 /**
49 * The ARN of the guest IAM role attached to the Cognito identity pool that
50 * authorizes sending data to RUM.
51 */
52 guestRoleArn?: string;
53 /**
54 * Whether the RUM web client sets a cookie so that RUM can collect data
55 * about user sessions across page views.
56 * @default false
57 */
58 allowCookies?: boolean;
59 /**
60 * The kinds of telemetry to collect: `"errors"`, `"performance"`, and/or
61 * `"http"`.
62 */
63 telemetries?: ("errors" | "performance" | "http")[];
64 /**
65 * Whether RUM sends client-side traces to AWS X-Ray for sampled sessions.
66 * @default false
67 */
68 enableXRay?: boolean;
69}
70
71export interface AppMonitorProps {
72 /**
73 * Name of the app monitor. Changing the name replaces the app monitor.
74 * @default ${app}-${stage}-${id}
75 */
76 appMonitorName?: string;
77 /**
78 * The top-level internet domain name your application has administrative
79 * authority over, e.g. `example.com` or `*.example.com`. Specify exactly
80 * one of `domain` or `domainList`.
81 */
82 domain?: string;
83 /**
84 * List of internet domain names your application has administrative
85 * authority over (up to 5). Specify exactly one of `domain` or
86 * `domainList`.
87 */
88 domainList?: string[];
89 /**
90 * Configuration for how the RUM web client collects session data.
91 */
92 appMonitorConfiguration?: AppMonitorConfiguration;
93 /**
94 * Whether the app monitor copies the telemetry data it collects to a
95 * CloudWatch Logs log group in your account (retained for 30 days).
96 * @default false
97 */
98 cwLogEnabled?: boolean;
99 /**
100 * Whether the app monitor accepts custom events sent by the RUM web
101 * client.
102 * @default "DISABLED"
103 */
104 customEvents?: "ENABLED" | "DISABLED";
105 /**
106 * Tags to apply to the app monitor. Merged with internal Alchemy tags.
107 */
108 tags?: Record<string, string>;
109}
110
111export interface AppMonitor extends Resource<
112 "AWS.RUM.AppMonitor",
113 AppMonitorProps,
114 {
115 /**
116 * Name of the app monitor.
117 */
118 appMonitorName: string;
119 /**
120 * Unique ID of the app monitor (used by the RUM web client configuration).
121 */
122 appMonitorId: string;
123 /**
124 * ARN of the app monitor.
125 */
126 appMonitorArn: string;
127 },
128 never,
129 Providers
130> {}
131
132/**
133 * An Amazon CloudWatch RUM app monitor that collects client-side telemetry
134 * (page load times, JavaScript errors, user behavior) from your web
135 * application.
136 * ### Creating App Monitors
137 * **Example:** Monitor a single domain
138 * ```typescript
139 * import * as RUM from "alchemy/AWS/RUM";
140 *
141 * const monitor = yield* RUM.AppMonitor("SiteMonitor", {
142 * domain: "example.com",
143 * });
144 * ```
145 *
146 * **Example:** Sample all sessions and collect every telemetry type
147 * ```typescript
148 * const monitor = yield* RUM.AppMonitor("SiteMonitor", {
149 * domain: "*.example.com",
150 * appMonitorConfiguration: {
151 * sessionSampleRate: 1,
152 * telemetries: ["errors", "performance", "http"],
153 * allowCookies: true,
154 * },
155 * });
156 * ```
157 *
158 * ### Log Retention and Custom Events
159 * **Example:** Copy telemetry to CloudWatch Logs and accept custom events
160 * ```typescript
161 * const monitor = yield* RUM.AppMonitor("SiteMonitor", {
162 * domainList: ["example.com", "app.example.com"],
163 * cwLogEnabled: true,
164 * customEvents: "ENABLED",
165 * });
166 * ```
167 *
168 * @resource
169 */
170export const AppMonitor = Resource<AppMonitor>("AWS.RUM.AppMonitor");
171
172/**
173 * Raised when an `AppMonitor` is configured with both or neither of
174 * `domain` / `domainList` — the API requires exactly one.
175 */
176export class RumAppMonitorInvalidDomains extends Data.TaggedError(
177 "RumAppMonitorInvalidDomains",
178)<{ message: string }> {}
179
180const validateDomains = (
181 props: Pick<AppMonitorProps, "domain" | "domainList">,
182) => {
183 const hasDomain = props.domain !== undefined;
184 const hasDomainList = (props.domainList?.length ?? 0) > 0;
185 if (hasDomain === hasDomainList) {
186 return Effect.fail(
187 new RumAppMonitorInvalidDomains({
188 message: hasDomain
189 ? "specify exactly one of domain or domainList, not both."
190 : "an AppMonitor requires either domain or domainList.",
191 }),
192 );
193 }
194 return Effect.void;
195};
196
197const sameStringList = (
198 a: readonly string[] | undefined,
199 b: readonly string[] | undefined,
200) => {
201 const left = [...(a ?? [])].sort();
202 const right = [...(b ?? [])].sort();
203 return left.length === right.length && left.every((v, i) => v === right[i]);
204};
205
206const desiredConfiguration = (
207 props: AppMonitorProps,
208): rum.AppMonitorConfiguration | undefined => {
209 const c = props.appMonitorConfiguration;
210 if (c === undefined) return undefined;
211 return {
212 IdentityPoolId: c.identityPoolId,
213 ExcludedPages: c.excludedPages,
214 IncludedPages: c.includedPages,
215 FavoritePages: c.favoritePages,
216 SessionSampleRate: c.sessionSampleRate,
217 GuestRoleArn: c.guestRoleArn,
218 AllowCookies: c.allowCookies,
219 Telemetries: c.telemetries,
220 EnableXRay: c.enableXRay,
221 };
222};
223
224/** Compares only the fields the user explicitly configured against the observed cloud state. */
225const configurationInSync = (
226 observed: rum.AppMonitorConfiguration | undefined,
227 desired: rum.AppMonitorConfiguration | undefined,
228) => {
229 if (desired === undefined) return true;
230 const o = observed ?? {};
231 return (
232 (desired.IdentityPoolId === undefined ||
233 o.IdentityPoolId === desired.IdentityPoolId) &&
234 (desired.ExcludedPages === undefined ||
235 sameStringList(o.ExcludedPages, desired.ExcludedPages)) &&
236 (desired.IncludedPages === undefined ||
237 sameStringList(o.IncludedPages, desired.IncludedPages)) &&
238 (desired.FavoritePages === undefined ||
239 sameStringList(o.FavoritePages, desired.FavoritePages)) &&
240 (desired.SessionSampleRate === undefined ||
241 o.SessionSampleRate === desired.SessionSampleRate) &&
242 (desired.GuestRoleArn === undefined ||
243 o.GuestRoleArn === desired.GuestRoleArn) &&
244 (desired.AllowCookies === undefined ||
245 (o.AllowCookies ?? false) === desired.AllowCookies) &&
246 (desired.Telemetries === undefined ||
247 sameStringList(o.Telemetries, desired.Telemetries)) &&
248 (desired.EnableXRay === undefined ||
249 (o.EnableXRay ?? false) === desired.EnableXRay)
250 );
251};
252
253const appMonitorArn = (region: string, accountId: string, name: string) =>
254 `arn:aws:rum:${region}:${accountId}:appmonitor/${name}`;
255
256export const AppMonitorProvider = () =>
257 Provider.effect(
258 AppMonitor,
259 Effect.gen(function* () {
260 const createName = Effect.fn(function* (
261 id: string,
262 props: Pick<AppMonitorProps, "appMonitorName">,
263 ) {
264 return (
265 props.appMonitorName ??
266 (yield* createPhysicalName({ id, maxLength: 64 }))
267 );
268 });
269
270 const observeMonitor = (name: string) =>
271 rum.getAppMonitor({ Name: name }).pipe(
272 Effect.map((r) => r.AppMonitor),
273 Effect.catchTag("ResourceNotFoundException", () =>
274 Effect.succeed(undefined),
275 ),
276 );
277
278 return AppMonitor.Provider.of({
279 stables: ["appMonitorName", "appMonitorId", "appMonitorArn"],
280 list: () =>
281 Effect.gen(function* () {
282 const { accountId, region } = yield* AWSEnvironment.current;
283 const summaries = yield* rum.listAppMonitors
284 .items({})
285 .pipe(Stream.runCollect);
286 return Array.from(summaries).flatMap((monitor) =>
287 monitor.Name !== undefined && monitor.Id !== undefined
288 ? [
289 {
290 appMonitorName: monitor.Name,
291 appMonitorId: monitor.Id,
292 appMonitorArn: appMonitorArn(
293 region,
294 accountId,
295 monitor.Name,
296 ),
297 },
298 ]
299 : [],
300 );
301 }),
302 read: Effect.fn(function* ({ id, olds, output }) {
303 const { accountId, region } = yield* AWSEnvironment.current;
304 const name =
305 output?.appMonitorName ?? (yield* createName(id, olds ?? {}));
306 const found = yield* observeMonitor(name);
307 if (found?.Id === undefined) return undefined;
308 const attrs = {
309 appMonitorName: name,
310 appMonitorId: found.Id,
311 appMonitorArn: appMonitorArn(region, accountId, name),
312 };
313 const tags = Object.fromEntries(
314 Object.entries(found.Tags ?? {}).filter(
315 (t): t is [string, string] => t[1] !== undefined,
316 ),
317 );
318 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
319 }),
320 diff: Effect.fn(function* ({ id, news, olds }) {
321 if (!isResolved(news)) return undefined;
322 yield* validateDomains(news ?? {});
323 const oldName = yield* createName(id, olds ?? {});
324 const newName = yield* createName(id, news ?? {});
325 if (oldName !== newName) {
326 return { action: "replace" } as const;
327 }
328 // fall through: engine default update logic for mutable fields
329 }),
330 reconcile: Effect.fn(function* ({ id, news, output, session }) {
331 yield* validateDomains(news);
332 const { accountId, region } = yield* AWSEnvironment.current;
333 const name = output?.appMonitorName ?? (yield* createName(id, news));
334 const arn = appMonitorArn(region, accountId, name);
335 const internalTags = yield* createInternalTags(id);
336 const desiredTags: Record<string, string> = {
337 ...news.tags,
338 ...internalTags,
339 };
340 const desiredConfig = desiredConfiguration(news);
341 const desiredCwLogEnabled = news.cwLogEnabled ?? false;
342 const desiredCustomEvents = news.customEvents ?? "DISABLED";
343
344 // 1. OBSERVE — cloud state is authoritative; output is only a
345 // cache of the derived physical name.
346 let live = yield* observeMonitor(name);
347
348 // 2. ENSURE — create when missing; a concurrent create surfaces as
349 // the typed ConflictException, which we treat as a race and
350 // re-observe. CreateAppMonitor is eventually consistent — an
351 // immediate GetAppMonitor can still miss it, and persisting an
352 // undefined appMonitorId poisons every downstream binding env —
353 // so poll (bounded) until the monitor is observable.
354 if (live === undefined) {
355 yield* rum
356 .createAppMonitor({
357 Name: name,
358 Domain: news.domain,
359 DomainList: news.domainList,
360 Tags: desiredTags,
361 AppMonitorConfiguration: desiredConfig,
362 CwLogEnabled: desiredCwLogEnabled,
363 CustomEvents: { Status: desiredCustomEvents },
364 })
365 .pipe(
366 Effect.asVoid,
367 Effect.catchTag("ConflictException", () => Effect.void),
368 );
369 live = yield* observeMonitor(name).pipe(
370 Effect.repeat({
371 schedule: Schedule.spaced("2 seconds"),
372 until: (monitor) => monitor !== undefined,
373 times: 15,
374 }),
375 );
376 }
377
378 // 3. SYNC — diff the OBSERVED domain(s), configuration, log
379 // setting, and custom-events status against the desired state;
380 // update only on drift.
381 const inSync =
382 live !== undefined &&
383 (news.domain === undefined || live.Domain === news.domain) &&
384 (news.domainList === undefined ||
385 sameStringList(live.DomainList, news.domainList)) &&
386 configurationInSync(live.AppMonitorConfiguration, desiredConfig) &&
387 (live.DataStorage?.CwLog?.CwLogEnabled ?? false) ===
388 desiredCwLogEnabled &&
389 (live.CustomEvents?.Status ?? "DISABLED") === desiredCustomEvents;
390 if (!inSync) {
391 yield* rum.updateAppMonitor({
392 Name: name,
393 Domain: news.domain,
394 DomainList: news.domainList,
395 AppMonitorConfiguration: desiredConfig,
396 CwLogEnabled: desiredCwLogEnabled,
397 CustomEvents: { Status: desiredCustomEvents },
398 });
399 live = yield* observeMonitor(name);
400 }
401
402 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
403 // converges (create-time Tags only apply on first create).
404 const currentTags = Object.fromEntries(
405 Object.entries(live?.Tags ?? {}).filter(
406 (t): t is [string, string] => t[1] !== undefined,
407 ),
408 );
409 const { upsert, removed } = diffTags(currentTags, desiredTags);
410 if (upsert.length > 0) {
411 yield* rum.tagResource({
412 ResourceArn: arn,
413 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
414 });
415 }
416 if (removed.length > 0) {
417 yield* rum.untagResource({ ResourceArn: arn, TagKeys: removed });
418 }
419
420 yield* session.note(name);
421 return {
422 appMonitorName: name,
423 appMonitorId: live?.Id ?? output?.appMonitorId!,
424 appMonitorArn: arn,
425 };
426 }),
427 delete: Effect.fn(function* ({ output }) {
428 yield* rum.deleteAppMonitor({ Name: output.appMonitorName }).pipe(
429 // idempotent — the monitor may already be gone
430 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
431 );
432
433 // When cwLogEnabled is (or was ever) on, RUM vends telemetry into
434 // a log group named
435 // /aws/vendedlogs/RUMService_{appMonitorName}{first 8 hex chars of Id}
436 // and deleteAppMonitor does NOT remove it — without this reap every
437 // deleted log-enabled monitor leaks an orphaned log group. Match
438 // the observed groups against the monitor's Id so a sibling
439 // monitor whose name extends ours is never reaped by accident.
440 const logGroupPrefix = `/aws/vendedlogs/RUMService_${output.appMonitorName}`;
441 const idHex = output.appMonitorId.replaceAll("-", "");
442 const groups = yield* logs
443 .describeLogGroups({ logGroupNamePrefix: logGroupPrefix })
444 .pipe(Effect.map((r) => r.logGroups ?? []));
445 yield* Effect.forEach(
446 groups.flatMap((g) =>
447 g.logGroupName !== undefined &&
448 idHex.startsWith(g.logGroupName.slice(logGroupPrefix.length))
449 ? [g.logGroupName]
450 : [],
451 ),
452 (logGroupName) =>
453 logs
454 .deleteLogGroup({ logGroupName })
455 .pipe(
456 Effect.catchTag(
457 "ResourceNotFoundException",
458 () => Effect.void,
459 ),
460 ),
461 );
462 }),
463 });
464 }),
465 );