1import * as Alchemy from "alchemy";
2import * as Axiom from "alchemy/Axiom";
3import type { Input } from "alchemy/Input";
4import * as Output from "alchemy/Output";
5import { Effect } from "effect";
6import { Traces } from "./Datasets.ts";
7
8/**
9 * `${stage} alchemy CLI overview` — a single dashboard answering the
10 * four questions we actually care about, laid out on a 12-column grid:
11 *
12 * 1. **How many active users are working on a project?**
13 * Distinct `alchemy.user.id` per `alchemy.git.origin_hash`.
14 * 2. **How many distinct projects are there?**
15 * Distinct `alchemy.git.origin_hash`.
16 * 3. **How many projects use CI/CD?**
17 * Distinct `alchemy.git.origin_hash` where `alchemy.ci=true`.
18 * 4. **What state stores are people using?**
19 * Sourced from `state_store.init` spans tagged with
20 * `alchemy.state_store.id` (the open-ended `StateService.id`
21 * slug; built-ins are `local` / `inmemory` / `http` /
22 * `cloudflare-http`, third-party stores get tracked automatically
23 * by setting their own slug). Emitted once per process via
24 * `recordStateStoreInit` at every `Layer.effect(State, …)` site.
25 *
26 * Project identity uses `alchemy.git.origin_hash` rather than
27 * `alchemy.user.id`: ephemeral CI runners regenerate `~/.alchemy/id`
28 * every job, which dramatically inflates the user count. The git
29 * origin hash is stable across runs of the same repo and is the
30 * closest proxy we have for "a project".
31 *
32 * All queries target `${stage}-traces` — Axiom's metrics datasets
33 * cannot be queried via APL, but every metric we emit has an
34 * equivalent span (`cli.<command>`, `provider.<op>`,
35 * `state_store.deploy`, `state_store.init`).
36 *
37 * Each chart's APL query is built with `Output.interpolate` against
38 * `traces.name` so Alchemy sequences the dashboard after the dataset
39 * exists. Otherwise Axiom would reject creation with
40 * `BadRequest: failed to validate ... entity not found`.
41 */
42export const CliOverviewDashboard = Axiom.Dashboard(
43 "CliOverview",
44 Effect.gen(function* () {
45 const stack = yield* Alchemy.Stack;
46 const traces = yield* Traces;
47 const t = traces.name;
48 const isProd = stack.stage === "prod";
49
50 const prodTracesName: Input<string> = isProd
51 ? t
52 : (yield* Axiom.Dataset.ref("Traces", { stage: "prod" })).name;
53 // Global filter bar — a `SmartFilter` chart drives every other
54 // chart's APL via `declare query_parameters` parameters:
55 //
56 // - `alchemy_version` (every stage) narrows every chart to a
57 // single alchemy version. An explicit "All" row with an empty
58 // value short-circuits the filter via
59 // `isempty(alchemy_version)`. APL-sourced filters don't get
60 // the auto-"All" that `list` filters do, so the source query
61 // unions one in.
62 //
63 // - `dataset_filter` (non-prod stages only) lets us preview
64 // dashboard changes against `prod-traces` while defaulting to
65 // the current stage's own dataset. Charts reference the
66 // dataset via `table(dataset_filter)` instead of a literal
67 // `['<name>']` so the parameter binding takes effect. On prod
68 // we skip the filter entirely and hardcode `['prod-traces']`.
69 //
70 // Fresh stages have no spans yet, so `service.version` may not
71 // exist as a column. `column_ifexists` lets the queries parse
72 // against an empty dataset (otherwise APL rejects them with
73 // `invalid field: "service.version"`).
74 // `bin_size` drives every TimeSeries chart's bucket width so a
75 // single filter flips the whole dashboard between hourly / daily /
76 // weekly aggregation. Queries reference it as
77 // `bin(_time, totimespan(bin_size))`.
78 const declarations: Input<string> = isProd
79 ? `declare query_parameters (alchemy_version:string = "", bin_size:string = "1h");`
80 : Output.interpolate`declare query_parameters (dataset_filter:string = "${t}", alchemy_version:string = "", bin_size:string = "1h");`;
81 const datasetExpr: Input<string> = isProd
82 ? Output.interpolate`['${t}']`
83 : "table(dataset_filter)";
84 const versionFilterWhere = `\n | where isempty(alchemy_version) or tostring(column_ifexists("service.version", "")) == alchemy_version`;
85
86 const charts: Input<Axiom.DashboardProps["dashboard"]["charts"]> = [
87 {
88 id: "filter-bar",
89 type: "SmartFilter",
90 name: "Filters",
91 filters: [
92 ...(isProd
93 ? []
94 : [
95 {
96 id: "dataset_filter",
97 type: "select" as const,
98 name: "dataset",
99 active: true,
100 selectType: "list" as const,
101 options: [
102 {
103 key: `${stack.stage} (this stage)`,
104 value: t,
105 default: true,
106 },
107 {
108 key: "prod (production data)",
109 value: prodTracesName,
110 },
111 ],
112 },
113 ]),
114 {
115 id: "bin_size",
116 type: "select",
117 name: "aggregation period",
118 active: true,
119 selectType: "list",
120 options: [
121 { key: "1 hour", value: "1h", default: true },
122 { key: "6 hours", value: "6h" },
123 { key: "1 day", value: "1d" },
124 { key: "7 days", value: "7d" },
125 ],
126 },
127 {
128 id: "alchemy_version",
129 type: "select",
130 name: "alchemy version",
131 active: true,
132 selectType: "query",
133 options: [],
134 query: {
135 apl: Output.interpolate`
136 ${declarations}
137 let opts = ${datasetExpr}
138 | extend sv = tostring(column_ifexists("service.version", ""))
139 | where sv != ""
140 | distinct sv
141 | sort by sv desc
142 | project key=sv, value=sv;
143 let all = print key="All", value="";
144 union all, opts
145 `,
146 },
147 },
148 ],
149 },
150 // Row 1 — top-line counts answering Qs 1, 2, 3.
151 {
152 id: "distinct-projects",
153 name: "Distinct projects (7d)",
154 type: "Statistic",
155 query: {
156 apl: Output.interpolate`
157 ${declarations}
158 ${datasetExpr}${versionFilterWhere}
159 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash'])
160 | where project != ""
161 | summarize projects=dcount(project)
162 `,
163 },
164 },
165 {
166 id: "projects-using-ci",
167 name: "Projects using CI/CD (7d)",
168 type: "Statistic",
169 query: {
170 apl: Output.interpolate`
171 ${declarations}
172 ${datasetExpr}${versionFilterWhere}
173 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash']),
174 ci=tostring(['resource.custom']['alchemy.ci'])
175 | where project != "" and ci == "true"
176 | summarize projects=dcount(project)
177 `,
178 },
179 },
180 {
181 id: "active-users-7d",
182 name: "Active users — non-CI (7d)",
183 type: "Statistic",
184 query: {
185 apl: Output.interpolate`
186 ${declarations}
187 ${datasetExpr}${versionFilterWhere}
188 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
189 ci=tostring(['resource.custom']['alchemy.ci'])
190 | where ci != "true"
191 | summarize users=dcount(uid)
192 `,
193 },
194 },
195
196 // Row 2 — Q1 broken out per-project, plus solo-vs-team split.
197 {
198 id: "users-per-project",
199 name: "Active users per project (7d)",
200 type: "Table",
201 query: {
202 apl: Output.interpolate`
203 ${declarations}
204 ${datasetExpr}${versionFilterWhere}
205 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash']),
206 uid=tostring(['resource.custom']['alchemy.user.id']),
207 ci=tostring(['resource.custom']['alchemy.ci'])
208 | where project != ""
209 | summarize users_total=dcount(uid),
210 users_local=dcountif(uid, ci != "true"),
211 users_ci=dcountif(uid, ci == "true"),
212 events=count()
213 by project
214 | order by users_total desc
215 | take 100
216 `,
217 },
218 },
219 {
220 id: "project-team-size-distribution",
221 name: "Project team-size distribution (local users / project)",
222 type: "Table",
223 query: {
224 apl: Output.interpolate`
225 ${declarations}
226 ${datasetExpr}${versionFilterWhere}
227 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash']),
228 uid=tostring(['resource.custom']['alchemy.user.id']),
229 ci=tostring(['resource.custom']['alchemy.ci'])
230 | where project != "" and ci != "true"
231 | summarize users=dcount(uid) by project
232 | extend bucket = case(
233 users == 1, "1 (solo)",
234 users <= 3, "2-3",
235 users <= 10, "4-10",
236 "11+")
237 | summarize projects=count() by bucket
238 | order by bucket asc
239 `,
240 },
241 },
242
243 // Row 3 — Q4: state-store breakdown.
244 {
245 id: "state-store-projects-by-id",
246 name: "Projects by state store (7d)",
247 type: "Table",
248 query: {
249 apl: Output.interpolate`
250 ${declarations}
251 ${datasetExpr}${versionFilterWhere}
252 | where name == "state_store.init"
253 | extend store=tostring(['attributes.custom']['alchemy.state_store.id']),
254 project=tostring(['resource.custom']['alchemy.git.origin_hash']),
255 uid=tostring(['resource.custom']['alchemy.user.id'])
256 | summarize projects=dcountif(project, project != ""),
257 users=dcount(uid),
258 inits=count()
259 by store
260 | order by projects desc
261 `,
262 },
263 },
264 {
265 id: "state-store-by-id-over-time",
266 name: "State store init by store / hour",
267 type: "TimeSeries",
268 query: {
269 apl: Output.interpolate`
270 ${declarations}
271 ${datasetExpr}${versionFilterWhere}
272 | where name == "state_store.init"
273 | extend store=tostring(['attributes.custom']['alchemy.state_store.id'])
274 | summarize count=count() by store, bin(_time, totimespan(bin_size))
275 | order by _time asc
276 `,
277 },
278 },
279
280 // Row 4 — adoption shape: project growth + CI-vs-local split.
281 {
282 id: "projects-over-time",
283 name: "Distinct projects per day",
284 type: "TimeSeries",
285 query: {
286 apl: Output.interpolate`
287 ${declarations}
288 ${datasetExpr}${versionFilterWhere}
289 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash'])
290 | where project != ""
291 | summarize projects=dcount(project) by bin(_time, totimespan(bin_size))
292 | order by _time asc
293 `,
294 },
295 },
296 {
297 id: "active-users-over-time-hourly",
298 name: "Active users (non-CI)",
299 type: "TimeSeries",
300 query: {
301 apl: Output.interpolate`
302 ${declarations}
303 ${datasetExpr}${versionFilterWhere}
304 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
305 ci=tostring(['resource.custom']['alchemy.ci'])
306 | where ci != "true" and uid != ""
307 | summarize users=dcount(uid) by bin(_time, totimespan(bin_size))
308 | order by _time asc
309 `,
310 },
311 },
312 {
313 id: "active-users-over-time-daily",
314 name: "Active users (CI vs local)",
315 type: "TimeSeries",
316 query: {
317 apl: Output.interpolate`
318 ${declarations}
319 ${datasetExpr}${versionFilterWhere}
320 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
321 ci=tostring(['resource.custom']['alchemy.ci'])
322 | where uid != ""
323 | extend bucket=iff(ci == "true", "ci", "local")
324 | summarize users=dcount(uid) by bucket, bin(_time, totimespan(bin_size))
325 | order by _time asc
326 `,
327 },
328 },
329 {
330 id: "ci-vs-local-projects",
331 name: "Projects: CI vs local per day",
332 type: "TimeSeries",
333 query: {
334 apl: Output.interpolate`
335 ${declarations}
336 ${datasetExpr}${versionFilterWhere}
337 | extend project=tostring(['resource.custom']['alchemy.git.origin_hash']),
338 ci=tostring(['resource.custom']['alchemy.ci'])
339 | where project != ""
340 | extend bucket=iff(ci == "true", "ci", "local")
341 | summarize projects=dcount(project) by bucket, bin(_time, totimespan(bin_size))
342 | order by _time asc
343 `,
344 },
345 },
346
347 // Row 5 — keep the state-store deploy health signals for the
348 // Cloudflare-hosted store (not just init, but actual deploys).
349 {
350 id: "state-store-deploys",
351 name: "Cloudflare state store deploys (success vs error)",
352 type: "TimeSeries",
353 query: {
354 apl: Output.interpolate`
355 ${declarations}
356 ${datasetExpr}${versionFilterWhere}
357 | where name == "state_store.deploy"
358 | extend status=iff(tobool(['error']), "error", "success")
359 | summarize count=count() by status, bin(_time, totimespan(bin_size))
360 | order by _time asc
361 `,
362 },
363 },
364 {
365 id: "active-users-by-version",
366 name: "Active users by alchemy version (7d)",
367 type: "Table",
368 query: {
369 apl: Output.interpolate`
370 ${declarations}
371 ${datasetExpr}${versionFilterWhere}
372 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
373 version=tostring(['resource.custom']['alchemy.version']),
374 ci=tostring(['resource.custom']['alchemy.ci'])
375 | summarize users_total=dcount(uid),
376 users_local=dcountif(uid, ci != "true"),
377 users_ci=dcountif(uid, ci == "true")
378 by version
379 | order by users_total desc
380 `,
381 },
382 },
383
384 // ─── Cloudflare State Store ────────────────────────────────
385 //
386 // Two span sources feed this section:
387 //
388 // - **CLI spans** (`state_store.init`, `state_store.bootstrap`,
389 // `state_store.deploy`, ...) — emitted from the user's
390 // terminal/CI. Carry `alchemy.cloudflare.account_hash` (a
391 // SHA-256 of the CF accountId) so we can count distinct
392 // deployments without leaking raw account IDs.
393 //
394 // - **Worker handler spans** (`state_store.{getVersion,
395 // listStacks,listStages,listResources,getState,setState,
396 // deleteState,getReplacedResources,deleteStack}`) — emitted
397 // from inside the deployed `alchemy-state-store` worker.
398 // Distinguished by the `alchemy.state_store.script_name`
399 // **resource** attribute (set on the worker's OTLP tracer
400 // resource); CLI spans do not carry that resource attr.
401 //
402 // Worker handler spans carry `alchemy.state_store.{op,stack,
403 // stage,fqn}` as span attributes plus a `duration` field —
404 // that's what powers the latency / op-rate / stack-count
405 // charts. Account-hash sits on CLI spans only (worker-side
406 // injection would need a separate deploy-time env-var pass).
407 {
408 id: "state-store-distinct-cloudflare-stores",
409 name: "Distinct Cloudflare state-store deployments (7d)",
410 type: "Statistic",
411 query: {
412 apl: Output.interpolate`
413 ${declarations}
414 ${datasetExpr}${versionFilterWhere}
415 | where name == "state_store.init"
416 | extend hash=tostring(['attributes.custom']['alchemy.cloudflare.account_hash'])
417 | where hash != ""
418 | summarize stores=dcount(hash)
419 `,
420 },
421 },
422 {
423 id: "state-store-distinct-stacks",
424 name: "Distinct stacks tracked (7d)",
425 type: "Statistic",
426 query: {
427 apl: Output.interpolate`
428 ${declarations}
429 ${datasetExpr}${versionFilterWhere}
430 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
431 | extend stack=tostring(['attributes.custom']['alchemy.state_store.stack'])
432 | where stack != ""
433 | summarize stacks=dcount(stack)
434 `,
435 },
436 },
437 {
438 id: "state-store-total-ops",
439 name: "Cloudflare state store ops (7d)",
440 type: "Statistic",
441 query: {
442 apl: Output.interpolate`
443 ${declarations}
444 ${datasetExpr}${versionFilterWhere}
445 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
446 | extend op=tostring(['attributes.custom']['alchemy.state_store.op'])
447 | where op != ""
448 | summarize ops=count()
449 `,
450 },
451 },
452 {
453 id: "state-store-op-latency",
454 name: "State store op latency (p50 / p95 / p99, 7d)",
455 type: "Table",
456 query: {
457 apl: Output.interpolate`
458 ${declarations}
459 ${datasetExpr}${versionFilterWhere}
460 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
461 | extend op=tostring(['attributes.custom']['alchemy.state_store.op'])
462 | where op != ""
463 | summarize p50=percentile(duration, 50),
464 p95=percentile(duration, 95),
465 p99=percentile(duration, 99),
466 calls=count()
467 by op
468 | order by calls desc
469 `,
470 },
471 },
472 {
473 id: "state-store-ops-over-time",
474 name: "State store ops over time (by op)",
475 type: "TimeSeries",
476 query: {
477 apl: Output.interpolate`
478 ${declarations}
479 ${datasetExpr}${versionFilterWhere}
480 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
481 | extend op=tostring(['attributes.custom']['alchemy.state_store.op'])
482 | where op != ""
483 | summarize count=count() by op, bin(_time, totimespan(bin_size))
484 | order by _time asc
485 `,
486 },
487 },
488 {
489 id: "state-store-error-rate",
490 name: "State store error rate by op (7d)",
491 type: "Table",
492 query: {
493 apl: Output.interpolate`
494 ${declarations}
495 ${datasetExpr}${versionFilterWhere}
496 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
497 | extend op=tostring(['attributes.custom']['alchemy.state_store.op']),
498 err=tobool(['error'])
499 | where op != ""
500 | summarize errors=countif(err == true),
501 total=count()
502 by op
503 | extend error_rate=todouble(errors) / todouble(total)
504 | order by total desc
505 `,
506 },
507 },
508 {
509 id: "state-store-top-stacks",
510 name: "Top stacks by activity (7d)",
511 type: "Table",
512 query: {
513 apl: Output.interpolate`
514 ${declarations}
515 ${datasetExpr}${versionFilterWhere}
516 | where ['resource.custom']['alchemy.state_store.script_name'] != ""
517 | extend stack=tostring(['attributes.custom']['alchemy.state_store.stack'])
518 | where stack != ""
519 | summarize ops=count() by stack
520 | order by ops desc
521 | take 25
522 `,
523 },
524 },
525 {
526 id: "state-store-deployments-over-time",
527 name: "Distinct Cloudflare state-store deployments per day",
528 type: "TimeSeries",
529 query: {
530 apl: Output.interpolate`
531 ${declarations}
532 ${datasetExpr}${versionFilterWhere}
533 | where name == "state_store.init"
534 | extend hash=tostring(['attributes.custom']['alchemy.cloudflare.account_hash'])
535 | where hash != ""
536 | summarize stores=dcount(hash) by bin(_time, totimespan(bin_size))
537 | order by _time asc
538 `,
539 },
540 },
541
542 // ─── Resource usage & reliability ──────────────────────────
543 //
544 // Every provider lifecycle invocation is wrapped in a
545 // `provider.<op>` span (see `instrumentLifecycle` in Apply.ts)
546 // carrying `alchemy.resource.type` and `alchemy.resource.op`
547 // (precreate/create/update/delete/read). `error` is set when
548 // the lifecycle effect fails.
549 {
550 id: "resource-usage-ranked",
551 name: "Resource usage — ranked by lifecycle ops (7d)",
552 type: "Table",
553 query: {
554 apl: Output.interpolate`
555 ${declarations}
556 ${datasetExpr}${versionFilterWhere}
557 | where name startswith "provider."
558 | extend resource_type=tostring(['attributes.custom']['alchemy.resource.type']),
559 project=tostring(['resource.custom']['alchemy.git.origin_hash'])
560 | where resource_type != ""
561 | summarize ops=count(),
562 projects=dcountif(project, project != ""),
563 errors=countif(tobool(['error']) == true)
564 by resource_type
565 | order by ops desc
566 | take 200
567 `,
568 },
569 },
570 {
571 id: "resource-error-rate-by-type",
572 name: "Resource error rate by resource type (7d)",
573 type: "Table",
574 query: {
575 apl: Output.interpolate`
576 ${declarations}
577 ${datasetExpr}${versionFilterWhere}
578 | where name startswith "provider."
579 | extend resource_type=tostring(['attributes.custom']['alchemy.resource.type']),
580 err=tobool(['error'])
581 | where resource_type != ""
582 | summarize errors=countif(err == true),
583 total=count()
584 by resource_type
585 | extend error_rate=todouble(errors) / todouble(total)
586 | order by error_rate desc, total desc
587 | take 200
588 `,
589 },
590 },
591 {
592 id: "resource-error-rate-by-op",
593 name: "Resource error rate by lifecycle method (7d)",
594 type: "Table",
595 query: {
596 apl: Output.interpolate`
597 ${declarations}
598 ${datasetExpr}${versionFilterWhere}
599 | where name startswith "provider."
600 | extend op=tostring(['attributes.custom']['alchemy.resource.op']),
601 err=tobool(['error'])
602 | where op != ""
603 | summarize errors=countif(err == true),
604 total=count()
605 by op
606 | extend error_rate=todouble(errors) / todouble(total)
607 | order by total desc
608 `,
609 },
610 },
611 ];
612
613 const layout: Axiom.DashboardProps["dashboard"]["layout"] = [
614 // Row 0 — filter bar (full width, narrow)
615 { i: "filter-bar", x: 0, y: 0, w: 12, h: 2 },
616 // Row 1
617 { i: "distinct-projects", x: 0, y: 2, w: 4, h: 4 },
618 { i: "projects-using-ci", x: 4, y: 2, w: 4, h: 4 },
619 { i: "active-users-7d", x: 8, y: 2, w: 4, h: 4 },
620 // Row 2 — active users over time
621 { i: "active-users-over-time-hourly", x: 0, y: 6, w: 6, h: 6 },
622 { i: "active-users-over-time-daily", x: 6, y: 6, w: 6, h: 6 },
623 // Row 3
624 { i: "users-per-project", x: 0, y: 12, w: 8, h: 8 },
625 { i: "project-team-size-distribution", x: 8, y: 12, w: 4, h: 8 },
626 // Row 4
627 { i: "state-store-projects-by-id", x: 0, y: 20, w: 6, h: 6 },
628 { i: "state-store-by-id-over-time", x: 6, y: 20, w: 6, h: 6 },
629 // Row 5
630 { i: "projects-over-time", x: 0, y: 26, w: 6, h: 6 },
631 { i: "ci-vs-local-projects", x: 6, y: 26, w: 6, h: 6 },
632 // Row 6
633 { i: "state-store-deploys", x: 0, y: 32, w: 6, h: 6 },
634 { i: "active-users-by-version", x: 6, y: 32, w: 6, h: 6 },
635 // Row 7 — Cloudflare State Store: top-line stats
636 {
637 i: "state-store-distinct-cloudflare-stores",
638 x: 0,
639 y: 38,
640 w: 4,
641 h: 4,
642 },
643 { i: "state-store-distinct-stacks", x: 4, y: 38, w: 4, h: 4 },
644 { i: "state-store-total-ops", x: 8, y: 38, w: 4, h: 4 },
645 // Row 8 — performance: latency table (full width)
646 { i: "state-store-op-latency", x: 0, y: 42, w: 12, h: 8 },
647 // Row 9 — ops over time + error rate
648 { i: "state-store-ops-over-time", x: 0, y: 50, w: 8, h: 6 },
649 { i: "state-store-error-rate", x: 8, y: 50, w: 4, h: 6 },
650 // Row 10 — distribution + adoption shape
651 { i: "state-store-top-stacks", x: 0, y: 56, w: 6, h: 8 },
652 { i: "state-store-deployments-over-time", x: 6, y: 56, w: 6, h: 8 },
653 // Row 11 — Resource usage ranking (full width)
654 { i: "resource-usage-ranked", x: 0, y: 64, w: 12, h: 10 },
655 // Row 12 — Error rate broken down by resource type and lifecycle method
656 { i: "resource-error-rate-by-type", x: 0, y: 74, w: 8, h: 10 },
657 { i: "resource-error-rate-by-op", x: 8, y: 74, w: 4, h: 10 },
658 ];
659
660 return {
661 dashboard: {
662 name: `${stack.stage} alchemy CLI overview`,
663 // Empty owner = X-AXIOM-EVERYONE (org-shared). API tokens
664 // can't create per-user "private" dashboards.
665 owner: "",
666 description: [
667 "Adoption telemetry: distinct projects, active users per project, ",
668 "CI vs local usage, and state-store backend breakdown. Plus ",
669 "Cloudflare State Store ops: distinct deployments, stack count, ",
670 "per-op latency / error rate. ",
671 "Resource usage: ranked list of resource types by lifecycle ops, ",
672 "with error rates broken down by resource type and by lifecycle method. ",
673 "Project identity = alchemy.git.origin_hash (stable across CI runs); ",
674 "user identity = alchemy.user.id (ephemeral in CI); ",
675 "Cloudflare deployment identity = alchemy.cloudflare.account_hash ",
676 "(SHA-256 of accountId, set on CLI state_store.* spans).",
677 ].join(""),
678 refreshTime: 60 as const,
679 schemaVersion: 2 as const,
680 // Axiom requires the `qr-now-{duration}` form for relative times.
681 timeWindowStart: "qr-now-7d",
682 timeWindowEnd: "qr-now",
683 charts,
684 layout,
685 },
686 };
687 }),
688);