| 1 | import * as Axiom from "@distilled.cloud/axiom"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { isResolved } from "../Diff.ts"; |
| 4 | import * as Provider from "../Provider.ts"; |
| 5 | import { Resource } from "../Resource.ts"; |
| 6 | import type { Providers } from "./Providers.ts"; |
| 7 | |
| 8 | export type MonitorProps = Axiom.CreateMonitorRequest; |
| 9 | |
| 10 | export type Monitor = Resource< |
| 11 | "Axiom.Monitor", |
| 12 | MonitorProps, |
| 13 | Axiom.CreateMonitorResponse, |
| 14 | never, |
| 15 | Providers |
| 16 | >; |
| 17 | |
| 18 | /** |
| 19 | * An Axiom monitor — a scheduled APL/MPL query that evaluates on a fixed |
| 20 | * cadence and fires alerts via {@link Notifier notifiers} when its |
| 21 | * condition is met. |
| 22 | * |
| 23 | * Three monitor `type`s are supported: |
| 24 | * |
| 25 | * - **`Threshold`** — fires when an aggregate result crosses a static |
| 26 | * `threshold` (compared with `operator`). |
| 27 | * - **`MatchEvent`** — fires for every event matching the query. |
| 28 | * - **`AnomalyDetection`** — fires when results deviate from a learned |
| 29 | * baseline by more than `tolerance` over `compareDays`. |
| 30 | * |
| 31 | * Changing `type` triggers a replacement; everything else updates in place. |
| 32 | * @see https://axiom.co/docs/monitor-data/monitors |
| 33 | * |
| 34 | * ### Creating a Monitor |
| 35 | * **Example:** Threshold: alert on >100 errors per 5m |
| 36 | * ```typescript |
| 37 | * yield* Axiom.Monitor("error-rate", { |
| 38 | * name: "High error rate", |
| 39 | * description: "Fires when error count exceeds 100/5m", |
| 40 | * type: "Threshold", |
| 41 | * aplQuery: ` |
| 42 | * ['my-app-traces'] |
| 43 | * | where status >= 500 |
| 44 | * | summarize count() by bin_auto(_time) |
| 45 | * `, |
| 46 | * operator: "Above", |
| 47 | * threshold: 100, |
| 48 | * intervalMinutes: 5, |
| 49 | * rangeMinutes: 5, |
| 50 | * alertOnNoData: false, |
| 51 | * resolvable: true, |
| 52 | * notifierIds: [slack.id, pagerduty.id], |
| 53 | * }); |
| 54 | * ``` |
| 55 | * |
| 56 | * **Example:** MatchEvent: alert on every panic |
| 57 | * ```typescript |
| 58 | * yield* Axiom.Monitor("panics", { |
| 59 | * name: "Service panic", |
| 60 | * type: "MatchEvent", |
| 61 | * aplQuery: `['my-app-logs'] | where message contains "panic:"`, |
| 62 | * intervalMinutes: 1, |
| 63 | * rangeMinutes: 1, |
| 64 | * notifierIds: [pagerduty.id], |
| 65 | * }); |
| 66 | * ``` |
| 67 | * |
| 68 | * **Example:** AnomalyDetection: deviation vs. last 7 days |
| 69 | * ```typescript |
| 70 | * yield* Axiom.Monitor("traffic-anomaly", { |
| 71 | * name: "Traffic anomaly", |
| 72 | * type: "AnomalyDetection", |
| 73 | * aplQuery: `['my-app-traces'] | summarize count() by bin_auto(_time)`, |
| 74 | * compareDays: 7, |
| 75 | * tolerance: 25, // % |
| 76 | * intervalMinutes: 15, |
| 77 | * rangeMinutes: 15, |
| 78 | * notifierIds: [slack.id], |
| 79 | * }); |
| 80 | * ``` |
| 81 | * |
| 82 | * @resource |
| 83 | * @product Monitor |
| 84 | */ |
| 85 | export const Monitor = Resource<Monitor>("Axiom.Monitor"); |
| 86 | |
| 87 | export const MonitorProvider = () => |
| 88 | Provider.effect( |
| 89 | Monitor, |
| 90 | Effect.gen(function* () { |
| 91 | const create = yield* Axiom.createMonitor; |
| 92 | const update = yield* Axiom.updateMonitor; |
| 93 | const get = yield* Axiom.getMonitor; |
| 94 | const list = yield* Axiom.getMonitors; |
| 95 | const del = yield* Axiom.deleteMonitor; |
| 96 | |
| 97 | return { |
| 98 | stables: ["id"], |
| 99 | // Enumerate every monitor in the org. Axiom exposes a single |
| 100 | // account-wide `GET /v2/monitors` collection op (no pagination) whose |
| 101 | // item schema is identical to `getMonitor`/`createMonitor`'s output, so |
| 102 | // we fetch it once and each row already carries the exact `read` |
| 103 | // Attributes shape — directly usable by `delete` with no follow-up get. |
| 104 | list: () => |
| 105 | Effect.gen(function* () { |
| 106 | const monitors = yield* list({}); |
| 107 | return monitors.map((m) => m); |
| 108 | }), |
| 109 | diff: Effect.fn(function* ({ news, output }) { |
| 110 | if (!isResolved(news)) return undefined; |
| 111 | if (output && news.type !== output.type) { |
| 112 | return { action: "replace" } as const; |
| 113 | } |
| 114 | return undefined; |
| 115 | }), |
| 116 | reconcile: Effect.fn(function* ({ news, output }) { |
| 117 | // Observe — Axiom assigns the monitor id server-side, so the only |
| 118 | // handle to a previously-created monitor is the cached |
| 119 | // `output.id`. Probe for live state with that id; treat NotFound |
| 120 | // (deleted out-of-band) as "no observed state" so we converge by |
| 121 | // re-creating. |
| 122 | const observed = output?.id |
| 123 | ? yield* get({ id: output.id }).pipe( |
| 124 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 125 | ) |
| 126 | : undefined; |
| 127 | |
| 128 | // Ensure — POST mints a new monitor with a fresh id. |
| 129 | if (observed === undefined) { |
| 130 | return yield* create(news); |
| 131 | } |
| 132 | |
| 133 | // Sync — the monitor exists; PATCH against its id with the |
| 134 | // desired props. `type` is replacement-only (handled in diff). |
| 135 | return yield* update({ ...news, id: observed.id }); |
| 136 | }), |
| 137 | delete: Effect.fn(function* ({ output }) { |
| 138 | yield* del({ id: output.id }).pipe( |
| 139 | Effect.catchTag("NotFound", () => Effect.void), |
| 140 | ); |
| 141 | }), |
| 142 | read: Effect.fn(function* ({ output }) { |
| 143 | if (!output?.id) return undefined; |
| 144 | return yield* get({ id: output.id }).pipe( |
| 145 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 146 | ); |
| 147 | }), |
| 148 | }; |
| 149 | }), |
| 150 | ); |