1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import type { PlanStatusSession, ProgressEvent } from "../Report.ts";
4import { type ProgressReporter } from "../Report.ts";
5
6// The event contract itself lives in Report.ts (one flat `_tag` union, the
7// `Progress` reference the engine emits into). This module adds the
8// Alchemist-side plumbing: the OTEL span-event bridge routes wrap their
9// reporter with, and the adapter to the engine's apply-session contract.
10export {
11 Progress,
12 type ActionPlanned,
13 type ApplyEvent,
14 type NukeEvent,
15 type NukeScanStarted,
16 type NukeProviderScanStarted,
17 type NukePassStarted,
18 type NukeProviderScanned,
19 type NukeResourceDeleted,
20 type NukeResourceFailed,
21 type PlanEvent,
22 type PlanNodeEvent,
23 type PlanPhaseChanged,
24 type PlanningPhase,
25 type ProgressEvent,
26 type ProgressReporter,
27 type ProviderConfigureCompleted,
28 type ProviderConfigureStarted,
29 type ProviderEvent,
30 type ProviderRefreshCompleted,
31 type ProviderRefreshStarted,
32 type ResourceAnnotated,
33 type ResourceDiffStarted,
34 type ResourcePlanned,
35 type ResourceStatusChanged,
36 type StateBootstrapCompleted,
37 type StateBootstrapStarted,
38 type StateEvent,
39} from "../Report.ts";
40
41/**
42 * The OTEL span-event shape of one {@link ProgressEvent}, or `undefined`
43 * for events that are renderer-only. Per-node plan events are deliberately
44 * excluded: a plan emits one per resource *including noops* on every
45 * (re)plan — dev-watch replans would flood the collector with rows that
46 * mostly say "noop", and the node's own `plan.diff.resource` span already
47 * carries the diff. The phase markers are enough to know planning started
48 * and where the time went.
49 */
50const spanEventOf = (
51 event: ProgressEvent,
52): { name: string; attributes: Record<string, unknown> } | undefined => {
53 switch (event._tag) {
54 case "plan.phase":
55 return {
56 name: "alchemy.plan.phase",
57 attributes: { "alchemy.plan.phase": event.phase },
58 };
59 case "plan.resource.started":
60 case "plan.resource.completed":
61 case "plan.action.completed":
62 return undefined;
63 case "apply.resource.status":
64 return {
65 name: "alchemy.apply.resource.status",
66 attributes: {
67 "alchemy.resource.fqn": event.fqn,
68 "alchemy.resource.logical_id": event.id,
69 "alchemy.resource.type": event.type,
70 "alchemy.apply.resource.status": event.status,
71 ...(event.message === undefined
72 ? {}
73 : { "alchemy.apply.message": event.message }),
74 },
75 };
76 case "apply.resource.note":
77 return {
78 name: "alchemy.apply.resource.note",
79 attributes: {
80 "alchemy.resource.fqn": event.fqn,
81 "alchemy.resource.logical_id": event.id,
82 "alchemy.apply.message": event.message,
83 ...(event.kind === undefined
84 ? {}
85 : { "alchemy.apply.note.kind": event.kind }),
86 },
87 };
88 case "state.bootstrap.started":
89 case "state.bootstrap.completed":
90 return {
91 name: `alchemy.${event._tag}`,
92 attributes: { "alchemy.state_store.name": event.store },
93 };
94 case "nuke.scan.started":
95 return {
96 name: "alchemy.nuke.scan.started",
97 attributes: { "alchemy.nuke.providers": event.total },
98 };
99 case "nuke.pass.started":
100 return {
101 name: "alchemy.nuke.pass.started",
102 attributes: { "alchemy.nuke.pass": event.pass },
103 };
104 case "nuke.scan.provider.started":
105 return {
106 name: "alchemy.nuke.scan.provider.started",
107 attributes: { "alchemy.provider": event.provider },
108 };
109 case "nuke.scan.provider.completed":
110 return {
111 name: "alchemy.nuke.scan.provider.completed",
112 attributes: {
113 "alchemy.provider": event.provider,
114 "alchemy.nuke.resources": event.resources,
115 ...(event.error === undefined
116 ? {}
117 : { "alchemy.nuke.message": event.error }),
118 },
119 };
120 case "nuke.resource.deleted":
121 return {
122 name: "alchemy.nuke.resource.deleted",
123 attributes: { "alchemy.resource.display_name": event.resource },
124 };
125 case "nuke.resource.failed":
126 return {
127 name: "alchemy.nuke.resource.failed",
128 attributes: {
129 "alchemy.resource.display_name": event.resource,
130 "alchemy.nuke.message": event.message,
131 },
132 };
133 case "provider.configure.started":
134 case "provider.configure.completed":
135 case "provider.refresh.started":
136 case "provider.refresh.completed":
137 return {
138 name: `alchemy.${event._tag}`,
139 attributes: { "alchemy.provider": event.provider },
140 };
141 }
142};
143
144/**
145 * Decorate a reporter so a {@link ProgressEvent} with a span-event shape is
146 * also recorded on the currently active span before it reaches the
147 * renderer. Emission happens inside the engine's instrumented fibers, so
148 * apply events land on `apply.resource` / `provider.<op>` spans — giving
149 * traces a within-span timeline of status transitions. A no-op when no
150 * span is active (tracing disabled), and independent of whether a renderer
151 * is attached, so `--yes` and CI runs still produce the events.
152 */
153export const withSpanEvents =
154 (report: ProgressReporter): ProgressReporter =>
155 (event) => {
156 const spanEvent = spanEventOf(event);
157 if (spanEvent === undefined) return report(event);
158 return Effect.currentSpan.pipe(
159 Effect.flatMap((span) =>
160 Effect.flatMap(Clock.currentTimeNanos, (now) =>
161 Effect.sync(() =>
162 span.event(spanEvent.name, now, spanEvent.attributes),
163 ),
164 ),
165 ),
166 Effect.ignore,
167 Effect.andThen(report(event)),
168 );
169 };
170
171/** Adapt the ambient reporter to the engine's apply-session contract. */
172export const applySession = (report: ProgressReporter): PlanStatusSession => ({
173 emit: (event) => report(event),
174 done: () => Effect.void,
175});