| 1 | import * as Alchemy from "alchemy"; |
| 2 | import * as Axiom from "alchemy/Axiom"; |
| 3 | import * as Output from "alchemy/Output"; |
| 4 | import { Effect } from "effect"; |
| 5 | import { 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 | |
| 33 | const 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 | |
| 42 | export 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 | |
| 58 | export 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 | |
| 75 | export 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 | |
| 91 | export 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 | |
| 109 | export 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 | |
| 128 | export 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 | |
| 150 | export 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 | |
| 169 | export 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 | ); |