| 1 | --- |
| 2 | title: Dashboards as code |
| 3 | description: Declare Axiom dashboards — charts, layout, filter bars, saved views, and deploy annotations — as alchemy resources next to the services they observe. |
| 4 | --- |
| 5 | |
| 6 | An Axiom dashboard is a single JSON document: charts, a grid layout, |
| 7 | a time window, a refresh interval. In alchemy that document is the |
| 8 | input to one `Dashboard` resource, so a chart tweak is a reviewable |
| 9 | diff and every stage gets the same dashboard. |
| 10 | |
| 11 | This guide builds an ops dashboard chart by chart, then adds the two |
| 12 | resources that round it out: saved `View`s and deploy `Annotation`s. |
| 13 | You'll need the Axiom provider registered ([setup](/axiom/setup)) and |
| 14 | a dataset to query ([ingest](/axiom/data/ingest)). |
| 15 | |
| 16 | ## Start with an empty dashboard |
| 17 | |
| 18 | ```typescript |
| 19 | import * as Axiom from "alchemy/Axiom"; |
| 20 | |
| 21 | yield* Axiom.Dashboard("ops", { |
| 22 | dashboard: { |
| 23 | name: "Ops Overview", |
| 24 | owner: "", // org-shared — required for API tokens |
| 25 | description: "Top-level service health", |
| 26 | charts: [], |
| 27 | layout: [], |
| 28 | refreshTime: 60, // seconds: 15 | 60 | 300 |
| 29 | schemaVersion: 2, |
| 30 | timeWindowStart: "qr-now-1h", |
| 31 | timeWindowEnd: "qr-now", |
| 32 | }, |
| 33 | }); |
| 34 | ``` |
| 35 | |
| 36 | The resource takes the full dashboard document at `schemaVersion: 2`. |
| 37 | Axiom assigns the `uid` on create (exposed as an output along with |
| 38 | `id` and the timestamps); updates overwrite the whole document in |
| 39 | place, so the deployed dashboard always matches what's in your code. |
| 40 | |
| 41 | ## Add a chart |
| 42 | |
| 43 | ```typescript |
| 44 | import type { Chart, LayoutCell } from "alchemy/Axiom"; |
| 45 | |
| 46 | const errors: Chart = { |
| 47 | id: "errors-5m", |
| 48 | name: "5xx errors / 5m", |
| 49 | type: "TimeSeries", |
| 50 | query: { |
| 51 | apl: `['my-app-traces'] |
| 52 | | where status >= 500 |
| 53 | | summarize count() by bin_auto(_time)`, |
| 54 | }, |
| 55 | }; |
| 56 | |
| 57 | yield* Axiom.Dashboard("errors", { |
| 58 | dashboard: { |
| 59 | name: "Errors", |
| 60 | owner: "", |
| 61 | refreshTime: 60, |
| 62 | schemaVersion: 2, |
| 63 | timeWindowStart: "qr-now-24h", |
| 64 | timeWindowEnd: "qr-now", |
| 65 | charts: [errors], |
| 66 | layout: [{ i: errors.id, x: 0, y: 0, w: 12, h: 6 } satisfies LayoutCell], |
| 67 | }, |
| 68 | }); |
| 69 | ``` |
| 70 | |
| 71 | `chart.id` is a free-form string you pick — the layout cell's `i` |
| 72 | joins to it. There's no `dataset` field: the dataset is implicit in |
| 73 | the APL query (`['my-app-traces']`). |
| 74 | |
| 75 | ## The chart union |
| 76 | |
| 77 | ```typescript |
| 78 | const p99: Chart = { |
| 79 | id: "p99", |
| 80 | name: "p99 latency", |
| 81 | type: "Statistic", |
| 82 | query: { |
| 83 | apl: `['my-app-traces'] | summarize percentile(duration_ms, 99)`, |
| 84 | }, |
| 85 | }; |
| 86 | ``` |
| 87 | |
| 88 | `Chart` is a discriminated union over the kinds Axiom's API |
| 89 | validates: `TimeSeries`, `Table`, `Pie`, `Statistic`, `Heatmap`, and |
| 90 | `LogStream` for data charts, plus `Note` and `SmartFilter` (below). |
| 91 | Every data chart carries exactly the same payload — `{ id, name, |
| 92 | type, query: { apl } }` — and switching kinds is just changing |
| 93 | `type`. |
| 94 | |
| 95 | ## Add a note |
| 96 | |
| 97 | ```typescript |
| 98 | const readme: Chart = { |
| 99 | id: "readme", |
| 100 | type: "Note", |
| 101 | text: "## Ops overview\n\nEscalation: #ops-oncall. Runbooks linked per chart.", |
| 102 | }; |
| 103 | ``` |
| 104 | |
| 105 | `Note` is the one chart without a `query` or a `name` — its payload |
| 106 | is strictly `{ id, type, text, variant? }`, with `text` rendered as |
| 107 | markdown in the cell. |
| 108 | |
| 109 | ## Add a filter bar |
| 110 | |
| 111 | ```typescript |
| 112 | const filters: Chart = { |
| 113 | id: "env-filter", |
| 114 | type: "SmartFilter", |
| 115 | filters: [ |
| 116 | { |
| 117 | id: "environment", |
| 118 | type: "select", |
| 119 | name: "Environment", |
| 120 | selectType: "list", |
| 121 | options: [ |
| 122 | { key: "production", value: "production", default: true }, |
| 123 | { key: "staging", value: "staging" }, |
| 124 | ], |
| 125 | }, |
| 126 | ], |
| 127 | }; |
| 128 | ``` |
| 129 | |
| 130 | `SmartFilter` is the wire name for what the UI calls a filter bar. |
| 131 | Each entry is either a free-text `search` filter or a dropdown |
| 132 | `select` filter — dropdown options come from an inline `list` (as |
| 133 | here) or, with `selectType: "apl"`, from a query returning |
| 134 | `{ key, value }` rows. Other charts pick the value up by declaring a |
| 135 | query parameter named after the filter's `id`: |
| 136 | `declare query_parameters (environment:string = "")`. |
| 137 | |
| 138 | ## Lay out the grid |
| 139 | |
| 140 | ```typescript |
| 141 | layout: [ |
| 142 | { i: "env-filter", x: 0, y: 0, w: 12, h: 2 }, |
| 143 | { i: "errors-5m", x: 0, y: 2, w: 6, h: 6 }, |
| 144 | { i: "p99", x: 6, y: 2, w: 6, h: 6 }, |
| 145 | ], |
| 146 | ``` |
| 147 | |
| 148 | Each `LayoutCell` positions one chart by its `i`: `x`/`y` place it on |
| 149 | the grid, `w`/`h` size it, and optional `minW`/`minH`/`maxW`/`maxH` |
| 150 | and `static` constrain how it can be resized or moved in the UI. |
| 151 | Every chart in `charts` needs a matching cell. |
| 152 | |
| 153 | ## Compare against yesterday |
| 154 | |
| 155 | ```typescript |
| 156 | yield* Axiom.Dashboard("compare", { |
| 157 | dashboard: { |
| 158 | name: "Compare vs yesterday", |
| 159 | owner: "", |
| 160 | refreshTime: 300, |
| 161 | schemaVersion: 2, |
| 162 | timeWindowStart: "qr-now-1h", |
| 163 | timeWindowEnd: "qr-now", |
| 164 | against: "-1d", // overlay the same window from 24h ago |
| 165 | charts: [], |
| 166 | layout: [], |
| 167 | }, |
| 168 | }); |
| 169 | ``` |
| 170 | |
| 171 | `against` overlays each chart with the same query shifted by the |
| 172 | given offset — the classic "is this spike new or does it happen every |
| 173 | day at this hour" view. |
| 174 | |
| 175 | ## Payload gotchas |
| 176 | |
| 177 | Axiom's `POST /v2/dashboards` is strict, and the failure modes are |
| 178 | worth knowing up front: |
| 179 | |
| 180 | - Relative time windows use the `qr-now-{duration}` form |
| 181 | (`"qr-now-7d"`, `"qr-now"`) — plain `"now-7d"` is rejected. |
| 182 | - When authenticating with an API token, `dashboard.owner` must be |
| 183 | `""`. Axiom rewrites it to the org-shared `X-AXIOM-EVERYONE`; |
| 184 | per-user "private" dashboards aren't allowed for tokens. |
| 185 | - Data-chart payloads accept only `id`, `name`, `type`, `query` — |
| 186 | extra keys like `dataset` or `description` fail with |
| 187 | `Unrecognized keys: "<name>"`. |
| 188 | - `refreshTime` is in seconds: `15`, `60`, or `300`. |
| 189 | |
| 190 | ## Save a view |
| 191 | |
| 192 | ```typescript |
| 193 | yield* Axiom.View("recent-errors", { |
| 194 | name: "recent-errors", |
| 195 | description: "Last 100 5xx responses", |
| 196 | datasets: ["my-app-traces"], |
| 197 | aplQuery: ` |
| 198 | ['my-app-traces'] |
| 199 | | where status >= 500 |
| 200 | | order by _time desc |
| 201 | | take 100 |
| 202 | `, |
| 203 | }); |
| 204 | ``` |
| 205 | |
| 206 | A `View` is a named, shareable APL query — the investigation you keep |
| 207 | re-typing, pinned as a resource. Its `name` is the identifier, so |
| 208 | renaming a view is a replacement (delete old, create new). |
| 209 | |
| 210 | ## Mark deploys with annotations |
| 211 | |
| 212 | ```typescript |
| 213 | yield* Axiom.Annotation("deploy-1.2.3", { |
| 214 | type: "deploy", |
| 215 | title: "Release 1.2.3", |
| 216 | datasets: ["my-app-traces", "my-app-logs"], |
| 217 | time: new Date().toISOString(), |
| 218 | url: "https://github.com/acme/app/releases/tag/v1.2.3", |
| 219 | }); |
| 220 | ``` |
| 221 | |
| 222 | An `Annotation` draws a vertical marker on every chart over the |
| 223 | listed datasets — pass a single `time` for a point event or `time` + |
| 224 | `endTime` for a range (an incident window), with `type` grouping |
| 225 | markers visually. Deploy markers next to your error charts answer |
| 226 | "did the release cause this" at a glance. |
| 227 | |
| 228 | ## Where next |
| 229 | |
| 230 | - [Alerting](/axiom/guides/alerting) — turn the same APL queries into |
| 231 | monitors and notifiers. |
| 232 | - [Ingest](/axiom/data/ingest) — datasets, tokens, and wiring OTEL output |
| 233 | into Axiom. |
| 234 | - [Axiom overview](/axiom) — all resources at a glance. |
| 235 | |
| 236 | Reference: |
| 237 | |
| 238 | - [Dashboard API reference](/providers/axiom/dashboard) |
| 239 | - [View API reference](/providers/axiom/view) |
| 240 | - [Annotation API reference](/providers/axiom/annotation) |