@alchemy.run/axiom 0.0.0 → 2.0.0-beta.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +54 -4
  3. package/lib/Annotation.d.ts +51 -0
  4. package/lib/Annotation.d.ts.map +1 -0
  5. package/lib/Annotation.js +97 -0
  6. package/lib/Annotation.js.map +1 -0
  7. package/lib/ApiToken.d.ts +64 -0
  8. package/lib/ApiToken.d.ts.map +1 -0
  9. package/lib/ApiToken.js +123 -0
  10. package/lib/ApiToken.js.map +1 -0
  11. package/lib/AuthProvider.d.ts +40 -0
  12. package/lib/AuthProvider.d.ts.map +1 -0
  13. package/lib/AuthProvider.js +99 -0
  14. package/lib/AuthProvider.js.map +1 -0
  15. package/lib/Chart.d.ts +130 -0
  16. package/lib/Chart.d.ts.map +1 -0
  17. package/lib/Chart.js +14 -0
  18. package/lib/Chart.js.map +1 -0
  19. package/lib/Contributions.d.ts +7 -0
  20. package/lib/Contributions.d.ts.map +1 -0
  21. package/lib/Contributions.js +6 -0
  22. package/lib/Contributions.js.map +1 -0
  23. package/lib/Credentials.d.ts +10 -0
  24. package/lib/Credentials.d.ts.map +1 -0
  25. package/lib/Credentials.js +30 -0
  26. package/lib/Credentials.js.map +1 -0
  27. package/lib/Dashboard.d.ts +112 -0
  28. package/lib/Dashboard.d.ts.map +1 -0
  29. package/lib/Dashboard.js +157 -0
  30. package/lib/Dashboard.js.map +1 -0
  31. package/lib/Dataset.d.ts +120 -0
  32. package/lib/Dataset.d.ts.map +1 -0
  33. package/lib/Dataset.js +215 -0
  34. package/lib/Dataset.js.map +1 -0
  35. package/lib/Monitor.d.ts +76 -0
  36. package/lib/Monitor.d.ts.map +1 -0
  37. package/lib/Monitor.js +126 -0
  38. package/lib/Monitor.js.map +1 -0
  39. package/lib/Notifier.d.ts +65 -0
  40. package/lib/Notifier.d.ts.map +1 -0
  41. package/lib/Notifier.js +112 -0
  42. package/lib/Notifier.js.map +1 -0
  43. package/lib/OtelHeaders.d.ts +9 -0
  44. package/lib/OtelHeaders.d.ts.map +1 -0
  45. package/lib/OtelHeaders.js +9 -0
  46. package/lib/OtelHeaders.js.map +1 -0
  47. package/lib/Providers.d.ts +15 -0
  48. package/lib/Providers.d.ts.map +1 -0
  49. package/lib/Providers.js +34 -0
  50. package/lib/Providers.js.map +1 -0
  51. package/lib/Telemetry.d.ts +66 -0
  52. package/lib/Telemetry.d.ts.map +1 -0
  53. package/lib/Telemetry.js +68 -0
  54. package/lib/Telemetry.js.map +1 -0
  55. package/lib/View.d.ts +57 -0
  56. package/lib/View.d.ts.map +1 -0
  57. package/lib/View.js +100 -0
  58. package/lib/View.js.map +1 -0
  59. package/lib/VirtualField.d.ts +44 -0
  60. package/lib/VirtualField.d.ts.map +1 -0
  61. package/lib/VirtualField.js +97 -0
  62. package/lib/VirtualField.js.map +1 -0
  63. package/lib/index.d.ts +14 -0
  64. package/lib/index.d.ts.map +1 -0
  65. package/lib/index.js +14 -0
  66. package/lib/index.js.map +1 -0
  67. package/package.json +53 -3
  68. package/src/Annotation.ts +121 -0
  69. package/src/ApiToken.ts +148 -0
  70. package/src/AuthProvider.ts +135 -0
  71. package/src/Chart.ts +156 -0
  72. package/src/Contributions.ts +9 -0
  73. package/src/Credentials.ts +68 -0
  74. package/src/Dashboard.ts +192 -0
  75. package/src/Dataset.ts +326 -0
  76. package/src/Monitor.ts +148 -0
  77. package/src/Notifier.ts +136 -0
  78. package/src/OtelHeaders.ts +9 -0
  79. package/src/Providers.ts +60 -0
  80. package/src/Telemetry.ts +116 -0
  81. package/src/View.ts +130 -0
  82. package/src/VirtualField.ts +123 -0
  83. package/src/index.ts +13 -0
