1import * as Axiom from "@distilled.cloud/axiom";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import type { Providers } from "./Providers.ts";
7
8export type MonitorProps = Axiom.CreateMonitorRequest;
9
10export 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 */
85export const Monitor = Resource<Monitor>("Axiom.Monitor");
86
87export 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 );