1import * as rum from "@distilled.cloud/aws/rum";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11/**
12 * One extended or custom metric a RUM app monitor derives from its telemetry
13 * events and sends to the destination.
14 */
15export interface MetricDefinition {
16 /**
17 * Name of the metric. For extended metrics this must be one of the valid
18 * extended metric names (e.g. `SessionCount`, `JsErrorCount`); for custom
19 * metrics (CloudWatch destinations with a `namespace`) any name is
20 * allowed.
21 */
22 name: string;
23 /**
24 * Field within the event to use as the metric value. Omit to count
25 * matching events (value 1 per event).
26 */
27 valueKey?: string;
28 /**
29 * CloudWatch metric unit label (e.g. `Count`, `Milliseconds`).
30 */
31 unitLabel?: string;
32 /**
33 * Extra dimensions derived from event fields — maps the event field path
34 * (e.g. `metadata.browserName`) to the dimension name (e.g.
35 * `BrowserName`).
36 */
37 dimensionKeys?: Record<string, string>;
38 /**
39 * JSON event-pattern filter — only events matching the pattern contribute
40 * to the metric. Required for extended metrics, where it must match the
41 * metric's event type (e.g.
42 * `{"event_type":["com.amazon.rum.session_start_event"]}` for
43 * `SessionCount`).
44 */
45 eventPattern?: string;
46 /**
47 * Custom-metric namespace (CloudWatch destinations only). RUM prepends
48 * `RUM/CustomMetrics/`; it must not start with `AWS/`. Omit for extended
49 * metrics.
50 */
51 namespace?: string;
52}
53
54export interface MetricsDestinationProps {
55 /**
56 * Name of the CloudWatch RUM app monitor that sends metrics to this
57 * destination. Changing it replaces the destination.
58 */
59 appMonitorName: string;
60 /**
61 * Where the extended/custom metrics are sent. Changing it replaces the
62 * destination.
63 */
64 destination: "CloudWatch" | "Evidently";
65 /**
66 * ARN of the CloudWatch Evidently experiment receiving the metrics.
67 * Required when `destination` is `"Evidently"`; changing it replaces the
68 * destination.
69 */
70 destinationArn?: string;
71 /**
72 * ARN of the IAM role RUM assumes to write to the Evidently experiment.
73 * Required when `destination` is `"Evidently"`.
74 */
75 iamRoleArn?: string;
76 /**
77 * The extended/custom metric definitions the app monitor sends to this
78 * destination. Synced in place: added, updated, and removed definitions
79 * converge to this list.
80 */
81 metricDefinitions?: MetricDefinition[];
82}
83
84export interface MetricsDestination extends Resource<
85 "AWS.RUM.MetricsDestination",
86 MetricsDestinationProps,
87 {
88 /**
89 * Name of the app monitor sending metrics.
90 */
91 appMonitorName: string;
92 /**
93 * The destination kind (`CloudWatch` or `Evidently`).
94 */
95 destination: "CloudWatch" | "Evidently";
96 /**
97 * ARN of the Evidently experiment, when `destination` is `Evidently`.
98 */
99 destinationArn: string | undefined;
100 /**
101 * IAM role RUM assumes to write to the destination, when set.
102 */
103 iamRoleArn: string | undefined;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * A destination for CloudWatch RUM extended and custom metrics — sends
111 * metrics that a RUM app monitor derives from its telemetry events to
112 * CloudWatch or to a CloudWatch Evidently experiment, including the metric
113 * definitions themselves.
114 *
115 * ### Creating a Metrics Destination
116 * **Example:** Send Extended Metrics to CloudWatch
117 * ```typescript
118 * const monitor = yield* RUM.AppMonitor("SiteMonitor", {
119 * domain: "example.com",
120 * });
121 * const metrics = yield* RUM.MetricsDestination("SiteMetrics", {
122 * appMonitorName: monitor.appMonitorName,
123 * destination: "CloudWatch",
124 * // extended metrics require the event pattern matching the metric
125 * metricDefinitions: [
126 * {
127 * name: "SessionCount",
128 * eventPattern: JSON.stringify({
129 * event_type: ["com.amazon.rum.session_start_event"],
130 * }),
131 * },
132 * ],
133 * });
134 * ```
135 *
136 * ### Custom Metrics
137 * **Example:** Derive a Custom Metric from Events
138 * ```typescript
139 * const metrics = yield* RUM.MetricsDestination("CustomMetrics", {
140 * appMonitorName: monitor.appMonitorName,
141 * destination: "CloudWatch",
142 * metricDefinitions: [
143 * {
144 * name: "Checkouts",
145 * namespace: "MyApp",
146 * unitLabel: "Count",
147 * eventPattern: JSON.stringify({
148 * event_type: ["com.example.checkout"],
149 * }),
150 * },
151 * ],
152 * });
153 * ```
154 *
155 * @resource
156 */
157export const MetricsDestination = Resource<MetricsDestination>(
158 "AWS.RUM.MetricsDestination",
159);
160
161/**
162 * Raised when a `BatchCreateRumMetricDefinitions` /
163 * `BatchDeleteRumMetricDefinitions` call reports per-definition errors.
164 */
165export class RumMetricDefinitionsError extends Data.TaggedError(
166 "RumMetricDefinitionsError",
167)<{ message: string }> {}
168
169const toWireDefinition = (
170 def: MetricDefinition,
171): rum.MetricDefinitionRequest => ({
172 Name: def.name,
173 ValueKey: def.valueKey,
174 UnitLabel: def.unitLabel,
175 DimensionKeys: def.dimensionKeys,
176 EventPattern: def.eventPattern,
177 Namespace: def.namespace,
178});
179
180const sameRecord = (
181 a: Record<string, string | undefined> | undefined,
182 b: Record<string, string | undefined> | undefined,
183) => {
184 const left = Object.entries(a ?? {}).filter(([, v]) => v !== undefined);
185 const right = Object.entries(b ?? {}).filter(([, v]) => v !== undefined);
186 return (
187 left.length === right.length && left.every(([k, v]) => (b ?? {})[k] === v)
188 );
189};
190
191const definitionInSync = (
192 observed: rum.MetricDefinition,
193 desired: MetricDefinition,
194) =>
195 observed.ValueKey === desired.valueKey &&
196 observed.UnitLabel === desired.unitLabel &&
197 observed.EventPattern === desired.eventPattern &&
198 observed.Namespace === desired.namespace &&
199 sameRecord(observed.DimensionKeys, desired.dimensionKeys);
200
201export const MetricsDestinationProvider = () =>
202 Provider.effect(
203 MetricsDestination,
204 Effect.gen(function* () {
205 /**
206 * Observe the live destination on the monitor; typed not-found (the
207 * monitor itself is gone) → undefined.
208 */
209 const observeDestination = Effect.fn(function* (
210 props: Pick<
211 MetricsDestinationProps,
212 "appMonitorName" | "destination" | "destinationArn"
213 >,
214 ) {
215 const destinations = yield* rum.listRumMetricsDestinations
216 .items({ AppMonitorName: props.appMonitorName })
217 .pipe(
218 Stream.runCollect,
219 Effect.catchTag("ResourceNotFoundException", () =>
220 Effect.succeed([] as rum.MetricDestinationSummary[]),
221 ),
222 );
223 return Array.from(destinations).find(
224 (d) =>
225 d.Destination === props.destination &&
226 (props.destination !== "Evidently" ||
227 d.DestinationArn === props.destinationArn),
228 );
229 });
230
231 const toAttrs = (
232 props: MetricsDestinationProps,
233 observed: rum.MetricDestinationSummary | undefined,
234 ) => ({
235 appMonitorName: props.appMonitorName,
236 destination: props.destination,
237 destinationArn: props.destinationArn,
238 iamRoleArn: observed?.IamRoleArn ?? props.iamRoleArn,
239 });
240
241 return {
242 stables: ["appMonitorName", "destination", "destinationArn"],
243
244 diff: Effect.fn(function* ({ olds, news }) {
245 if (!isResolved(news)) return undefined;
246 if (
247 olds?.appMonitorName !== news.appMonitorName ||
248 olds?.destination !== news.destination ||
249 olds?.destinationArn !== news.destinationArn
250 ) {
251 return { action: "replace" } as const;
252 }
253 }),
254
255 read: Effect.fn(function* ({ olds, output }) {
256 const identity = output ?? olds;
257 if (!identity) return undefined;
258 const observed = yield* observeDestination(identity);
259 if (observed === undefined) return undefined;
260 // Metrics destinations are not taggable — ownership is implied by
261 // the owned parent app monitor.
262 return {
263 appMonitorName: identity.appMonitorName,
264 destination: identity.destination,
265 destinationArn: identity.destinationArn,
266 iamRoleArn: observed.IamRoleArn,
267 };
268 }),
269
270 reconcile: Effect.fn(function* ({ news, session }) {
271 const props = news!;
272 const key = {
273 AppMonitorName: props.appMonitorName,
274 Destination: props.destination,
275 ...(props.destination === "Evidently"
276 ? { DestinationArn: props.destinationArn }
277 : {}),
278 };
279
280 // 1. OBSERVE — the live destination list is authoritative.
281 const observed = yield* observeDestination(props);
282
283 // 2. ENSURE + SYNC destination — `putRumMetricsDestination` is a
284 // true upsert; call it when missing or when the role drifts. A
285 // concurrent put surfaces as the typed ConflictException — a
286 // race, not a failure.
287 if (
288 observed === undefined ||
289 (props.iamRoleArn !== undefined &&
290 observed.IamRoleArn !== props.iamRoleArn)
291 ) {
292 yield* rum
293 .putRumMetricsDestination({
294 ...key,
295 IamRoleArn: props.iamRoleArn,
296 })
297 .pipe(
298 Effect.asVoid,
299 Effect.catchTag("ConflictException", () => Effect.void),
300 );
301 }
302
303 // 3. SYNC metric definitions — diff the OBSERVED definitions
304 // (keyed by name) against the desired list and apply only the
305 // delta: batch-create missing, update drifted, batch-delete
306 // extraneous.
307 const live = yield* rum.batchGetRumMetricDefinitions.items(key).pipe(
308 Stream.runCollect,
309 Effect.map((c) => Array.from(c)),
310 );
311 const desired = props.metricDefinitions ?? [];
312 const liveByName = new Map(live.map((d) => [d.Name, d]));
313 const desiredNames = new Set(desired.map((d) => d.name));
314
315 const toCreate = desired.filter((d) => !liveByName.has(d.name));
316 if (toCreate.length > 0) {
317 const { Errors } = yield* rum.batchCreateRumMetricDefinitions({
318 ...key,
319 MetricDefinitions: toCreate.map(toWireDefinition),
320 });
321 if (Errors.length > 0) {
322 yield* Effect.fail(
323 new RumMetricDefinitionsError({
324 message: `failed to create ${Errors.length} metric definition(s): ${Errors.map(
325 (e) => `${e.MetricDefinition.Name}: ${e.ErrorMessage}`,
326 ).join("; ")}`,
327 }),
328 );
329 }
330 }
331
332 yield* Effect.forEach(
333 desired.flatMap((d) => {
334 const current = liveByName.get(d.name);
335 return current !== undefined && !definitionInSync(current, d)
336 ? [{ id: current.MetricDefinitionId, definition: d }]
337 : [];
338 }),
339 ({ id, definition }) =>
340 rum.updateRumMetricDefinition({
341 ...key,
342 MetricDefinitionId: id,
343 MetricDefinition: toWireDefinition(definition),
344 }),
345 );
346
347 const toDelete = live.filter((d) => !desiredNames.has(d.Name));
348 if (toDelete.length > 0) {
349 const { Errors } = yield* rum.batchDeleteRumMetricDefinitions({
350 ...key,
351 MetricDefinitionIds: toDelete.map((d) => d.MetricDefinitionId),
352 });
353 if (Errors.length > 0) {
354 yield* Effect.fail(
355 new RumMetricDefinitionsError({
356 message: `failed to delete ${Errors.length} metric definition(s): ${Errors.map(
357 (e) => `${e.MetricDefinitionId}: ${e.ErrorMessage}`,
358 ).join("; ")}`,
359 }),
360 );
361 }
362 }
363
364 yield* session.note(`${props.appMonitorName}/${props.destination}`);
365 const final = yield* observeDestination(props);
366 return toAttrs(props, final);
367 }),
368
369 delete: Effect.fn(function* ({ output }) {
370 yield* rum
371 .deleteRumMetricsDestination({
372 AppMonitorName: output.appMonitorName,
373 Destination: output.destination,
374 ...(output.destination === "Evidently"
375 ? { DestinationArn: output.destinationArn }
376 : {}),
377 })
378 .pipe(
379 // idempotent — the destination (or its whole monitor) may be gone
380 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
381 Effect.retry({
382 while: (e) => e._tag === "ConflictException",
383 schedule: Schedule.max([
384 Schedule.fixed("3 seconds"),
385 Schedule.recurs(8),
386 ]),
387 }),
388 );
389 }),
390
391 // Sub-resource keyed by its parent app monitor — enumerating it
392 // would require listing every monitor's destinations.
393 list: () => Effect.succeed([]),
394 };
395 }),
396 );