| 1 | import * as Axiom from "@distilled.cloud/axiom"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Provider from "../Provider.ts"; |
| 4 | import { Resource } from "../Resource.ts"; |
| 5 | import type { Providers } from "./Providers.ts"; |
| 6 | |
| 7 | export type AnnotationProps = Axiom.CreateAnnotationRequest; |
| 8 | |
| 9 | export type Annotation = Resource< |
| 10 | "Axiom.Annotation", |
| 11 | AnnotationProps, |
| 12 | Axiom.Annotation, |
| 13 | never, |
| 14 | Providers |
| 15 | >; |
| 16 | |
| 17 | /** |
| 18 | * An Axiom annotation — a vertical marker overlaid on charts to flag a |
| 19 | * deploy, incident, feature flag flip, or any other point/range event you |
| 20 | * want correlated with telemetry. |
| 21 | * |
| 22 | * Annotations are scoped to one or more `datasets`. Use a single `time` for |
| 23 | * a point marker or `time` + `endTime` for a range. The `type` (e.g. |
| 24 | * `"deploy"`, `"incident"`) groups markers visually in the UI. |
| 25 | * |
| 26 | * Although typically created at deploy/release time (out-of-band of |
| 27 | * regular IaC), modelling them as resources makes per-environment |
| 28 | * annotation history reproducible. |
| 29 | * @see https://axiom.co/docs/query-data/annotate-charts |
| 30 | * |
| 31 | * ### Creating an Annotation |
| 32 | * **Example:** Point-in-time deploy marker |
| 33 | * ```typescript |
| 34 | * yield* Axiom.Annotation("deploy-1.2.3", { |
| 35 | * type: "deploy", |
| 36 | * title: "Release 1.2.3", |
| 37 | * description: "https://github.com/acme/app/releases/tag/v1.2.3", |
| 38 | * datasets: ["my-app-traces", "my-app-logs"], |
| 39 | * time: new Date().toISOString(), |
| 40 | * url: "https://github.com/acme/app/releases/tag/v1.2.3", |
| 41 | * }); |
| 42 | * ``` |
| 43 | * |
| 44 | * **Example:** Incident time-range |
| 45 | * ```typescript |
| 46 | * yield* Axiom.Annotation("inc-2026-04-27", { |
| 47 | * type: "incident", |
| 48 | * title: "Database failover", |
| 49 | * datasets: ["my-app-traces"], |
| 50 | * time: "2026-04-27T18:05:00Z", |
| 51 | * endTime: "2026-04-27T18:32:00Z", |
| 52 | * url: "https://incident.io/incidents/abc123", |
| 53 | * }); |
| 54 | * ``` |
| 55 | * |
| 56 | * @resource |
| 57 | * @product Dataset |
| 58 | */ |
| 59 | export const Annotation = Resource<Annotation>("Axiom.Annotation"); |
| 60 | |
| 61 | export const AnnotationProvider = () => |
| 62 | Provider.effect( |
| 63 | Annotation, |
| 64 | Effect.gen(function* () { |
| 65 | const create = yield* Axiom.createAnnotation; |
| 66 | const update = yield* Axiom.updateAnnotation; |
| 67 | const get = yield* Axiom.getAnnotation; |
| 68 | const del = yield* Axiom.deleteAnnotation; |
| 69 | const listAnnotations = yield* Axiom.getAnnotations; |
| 70 | |
| 71 | return { |
| 72 | stables: ["id"], |
| 73 | // Axiom exposes a flat, account-wide `GET /v2/annotations` list that |
| 74 | // returns every annotation's full record in one (non-paginated) |
| 75 | // response — the exact same shape `get`/`read` produce. Hand each |
| 76 | // record back directly as the resource's Attributes. |
| 77 | list: () => |
| 78 | listAnnotations({}).pipe( |
| 79 | Effect.map((annotations) => annotations.map((a) => ({ ...a }))), |
| 80 | ), |
| 81 | reconcile: Effect.fn(function* ({ news, output }) { |
| 82 | // Observe — Axiom assigns the annotation id server-side, so the |
| 83 | // only handle to a previously-created annotation is the cached |
| 84 | // `output.id`. Probe for live state with that id; treat NotFound |
| 85 | // (deleted out-of-band, or never created) as "no observed state". |
| 86 | const observed = output?.id |
| 87 | ? yield* get({ id: output.id }).pipe( |
| 88 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 89 | ) |
| 90 | : undefined; |
| 91 | |
| 92 | // Ensure — when no observed annotation exists, POST creates one |
| 93 | // and Axiom assigns the id. |
| 94 | if (observed === undefined) { |
| 95 | return yield* create(news); |
| 96 | } |
| 97 | |
| 98 | // Sync — the annotation exists; apply desired props with PUT and |
| 99 | // preserve the stable id and original time as fallbacks. |
| 100 | const result = yield* update({ ...news, id: observed.id! }); |
| 101 | return { |
| 102 | ...result, |
| 103 | id: observed.id!, |
| 104 | time: result.time ?? output?.time ?? news.time, |
| 105 | }; |
| 106 | }), |
| 107 | delete: Effect.fn(function* ({ output }) { |
| 108 | yield* del({ id: output.id }).pipe( |
| 109 | Effect.catchTag("NotFound", () => Effect.void), |
| 110 | ); |
| 111 | }), |
| 112 | read: Effect.fn(function* ({ output }) { |
| 113 | if (!output?.id) return undefined; |
| 114 | return yield* get({ id: output.id }).pipe( |
| 115 | Effect.map((current) => ({ |
| 116 | ...current, |
| 117 | id: current.id ?? output.id, |
| 118 | time: current.time ?? output.time, |
| 119 | })), |
| 120 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 121 | ); |
| 122 | }), |
| 123 | }; |
| 124 | }), |
| 125 | ); |