1import * as Alchemy from "alchemy";
2import * as Axiom from "alchemy/Axiom";
3import * as Output from "alchemy/Output";
4import { Effect } from "effect";
5import { Traces } from "./Datasets.ts";
6
7/**
8 * Saved Axiom views (APL queries) for the alchemy CLI's OTEL signals.
9 *
10 * All views target `${stage}-traces`. Axiom's metrics datasets cannot be
11 * queried via APL ("Please use the builder to query metrics datasets"),
12 * but every metric we emit has an equivalent span — `cli.<command>` for
13 * invocations, `provider.<op>` for resource lifecycle ops with
14 * `duration` for latency and `error` for status — so we derive
15 * everything from traces.
16 *
17 * Each view's `datasets` entry references `traces.name` (an Output) rather
18 * than a literal `${stage}-traces` string so Alchemy sequences view creation
19 * after the dataset exists. Without this, Axiom rejects the view with
20 * `BadRequest: failed to validate view: entity not found`.
21 *
22 * Attribute paths:
23 * - Resource attributes (per-process, set on `OtlpTracer.resource`):
24 * `['resource.custom']['alchemy.user.id']`,
25 * `['resource.custom']['alchemy.version']`, …
26 * - Span attributes (per-span, set on `Effect.withSpan(... { attributes })`):
27 * `['attributes.custom']['alchemy.resource.type']`,
28 * `['attributes.custom']['alchemy.resource.op']`, …
29 *
30 * `error` (bool) and `status.code` are first-class span fields.
31 */
32
33const viewProps = <A>(
34 fn: (ctx: { stage: string; traces: Output.Output<string> }) => A,
35) =>
36 Effect.all([Alchemy.Stack, Traces]).pipe(
37 Effect.map(([stack, traces]) =>
38 fn({ stage: stack.stage, traces: traces.name }),
39 ),
40 );
41
42export const ActiveUsersHourly = Axiom.View(
43 "ActiveUsersHourly",
44 viewProps(({ stage, traces }) => ({
45 name: `${stage}-active-users-hourly`,
46 description: "Distinct alchemy.user.id per hour, last 7d",
47 datasets: [traces],
48 aplQuery: `
49 ['${stage}-traces']
50 | where _time > ago(7d)
51 | extend uid=tostring(['resource.custom']['alchemy.user.id'])
52 | summarize users=dcount(uid) by bin(_time, 1h)
53 | order by _time asc
54 `,
55 })),
56);
57
58export const ActiveUsersByVersion = Axiom.View(
59 "ActiveUsersByVersion",
60 viewProps(({ stage, traces }) => ({
61 name: `${stage}-active-users-by-version`,
62 description: "Distinct users grouped by alchemy.version (last 7d)",
63 datasets: [traces],
64 aplQuery: `
65 ['${stage}-traces']
66 | where _time > ago(7d)
67 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
68 version=tostring(['resource.custom']['alchemy.version'])
69 | summarize users=dcount(uid) by version
70 | order by users desc
71 `,
72 })),
73);
74
75export const ActiveUsersByCi = Axiom.View(
76 "ActiveUsersByCi",
77 viewProps(({ stage, traces }) => ({
78 name: `${stage}-active-users-by-ci`,
79 description: "CI vs local users (last 7d)",
80 datasets: [traces],
81 aplQuery: `
82 ['${stage}-traces']
83 | where _time > ago(7d)
84 | extend uid=tostring(['resource.custom']['alchemy.user.id']),
85 ci=tostring(['resource.custom']['alchemy.ci'])
86 | summarize users=dcount(uid) by ci
87 `,
88 })),
89);
90
91export const ResourcesUsed = Axiom.View(
92 "ResourcesUsed",
93 viewProps(({ stage, traces }) => ({
94 name: `${stage}-resources-used`,
95 description: "Top resource types by lifecycle op count (last 7d)",
96 datasets: [traces],
97 aplQuery: `
98 ['${stage}-traces']
99 | where _time > ago(7d)
100 | where name startswith "provider."
101 | extend rt=tostring(['attributes.custom']['alchemy.resource.type']),
102 op=tostring(['attributes.custom']['alchemy.resource.op'])
103 | summarize ops=count() by rt, op
104 | order by ops desc
105 `,
106 })),
107);
108
109export const DeployDestroyLatency = Axiom.View(
110 "DeployDestroyLatency",
111 viewProps(({ stage, traces }) => ({
112 name: `${stage}-deploy-destroy-latency`,
113 description: "p50/p95/p99 of cli.deploy and cli.destroy spans",
114 datasets: [traces],
115 aplQuery: `
116 ['${stage}-traces']
117 | where _time > ago(7d)
118 | where name in ("cli.deploy", "cli.destroy")
119 | summarize p50=percentile(duration, 50),
120 p95=percentile(duration, 95),
121 p99=percentile(duration, 99)
122 by name, bin(_time, 1h)
123 | order by _time asc
124 `,
125 })),
126);
127
128export const ResourceLatency = Axiom.View(
129 "ResourceLatency",
130 viewProps(({ stage, traces }) => ({
131 name: `${stage}-resource-latency`,
132 description:
133 "p50/p95 of provider.<op> spans by resource_type and op (last 7d)",
134 datasets: [traces],
135 aplQuery: `
136 ['${stage}-traces']
137 | where _time > ago(7d)
138 | where name startswith "provider."
139 | extend rt=tostring(['attributes.custom']['alchemy.resource.type']),
140 op=tostring(['attributes.custom']['alchemy.resource.op'])
141 | summarize p50=percentile(duration, 50),
142 p95=percentile(duration, 95),
143 count=count()
144 by rt, op
145 | order by p95 desc
146 `,
147 })),
148);
149
150export const CliInvocations = Axiom.View(
151 "CliInvocations",
152 viewProps(({ stage, traces }) => ({
153 name: `${stage}-cli-invocations`,
154 description:
155 "cli.<command> span counts grouped by command and success/error",
156 datasets: [traces],
157 aplQuery: `
158 ['${stage}-traces']
159 | where _time > ago(7d)
160 | where name startswith "cli."
161 | extend command=extract("cli\\\\.(.+)", 1, name),
162 status=iff(tobool(['error']), "error", "success")
163 | summarize count=count() by command, status, bin(_time, 1h)
164 | order by _time asc
165 `,
166 })),
167);
168
169export const ResourceErrorRate = Axiom.View(
170 "ResourceErrorRate",
171 viewProps(({ stage, traces }) => ({
172 name: `${stage}-resource-error-rate`,
173 description:
174 "provider.<op> spans split by status (success vs error) per hour",
175 datasets: [traces],
176 aplQuery: `
177 ['${stage}-traces']
178 | where _time > ago(7d)
179 | where name startswith "provider."
180 | extend rt=tostring(['attributes.custom']['alchemy.resource.type']),
181 status=iff(tobool(['error']), "error", "success")
182 | summarize total=count() by rt, status, bin(_time, 1h)
183 | order by _time asc
184 `,
185 })),
186);