| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Output from "@/Output"; |
| 3 | import { |
| 4 | AppMonitor, |
| 5 | type MetricDefinition, |
| 6 | MetricsDestination, |
| 7 | ResourcePolicy, |
| 8 | } from "@/AWS/RUM"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import * as rum from "@distilled.cloud/aws/rum"; |
| 11 | import * as sts from "@distilled.cloud/aws/sts"; |
| 12 | import { expect } from "alchemy-test"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: AWS.providers() }); |
| 17 | |
| 18 | const MONITOR_NAME = "alchemy-test-rum-config-monitor"; |
| 19 | |
| 20 | const 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 | |
| 31 | const 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 | |
| 40 | test.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 | ); |