| 1 | import * as rum from "@distilled.cloud/aws/rum"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import 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 | */ |
| 15 | export 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 | |
| 54 | export 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 | |
| 84 | export 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 | */ |
| 157 | export const MetricsDestination = Resource<MetricsDestination>( |
| 158 | "AWS.RUM.MetricsDestination", |
| 159 | ); |
| 160 | |
| 161 | /** |
| 162 | * Raised when a `BatchCreateRumMetricDefinitions` / |
| 163 | * `BatchDeleteRumMetricDefinitions` call reports per-definition errors. |
| 164 | */ |
| 165 | export class RumMetricDefinitionsError extends Data.TaggedError( |
| 166 | "RumMetricDefinitionsError", |
| 167 | )<{ message: string }> {} |
| 168 | |
| 169 | const 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 | |
| 180 | const 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 | |
| 191 | const 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 | |
| 201 | export 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 | ); |