package/src/Dataset.ts ADDED
@@ -0,0 +1,326 @@
1
+ import * as Axiom from "@distilled.cloud/axiom";
2
+ import { Credentials } from "@distilled.cloud/axiom/Credentials";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import { isResolved } from "alchemy/Diff";
5
+ import * as Provider from "alchemy/Provider";
6
+ import { Resource } from "alchemy/Resource";
7
+ import { Stack } from "alchemy/Stack";
8
+ import { Stage } from "alchemy/Stage";
9
+ import * as Effect from "effect/Effect";
10
+ import { otelDatasetHeader } from "./OtelHeaders.ts";
11
+ import type { Providers } from "./Providers.ts";
12
+
13
+ export type DatasetKind =
14
+ | "otel:metrics:v1"
15
+ | "otel:traces:v1"
16
+ | "otel:logs:v1"
17
+ | "axiom:events:v1"
18
+ | (string & {});
19
+
20
+ export type DatasetProps = {
21
+ /**
22
+ * Dataset name. Used as the dataset's stable identifier in Axiom — changing
23
+ * this triggers a replacement.
24
+ */
25
+ name: string;
26
+ /** Free-form description shown in the Axiom UI. */
27
+ description?: string;
28
+ /**
29
+ * Edge deployment where Axiom stores and processes the dataset's data.
30
+ * Defaults to the organization's default edge deployment.
31
+ *
32
+ * Cannot be changed after creation — triggers a replacement.
33
+ */
34
+ edgeDeployment?: string;
35
+ /**
36
+ * Dataset kind. Defaults to `axiom:events:v1`.
37
+ *
38
+ * For OTEL pipelines, choose:
39
+ * - `otel:traces:v1` for traces
40
+ * - `otel:logs:v1` for logs
41
+ * - `otel:metrics:v1` for metrics
42
+ *
43
+ * Cannot be changed after creation — triggers a replacement.
44
+ */
45
+ kind?: DatasetKind;
46
+ /** Retention in days. Plan-dependent — see Axiom docs. */
47
+ retentionDays?: number;
48
+ /** Whether to enforce the configured retention period. */
49
+ useRetentionPeriod?: boolean;
50
+ };
51
+
52
+ export type Dataset = Resource<
53
+ "Axiom.Dataset",
54
+ DatasetProps,
55
+ {
56
+ id: string;
57
+ name: string;
58
+ kind: DatasetKind;
59
+ description: string;
60
+ created: string;
61
+ /** Edge deployment where the dataset's data is stored and processed. */
62
+ edgeDeployment: string;
63
+ /** Base URL for ingesting and querying data in the dataset's edge deployment. */
64
+ edgeDeploymentUrl: string;
65
+ /** Base URL for Axiom's centralized management API. */
66
+ apiBaseUrl: string;
67
+ /** Root OTLP endpoint. Most exporters auto-append the signal path. */
68
+ otelEndpoint: string;
69
+ /** OTLP/HTTP traces endpoint. */
70
+ otelTracesEndpoint: string;
71
+ /** OTLP/HTTP logs endpoint. */
72
+ otelLogsEndpoint: string;
73
+ /** OTLP/HTTP metrics endpoint. */
74
+ otelMetricsEndpoint: string;
75
+ /**
76
+ * Headers required for OTLP shipping aside from the bearer token:
77
+ * `X-Axiom-Dataset`, or `X-Axiom-Metrics-Dataset` for an
78
+ * `otel:metrics:v1` dataset.
79
+ * Add `Authorization: Bearer <AXIOM_TOKEN>` separately at runtime so the
80
+ * secret is never persisted in resource state.
81
+ */
82
+ otelHeaders: Record<string, string>;
83
+ },
84
+ never,
85
+ Providers
86
+ >;
87
+
88
+ /**
89
+ * An Axiom dataset — the top-level container that stores events, logs,
90
+ * traces, or metrics. Pick a `kind` up-front: it determines schema and how
91
+ * the data is shown in the UI, and **cannot be changed** after creation
92
+ * (changing it triggers a replacement, which deletes the data).
93
+ *
94
+ * Datasets expose their edge deployment and Axiom's OTLP/HTTP endpoints
95
+ * (`otelTracesEndpoint`, `otelLogsEndpoint`, `otelMetricsEndpoint`) as output
96
+ * attributes so you can inject them into a Worker / Lambda's env vars for
97
+ * OpenTelemetry shipping. The bearer token is **not** stored in resource state
98
+ * — supply `Authorization: Bearer <AXIOM_TOKEN>` separately at runtime.
99
+ * @see https://axiom.co/docs/reference/datasets
100
+ * @see https://axiom.co/docs/send-data/opentelemetry — OTLP endpoint reference
101
+ *
102
+ * ### Creating a Dataset
103
+ * **Example:** Logs dataset with 30-day retention
104
+ * ```typescript
105
+ * const logs = yield* Axiom.Dataset("app-logs", {
106
+ * name: "my-app-logs",
107
+ * kind: "otel:logs:v1",
108
+ * description: "Application logs from prod workers",
109
+ * retentionDays: 30,
110
+ * useRetentionPeriod: true,
111
+ * });
112
+ * ```
113
+ *
114
+ * **Example:** Separate datasets per OTEL signal
115
+ * ```typescript
116
+ * const traces = yield* Axiom.Dataset("traces", { name: "app-traces", kind: "otel:traces:v1" });
117
+ * const logs = yield* Axiom.Dataset("logs", { name: "app-logs", kind: "otel:logs:v1" });
118
+ * const metrics = yield* Axiom.Dataset("metrics", { name: "app-metrics", kind: "otel:metrics:v1" });
119
+ * ```
120
+ *
121
+ * ### Shipping OTEL data
122
+ * **Example:** Wire OTEL env vars into a Cloudflare Worker
123
+ * ```typescript
124
+ * yield* Cloudflare.Worker("api", {
125
+ * vars: {
126
+ * OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: traces.otelTracesEndpoint,
127
+ * OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: logs.otelLogsEndpoint,
128
+ * // Bearer token must come from a secret store, not the dataset state.
129
+ * OTEL_EXPORTER_OTLP_HEADERS:
130
+ * `Authorization=Bearer ${env.AXIOM_TOKEN},X-Axiom-Dataset=${traces.name}`,
131
+ * },
132
+ * });
133
+ * ```
134
+ *
135
+ * @resource
136
+ * @product Dataset
137
+ */
138
+ export const Dataset = Resource<Dataset>("Axiom.Dataset");
139
+
140
+ /**
141
+ * Axiom has no tags/labels API. The only writable field we can use to mark
142
+ * ownership is `description`. We append a deterministic marker on create so
143
+ * that on a re-apply (e.g. state was wiped) we can safely identify a dataset
144
+ * that **we** previously created and adopt it idempotently — without
145
+ * accidentally hijacking a dataset created by someone else with the same name.
146
+ */
147
+ const MARKER_RE = /\s*\[alchemy:stack=([^;]+);stage=([^;]+);id=([^\]]+)\]\s*$/;
148
+
149
+ const buildMarker = (stack: string, stage: string, id: string) =>
150
+ `[alchemy:stack=${stack};stage=${stage};id=${id}]`;
151
+
152
+ const augmentDescription = (description: string | undefined, marker: string) => {
153
+ const base = stripMarker(description ?? "");
154
+ return base.length > 0 ? `${base}\n${marker}` : marker;
155
+ };
156
+
157
+ const stripMarker = (description: string): string => description.replace(MARKER_RE, "").trimEnd();
158
+
159
+ const parseMarker = (
160
+ description: string | undefined,
161
+ ): { stack: string; stage: string; id: string } | undefined => {
162
+ if (!description) return undefined;
163
+ const m = description.match(MARKER_RE);
164
+ if (!m) return undefined;
165
+ return { stack: m[1], stage: m[2], id: m[3] };
166
+ };
167
+
168
+ export const DatasetProvider = () =>
169
+ Provider.effect(
170
+ Dataset,
171
+ Effect.gen(function* () {
172
+ const { apiBaseUrl } = yield* yield* Credentials;
173
+ const create = yield* Axiom.createDataset;
174
+ const update = yield* Axiom.updateDataset;
175
+ const get = yield* Axiom.getDataset;
176
+ const listDatasets = yield* Axiom.getDatasets;
177
+ const del = yield* Axiom.deleteDataset;
178
+
179
+ const toAttrs = Effect.fn(function* (dataset: Axiom.Dataset) {
180
+ if (!dataset.edgeDeployment || !dataset.edgeDeploymentUrl) {
181
+ return yield* Effect.fail(
182
+ new Error(`Axiom dataset "${dataset.name}" is missing its edge deployment metadata`),
183
+ );
184
+ }
185
+ const apiRoot = apiBaseUrl.replace(/\/$/, "");
186
+ const otelRoot = dataset.edgeDeploymentUrl.replace(/\/$/, "");
187
+ return {
188
+ id: dataset.id,
189
+ name: dataset.name,
190
+ kind: dataset.kind,
191
+ description: stripMarker(dataset.description),
192
+ created: dataset.created,
193
+ edgeDeployment: dataset.edgeDeployment,
194
+ edgeDeploymentUrl: dataset.edgeDeploymentUrl,
195
+ apiBaseUrl: apiRoot,
196
+ otelEndpoint: otelRoot,
197
+ otelTracesEndpoint: `${otelRoot}/v1/traces`,
198
+ otelLogsEndpoint: `${otelRoot}/v1/logs`,
199
+ otelMetricsEndpoint: `${otelRoot}/v1/metrics`,
200
+ otelHeaders: {
201
+ [otelDatasetHeader(dataset.kind === "otel:metrics:v1")]: dataset.name,
202
+ } as Record<string, string>,
203
+ };
204
+ });
205
+
206
+ return {
207
+ stables: ["id", "name", "kind", "edgeDeployment", "edgeDeploymentUrl"],
208
+ // Enumerate every dataset in the org. Axiom exposes a single
209
+ // account-wide `GET /v2/datasets` collection op (no pagination), so we
210
+ // fetch it once and hydrate each row into the exact `read`/`toAttrs`
211
+ // Attributes shape — directly usable by `delete` with no follow-up get.
212
+ list: () =>
213
+ Effect.gen(function* () {
214
+ const datasets = yield* listDatasets({});
215
+ return yield* Effect.forEach(datasets, toAttrs);
216
+ }),
217
+ diff: Effect.fn(function* ({ olds, news, output }) {
218
+ if (!isResolved(news)) return undefined;
219
+ if (output && news.name !== output.name) {
220
+ return { action: "replace" } as const;
221
+ }
222
+ if (news.kind && output && news.kind !== output.kind) {
223
+ return { action: "replace" } as const;
224
+ }
225
+ if (news.edgeDeployment && output && news.edgeDeployment !== output.edgeDeployment) {
226
+ return { action: "replace" } as const;
227
+ }
228
+ if (
229
+ news.description !== olds?.description ||
230
+ news.retentionDays !== olds?.retentionDays ||
231
+ news.useRetentionPeriod !== olds?.useRetentionPeriod
232
+ ) {
233
+ return { action: "update" } as const;
234
+ }
235
+ return undefined;
236
+ }),
237
+ reconcile: Effect.fn(function* ({ id, news, output }) {
238
+ const stack = yield* Stack;
239
+ const stage = yield* Stage;
240
+ const marker = buildMarker(stack.name, stage, id);
241
+
242
+ // Observe — Axiom's dataset path identifier is `name`, which is
243
+ // also stable input. Probe for live state by name (preferring the
244
+ // cached `output.id` when present). `read` upstream has already
245
+ // surfaced foreign datasets as `Unowned`, so by the time we land
246
+ // here mutation is safe.
247
+ const datasetId = output?.id ?? news.name;
248
+ const observed = yield* get({ dataset_id: datasetId }).pipe(
249
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
250
+ );
251
+
252
+ // Ensure — POST creates the dataset. Tolerate Conflict/
253
+ // UnprocessableEntity as a race with a peer reconciler (or with
254
+ // upstream read↔create), falling through to the sync path.
255
+ let current = observed;
256
+ if (current === undefined) {
257
+ current = yield* (
258
+ create({
259
+ name: news.name,
260
+ description: augmentDescription(news.description, marker),
261
+ edgeDeployment: news.edgeDeployment,
262
+ kind: news.kind,
263
+ retentionDays: news.retentionDays,
264
+ useRetentionPeriod: news.useRetentionPeriod,
265
+ }) as Effect.Effect<Axiom.Dataset, { readonly _tag: string }, never>
266
+ ).pipe(
267
+ Effect.catchIf(
268
+ (e): e is { readonly _tag: "Conflict" | "UnprocessableEntity" } =>
269
+ e._tag === "Conflict" || e._tag === "UnprocessableEntity",
270
+ () =>
271
+ update({
272
+ dataset_id: news.name,
273
+ description: augmentDescription(news.description, marker),
274
+ retentionDays: news.retentionDays,
275
+ useRetentionPeriod: news.useRetentionPeriod,
276
+ }),
277
+ ),
278
+ );
279
+ return yield* toAttrs(current);
280
+ }
281
+
282
+ // Sync — the dataset exists. Apply mutable aspects (description,
283
+ // retentionDays, useRetentionPeriod) via PATCH. `kind` and `name`
284
+ // are stable and replacement-only via diff above.
285
+ const desiredDescription = augmentDescription(news.description, marker);
286
+ const needsSync =
287
+ current.description !== desiredDescription ||
288
+ current.retentionDays !== news.retentionDays ||
289
+ current.useRetentionPeriod !== news.useRetentionPeriod;
290
+ if (!needsSync) {
291
+ return yield* toAttrs(current);
292
+ }
293
+ const updated = yield* update({
294
+ dataset_id: current.id,
295
+ description: desiredDescription,
296
+ retentionDays: news.retentionDays,
297
+ useRetentionPeriod: news.useRetentionPeriod,
298
+ });
299
+ return yield* toAttrs(updated);
300
+ }),
301
+ delete: Effect.fn(function* ({ output }) {
302
+ yield* del({ dataset_id: output.id }).pipe(
303
+ Effect.catchTag("NotFound", () => Effect.void),
304
+ );
305
+ }),
306
+ read: Effect.fn(function* ({ id, olds, output }) {
307
+ const stack = yield* Stack;
308
+ const stage = yield* Stage;
309
+ const datasetId = output?.id ?? olds?.name;
310
+ if (!datasetId) return undefined;
311
+ const existing = yield* get({ dataset_id: datasetId }).pipe(
312
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
313
+ );
314
+ if (!existing) return undefined;
315
+ const ownership = parseMarker(existing.description);
316
+ const isOurs =
317
+ ownership !== undefined &&
318
+ ownership.stack === stack.name &&
319
+ ownership.stage === stage &&
320
+ ownership.id === id;
321
+ const attrs = yield* toAttrs(existing);
322
+ return isOurs ? attrs : Unowned(attrs);
323
+ }),
324
+ };
325
+ }),
326
+ );
package/src/Monitor.ts ADDED
@@ -0,0 +1,148 @@
1
+ import * as Axiom from "@distilled.cloud/axiom";
2
+ import { isResolved } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import * as Effect from "effect/Effect";
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(Effect.catchTag("NotFound", () => Effect.void));
139
+ }),
140
+ read: Effect.fn(function* ({ output }) {
141
+ if (!output?.id) return undefined;
142
+ return yield* get({ id: output.id }).pipe(
143
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
144
+ );
145
+ }),
146
+ };
147
+ }),
148
+ );
@@ -0,0 +1,136 @@
1
+ import * as Axiom from "@distilled.cloud/axiom";
2
+ import * as Provider from "alchemy/Provider";
3
+ import { Resource } from "alchemy/Resource";
4
+ import * as Effect from "effect/Effect";
5
+ import type { Providers } from "./Providers.ts";
6
+
7
+ export type NotifierProps = Axiom.CreateNotifierRequest;
8
+
9
+ export 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
+ */
71
+ export const Notifier = Resource<Notifier>("Axiom.Notifier");
72
+
73
+ export 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(Effect.catchTag("NotFound", () => Effect.void));
123
+ }),
124
+ read: Effect.fn(function* ({ output }) {
125
+ if (!output?.id) return undefined;
126
+ return yield* get({ id: output.id }).pipe(
127
+ Effect.map((current) => ({
128
+ ...current,
129
+ id: current.id ?? output.id,
130
+ })),
131
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
132
+ );
133
+ }),
134
+ };
135
+ }),
136
+ );
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Axiom routes OTLP ingest by a dataset header. Traces and logs use
3
+ * `X-Axiom-Dataset`; the metrics endpoint requires `X-Axiom-Metrics-Dataset`
4
+ * instead (https://axiom.co/docs/send-data/opentelemetry).
5
+ *
6
+ * @internal
7
+ */
8
+ export const otelDatasetHeader = (metrics: boolean): string =>
9
+ metrics ? "X-Axiom-Metrics-Dataset" : "X-Axiom-Dataset";
@@ -0,0 +1,60 @@
1
+ import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
2
+ import { ProfileStoreLive } from "alchemy/Auth/Profile";
3
+ import * as Provider from "alchemy/Provider";
4
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
5
+ import * as Layer from "effect/Layer";
6
+ import { Annotation, AnnotationProvider } from "./Annotation.ts";
7
+ import { ApiToken, ApiTokenProvider } from "./ApiToken.ts";
8
+ import { AxiomAuth } from "./AuthProvider.ts";
9
+ import * as Credentials from "./Credentials.ts";
10
+ import { Dashboard, DashboardProvider } from "./Dashboard.ts";
11
+ import { Dataset, DatasetProvider } from "./Dataset.ts";
12
+ import { Monitor, MonitorProvider } from "./Monitor.ts";
13
+ import { Notifier, NotifierProvider } from "./Notifier.ts";
14
+ import { View, ViewProvider } from "./View.ts";
15
+ import { VirtualField, VirtualFieldProvider } from "./VirtualField.ts";
16
+
17
+ export { Credentials } from "@distilled.cloud/axiom/Credentials";
18
+
19
+ export class Providers extends Provider.ProviderCollection<Providers>()("Axiom") {}
20
+
21
+ export type ProviderRequirements = Layer.Services<ReturnType<typeof providers>>;
22
+
23
+ /**
24
+ * Axiom providers and credentials. Wires up Dataset, ApiToken, Notifier,
25
+ * Monitor, Dashboard, VirtualField, View and Annotation resources, and
26
+ * registers the Axiom AuthProvider so `alchemy profile edit` can configure it.
27
+ */
28
+ export const providers = () =>
29
+ Layer.effect(
30
+ Providers,
31
+ Provider.collection([
32
+ Annotation,
33
+ ApiToken,
34
+ Dashboard,
35
+ Dataset,
36
+ Monitor,
37
+ Notifier,
38
+ View,
39
+ VirtualField,
40
+ ]),
41
+ ).pipe(
42
+ Layer.provide(
43
+ Layer.mergeAll(
44
+ AnnotationProvider(),
45
+ ApiTokenProvider(),
46
+ DashboardProvider(),
47
+ DatasetProvider(),
48
+ MonitorProvider(),
49
+ NotifierProvider(),
50
+ ViewProvider(),
51
+ VirtualFieldProvider(),
52
+ ),
53
+ ),
54
+ Layer.provideMerge(Credentials.fromAuthProvider()),
55
+ Layer.provideMerge(FetchHttpClient.layer),
56
+ Layer.provideMerge(AxiomAuth),
57
+ Layer.provideMerge(ProfileStoreLive),
58
+ Layer.provideMerge(CredentialsStoreLive),
59
+ Layer.orDie,
60
+ );