1import * as AWS from "@/AWS";
2import * as Output from "@/Output";
3import {
4 AppMonitor,
5 type MetricDefinition,
6 MetricsDestination,
7 ResourcePolicy,
8} from "@/AWS/RUM";
9import * as Test from "@/Test/Alchemy";
10import * as rum from "@distilled.cloud/aws/rum";
11import * as sts from "@distilled.cloud/aws/sts";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Stream from "effect/Stream";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18const MONITOR_NAME = "alchemy-test-rum-config-monitor";
19
20const listDefinitions = () =>
21 rum.batchGetRumMetricDefinitions
22 .items({ AppMonitorName: MONITOR_NAME, Destination: "CloudWatch" })
23 .pipe(
24 Stream.runCollect,
25 Effect.map((c) => Array.from(c)),
26 Effect.catchTag("ResourceNotFoundException", () =>
27 Effect.succeed([] as rum.MetricDefinition[]),
28 ),
29 );
30
31const listDestinations = () =>
32 rum.listRumMetricsDestinations.items({ AppMonitorName: MONITOR_NAME }).pipe(
33 Stream.runCollect,
34 Effect.map((c) => Array.from(c)),
35 Effect.catchTag("ResourceNotFoundException", () =>
36 Effect.succeed([] as rum.MetricDestinationSummary[]),
37 ),
38 );
39
40test.provider(
41 "metrics destination + resource policy lifecycle on one app monitor",
42 (stack) =>
43 Effect.gen(function* () {
44 yield* stack.destroy();
45
46 const { Account } = yield* sts.getCallerIdentity({});
47 // RUM resource policies only accept rum:PutRumEvents actions — rotate
48 // the policy document by its Sid instead.
49 const policyFor = (arn: string, sid: string) =>
50 JSON.stringify({
51 Version: "2012-10-17",
52 Statement: [
53 {
54 Sid: sid,
55 Effect: "Allow",
56 Principal: { AWS: `arn:aws:iam::${Account}:root` },
57 Action: ["rum:PutRumEvents"],
58 Resource: arn,
59 },
60 ],
61 });
62
63 const program = (definitions: MetricDefinition[], policySid: string) =>
64 Effect.gen(function* () {
65 const monitor = yield* AppMonitor("ConfigMonitor", {
66 appMonitorName: MONITOR_NAME,
67 domain: "example.com",
68 });
69 const metrics = yield* MetricsDestination("Metrics", {
70 appMonitorName: monitor.appMonitorName,
71 destination: "CloudWatch",
72 metricDefinitions: definitions,
73 });
74 const policy = yield* ResourcePolicy("Policy", {
75 appMonitorName: monitor.appMonitorName,
76 policyDocument: Output.map(monitor.appMonitorArn, (arn: string) =>
77 policyFor(arn, policySid),
78 ),
79 });
80 return { monitor, metrics, policy };
81 });
82
83 // Extended metrics require the event pattern that matches the metric.
84 const sessionCountPattern = JSON.stringify({
85 event_type: ["com.amazon.rum.session_start_event"],
86 });
87 const jsErrorCountPattern = JSON.stringify({
88 event_type: ["com.amazon.rum.js_error_event"],
89 });
90
91 // Create: one extended metric definition + a PutRumEvents policy.
92 const created = yield* stack.deploy(
93 program(
94 [{ name: "SessionCount", eventPattern: sessionCountPattern }],
95 "AlchemyRumTest",
96 ),
97 );
98 expect(created.metrics.appMonitorName).toBe(MONITOR_NAME);
99 expect(created.metrics.destination).toBe("CloudWatch");
100 const firstRevision = created.policy.policyRevisionId;
101 expect(firstRevision).toBeTruthy();
102
103 // Out-of-band verification via distilled.
104 const destinations = yield* listDestinations();
105 expect(destinations.map((d) => d.Destination)).toContain("CloudWatch");
106 const defs = yield* listDefinitions();
107 expect(defs.map((d) => d.Name)).toEqual(["SessionCount"]);
108
109 const policy = yield* rum.getResourcePolicy({ Name: MONITOR_NAME });
110 expect(policy.PolicyDocument).toContain("rum:PutRumEvents");
111
112 // Update: modify SessionCount in place (dimension keys — the pattern
113 // must also filter on the dimension's event field), add JsErrorCount
114 // (batch create). The policy document is unchanged — its revision must
115 // be stable (no-op skipped the put).
116 const sessionCountByBrowserPattern = JSON.stringify({
117 event_type: ["com.amazon.rum.session_start_event"],
118 metadata: { browserName: ["Chrome", "Firefox", "Safari"] },
119 });
120 const updated = yield* stack.deploy(
121 program(
122 [
123 {
124 name: "SessionCount",
125 eventPattern: sessionCountByBrowserPattern,
126 dimensionKeys: { "metadata.browserName": "BrowserName" },
127 },
128 { name: "JsErrorCount", eventPattern: jsErrorCountPattern },
129 ],
130 "AlchemyRumTest",
131 ),
132 );
133 expect(updated.policy.policyRevisionId).toBe(firstRevision);
134
135 const defsAfterUpdate = yield* listDefinitions();
136 expect(defsAfterUpdate.map((d) => d.Name).sort()).toEqual([
137 "JsErrorCount",
138 "SessionCount",
139 ]);
140 expect(
141 defsAfterUpdate.find((d) => d.Name === "SessionCount")?.DimensionKeys,
142 ).toEqual({ "metadata.browserName": "BrowserName" });
143
144 // Update: drop SessionCount (batch delete) and rotate the policy
145 // document (revision changes).
146 const rotated = yield* stack.deploy(
147 program(
148 [{ name: "JsErrorCount", eventPattern: jsErrorCountPattern }],
149 "AlchemyRumTestRotated",
150 ),
151 );
152 expect(rotated.policy.policyRevisionId).not.toBe(firstRevision);
153
154 const defsAfterDelete = yield* listDefinitions();
155 expect(defsAfterDelete.map((d) => d.Name)).toEqual(["JsErrorCount"]);
156 const rotatedPolicy = yield* rum.getResourcePolicy({
157 Name: MONITOR_NAME,
158 });
159 expect(rotatedPolicy.PolicyDocument).toContain("AlchemyRumTestRotated");
160
161 yield* stack.destroy();
162
163 // Everything died with the stack: the monitor (and with it the
164 // destination + policy) is a typed not-found.
165 const gone = yield* Effect.flip(
166 rum.getAppMonitor({ Name: MONITOR_NAME }),
167 );
168 expect(gone._tag).toBe("ResourceNotFoundException");
169 }),
170 { tags: ["provider:aws", "provider:aws:rum", "live"], timeout: 240_000 },
171);