1import * as Axiom from "@distilled.cloud/axiom";
2import * as Effect from "effect/Effect";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import type { Providers } from "./Providers.ts";
6
7export type NotifierProps = Axiom.CreateNotifierRequest;
8
9export type Notifier = Resource<
10 "Axiom.Notifier",
11 NotifierProps,
12 Axiom.CreateNotifierResponse & { id: string },
13 never,
14 Providers
15>;
16
17/**
18 * An Axiom notifier — an alert destination (Slack, email, PagerDuty,
19 * Opsgenie, Discord, Microsoft Teams, generic webhook, or a fully custom
20 * webhook with templated body/headers) that {@link Monitor monitors} target
21 * via `notifierIds`. Exactly one channel under `properties` should be set.
22 * @see https://axiom.co/docs/monitor-data/notifiers
23 *
24 * ### Creating a Notifier
25 * **Example:** Slack incoming webhook
26 * ```typescript
27 * const slack = yield* Axiom.Notifier("ops-slack", {
28 * name: "ops-channel",
29 * properties: {
30 * slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! },
31 * },
32 * });
33 * ```
34 *
35 * **Example:** Email distribution list
36 * ```typescript
37 * yield* Axiom.Notifier("ops-email", {
38 * name: "ops-team",
39 * properties: { email: { emails: ["sre@example.com", "oncall@example.com"] } },
40 * });
41 * ```
42 *
43 * **Example:** PagerDuty integration
44 * ```typescript
45 * yield* Axiom.Notifier("pagerduty", {
46 * name: "primary-oncall",
47 * properties: {
48 * pagerduty: { routingKey: process.env.PAGERDUTY_ROUTING_KEY!, token: "" },
49 * },
50 * });
51 * ```
52 *
53 * **Example:** Custom webhook with templated body
54 * ```typescript
55 * yield* Axiom.Notifier("incident-webhook", {
56 * name: "incident.io",
57 * properties: {
58 * customWebhook: {
59 * url: "https://api.incident.io/v2/alert_events",
60 * headers: { "Content-Type": "application/json" },
61 * secretHeaders: { Authorization: `Bearer ${process.env.INCIDENT_TOKEN}` },
62 * body: '{"title": "{{.Monitor.Name}}", "status": "firing"}',
63 * },
64 * },
65 * });
66 * ```
67 *
68 * @resource
69 * @product Monitor
70 */
71export const Notifier = Resource<Notifier>("Axiom.Notifier");
72
73export const NotifierProvider = () =>
74 Provider.effect(
75 Notifier,
76 Effect.gen(function* () {
77 const create = yield* Axiom.createNotifier;
78 const update = yield* Axiom.updateNotifier;
79 const get = yield* Axiom.getNotifier;
80 const listNotifiers = yield* Axiom.getNotifiers;
81 const del = yield* Axiom.deleteNotifier;
82
83 return {
84 stables: ["id"],
85 // Enumerate every notifier in the org. Axiom exposes a single
86 // account-wide `GET /v2/notifiers` collection op (no pagination), so we
87 // fetch it once and hydrate each row into the exact `read` Attributes
88 // shape (`CreateNotifierOutput & { id: string }`) — directly usable by
89 // `delete` with no follow-up get.
90 list: () =>
91 Effect.gen(function* () {
92 const notifiers = yield* listNotifiers({});
93 return notifiers.map((n) => ({ ...n, id: n.id ?? "" }));
94 }),
95 reconcile: Effect.fn(function* ({ news, output }) {
96 // Observe — Axiom assigns the notifier id server-side, so the
97 // only handle to a previously-created notifier is the cached
98 // `output.id`. Probe for live state with that id; treat NotFound
99 // (deleted out-of-band) as "no observed state" so we converge
100 // by re-creating.
101 const observed = output?.id
102 ? yield* get({ id: output.id }).pipe(
103 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
104 )
105 : undefined;
106
107 // Ensure — POST mints a new notifier. The Axiom create response
108 // sometimes omits `id` on the body; default to "" to keep the
109 // attribute shape stable until the next read populates it.
110 if (observed === undefined) {
111 const result = yield* create(news);
112 return { ...result, id: result.id ?? "" };
113 }
114
115 // Sync — the notifier exists; PATCH against its id with the
116 // desired props. Preserve the cached id if the API response
117 // omits it.
118 const result = yield* update({ ...news, id: observed.id! });
119 return { ...result, id: result.id ?? observed.id! };
120 }),
121 delete: Effect.fn(function* ({ output }) {
122 yield* del({ id: output.id }).pipe(
123 Effect.catchTag("NotFound", () => Effect.void),
124 );
125 }),
126 read: Effect.fn(function* ({ output }) {
127 if (!output?.id) return undefined;
128 return yield* get({ id: output.id }).pipe(
129 Effect.map((current) => ({
130 ...current,
131 id: current.id ?? output.id,
132 })),
133 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
134 );
135 }),
136 };
137 }),
138 );