1/**
2 * OpenTelemetry export for deployed Functions/Workers, built on Effect's
3 * OTLP exporters (`effect/observability`) and configured through
4 * alchemy's binding infrastructure — exporters are Layers, and their
5 * configuration (endpoints, tokens) is wired from resource Outputs like any
6 * other binding.
7 *
8 * Provide a telemetry Layer on the Function/Worker init Effect, composed
9 * into the single `Effect.provide` alongside the other binding layers:
10 *
11 * ```ts
12 * import * as Alchemy from "alchemy";
13 * import * as Axiom from "alchemy/Axiom";
14 *
15 * Effect.gen(function* () {
16 * // ...
17 * }).pipe(
18 * Effect.provide(
19 * Layer.mergeAll(
20 * Cloudflare.R2.ReadWriteBucketBinding,
21 * // vendor sugar — binds dataset endpoints + ingest token:
22 * Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
23 * // or the generic OTLP form, wired from any Inputs/Outputs:
24 * // Alchemy.Telemetry.layerOtlp({ url: collector.url, headers: { ... } }),
25 * // or any custom exporter Layer:
26 * // Alchemy.Telemetry.layer(myExporterLayer),
27 * ),
28 * ),
29 * );
30 * ```
31 *
32 * {@link layerOtlp} is a *binding* layer: at deploy time it binds the
33 * configured endpoints/headers onto the host (Redacted values as secrets),
34 * and at runtime the exporter reads those bound values back. Telemetry is
35 * off until a layer is provided — Effect's default tracer is a no-op, so
36 * all instrumentation stays free.
37 *
38 * `Telemetry` itself is a `Context.Reference` holding the Layer of
39 * exporters to install for every event (fetch, queue, cron, RPC, Durable
40 * Object call, Workflow run, Lambda invoke). The runtime bridges build that
41 * Layer into the event's request scope, so:
42 *
43 * - the exporter's batching fiber runs inside the event's I/O context
44 * (required on workerd, where timers/fetches are pinned to the request),
45 * - buffered spans/logs/metrics are flushed when the request scope
46 * finalizes — registered with `ctx.waitUntil`, so flushing never delays
47 * the response.
48 */
49import * as Effect from "effect/Effect";
50import * as Layer from "effect/Layer";
51import * as Redacted from "effect/Redacted";
52import type { Input } from "./Input.ts";
53import * as Output from "./Output.ts";
54import { CurrentRuntimeContext } from "./RuntimeContext.ts";
55import {
56 EXPORTERS_KEY,
57 fromBoundConfig,
58 type ResolvedDestination,
59 type ResolvedSignal,
60 Telemetry,
61 type TelemetryLayer,
62} from "./TelemetryRuntime.ts";
63
64export {
65 buildEventTelemetry,
66 provideProcessTelemetry,
67 Telemetry,
68 type TelemetryLayer,
69} from "./TelemetryRuntime.ts";
70
71const reference = Telemetry;
72
73/**
74 * Install a custom telemetry Layer (any Layer providing a `Tracer`,
75 * loggers, and/or metric exporters). It is built once per event into the
76 * event's request scope, so scoped exporters flush when the request scope
77 * finalizes.
78 *
79 * Custom layers COMPOSE with the built-in OTLP destinations and with each
80 * other: loggers and metric exporters merge; a custom `Tracer` (a single
81 * Effect service) replaces the built-in one.
82 *
83 * The isolate {@link Telemetry} reference stays the bound OTLP reader.
84 * The custom Layer is stored on `ctx.telemetry` and merged per event as
85 * the override, so `Layer.mergeAll` order with {@link layerOtlp} does not
86 * drop logs/metrics.
87 *
88 * Provide it on the Function/Worker's init Effect (merged into the single
89 * `Effect.provide`): building the returned Layer registers the exporter
90 * Layer on the current runtime context, where the runtime bridges pick it
91 * up per event. Handlers' request-time context is assembled by the bridge,
92 * so a plain `Layer.succeed` of the reference on the init Effect would
93 * never reach them — the registration is what makes it visible at request
94 * time.
95 */
96export const layer = (exporter: TelemetryLayer): Layer.Layer<never> =>
97 Layer.effect(
98 reference,
99 Effect.gen(function* () {
100 const ctx = yield* CurrentRuntimeContext;
101 if (ctx !== undefined) {
102 ctx.telemetry =
103 ctx.telemetry === undefined
104 ? exporter
105 : Layer.mergeAll(ctx.telemetry, exporter);
106 }
107 return fromBoundConfig;
108 }),
109 );
110
111/**
112 * A header value: a plain string, a `Redacted` secret, or an Output of
113 * either (e.g. an ApiToken's `token` attribute).
114 */
115export type OtlpHeaderValue = Input<string | Redacted.Redacted<string>>;
116
117/**
118 * OTLP configuration for one signal. `url` and header values accept plain
119 * values or resource Outputs — they are *bound* onto the host at deploy
120 * time like any other binding.
121 */
122export interface OtlpSignalOptions {
123 /** The OTLP/HTTP URL exports for this signal are POSTed to. */
124 url: Input<string>;
125 /**
126 * Headers sent with each export request (e.g. auth tokens). `Redacted`
127 * values bind as secrets.
128 */
129 headers?: Record<string, OtlpHeaderValue> | undefined;
130}
131
132/**
133 * Options for {@link layerOtlp}. Configure a base `url` (with
134 * `/v1/{signal}` appended per signal), per-signal urls, or a mix — a
135 * per-signal entry takes precedence over the base.
136 */
137export interface OtlpOptions {
138 /** Base OTLP/HTTP URL; `/v1/{traces,logs,metrics}` is appended per signal. */
139 url?: Input<string> | undefined;
140 /** Headers for every signal; per-signal `headers` take precedence. */
141 headers?: Record<string, OtlpHeaderValue> | undefined;
142 traces?: OtlpSignalOptions | undefined;
143 logs?: OtlpSignalOptions | undefined;
144 metrics?: OtlpSignalOptions | undefined;
145 /**
146 * The exported `service.name`.
147 * @default the deployed Function/Worker's physical name
148 */
149 serviceName?: Input<string> | undefined;
150}
151
152/**
153 * A placeholder in the destinations template pointing at one captured
154 * Input value.
155 */
156interface Placeholder {
157 readonly $input: number;
158}
159
160/**
161 * Per-runtime-context accumulator: every `layerOtlp` layer built for
162 * the same host appends its destination here and rebinds the full list, so
163 * merged layers compose instead of clobbering each other.
164 */
165const rcDestinations = new WeakMap<object, OtlpOptions[]>();
166
167/**
168 * Compose the full destination list into a single Output: capture every
169 * Input (urls, header values) into an `Output.all`, then materialize the
170 * JSON array of {@link ResolvedDestination}. If any captured value is
171 * `Redacted`, the whole JSON binds as a secret.
172 */
173const destinationsOutput = (
174 list: OtlpOptions[],
175): Output.Output<string | Redacted.Redacted<string>> => {
176 const inputs: unknown[] = [];
177 const capture = (value: unknown): Placeholder => {
178 inputs.push(value);
179 return { $input: inputs.length - 1 };
180 };
181 const captureHeaders = (
182 headers: Record<string, OtlpHeaderValue> | undefined,
183 ): Record<string, Placeholder> | undefined =>
184 headers === undefined
185 ? undefined
186 : Object.fromEntries(
187 Object.entries(headers).map(([key, value]) => [key, capture(value)]),
188 );
189 const template = list.map((options) => ({
190 url: options.url !== undefined ? capture(options.url) : undefined,
191 headers: captureHeaders(options.headers),
192 traces: options.traces && {
193 url: capture(options.traces.url),
194 headers: captureHeaders(options.traces.headers),
195 },
196 logs: options.logs && {
197 url: capture(options.logs.url),
198 headers: captureHeaders(options.logs.headers),
199 },
200 metrics: options.metrics && {
201 url: capture(options.metrics.url),
202 headers: captureHeaders(options.metrics.headers),
203 },
204 }));
205 return (
206 Output.all(
207 ...inputs.map((input) => Output.asOutput(input as never)),
208 ) as Output.Output<unknown[]>
209 ).pipe(
210 Output.map((values) => {
211 let secret = false;
212 const resolve = (placeholder: Placeholder): string => {
213 let value = values[placeholder.$input];
214 if (Redacted.isRedacted(value)) {
215 secret = true;
216 value = Redacted.value(value);
217 }
218 return typeof value === "string" ? value : String(value);
219 };
220 const resolveHeaders = (
221 headers: Record<string, Placeholder> | undefined,
222 ): Record<string, string> | undefined =>
223 headers === undefined
224 ? undefined
225 : Object.fromEntries(
226 Object.entries(headers).map(([key, value]) => [
227 key,
228 resolve(value),
229 ]),
230 );
231 const resolveSignal = (
232 entry: { url: Placeholder; headers?: Record<string, Placeholder> },
233 base: { url?: Placeholder; headers?: Record<string, Placeholder> },
234 path: string,
235 ): ResolvedSignal | undefined => {
236 if (entry !== undefined) {
237 return {
238 url: resolve(entry.url),
239 headers: resolveHeaders(entry.headers),
240 };
241 }
242 if (base.url !== undefined) {
243 return {
244 url: `${resolve(base.url).replace(/\/$/, "")}/v1/${path}`,
245 headers: resolveHeaders(base.headers),
246 };
247 }
248 return undefined;
249 };
250 const destinations = template.flatMap((entry): ResolvedDestination[] => {
251 const destination: ResolvedDestination = {
252 traces: resolveSignal(entry.traces as never, entry, "traces"),
253 logs: resolveSignal(entry.logs as never, entry, "logs"),
254 metrics: resolveSignal(entry.metrics as never, entry, "metrics"),
255 };
256 return destination.traces || destination.logs || destination.metrics
257 ? [destination]
258 : [];
259 });
260 const json = JSON.stringify(destinations);
261 return secret ? Redacted.make(json) : json;
262 }),
263 );
264};
265
266/**
267 * The built-in OTLP exporter as a *binding* layer.
268 *
269 * At deploy time, building this layer binds the configured urls and
270 * headers onto the host Function/Worker (Redacted values as secret
271 * bindings) — url and header values accept resource Outputs, so exporter
272 * config is wired from resources like any other binding. At runtime the
273 * exporter reads the bound values back and ships traces, logs, and metrics
274 * over OTLP/HTTP JSON, flushed as each event's scope closes.
275 *
276 * Exporters COMPOSE: merge several `otlp` layers (or vendor sugar like
277 * `Axiom.Telemetry`) and every destination receives the telemetry — spans
278 * are serialized once, so trace/span ids agree across destinations:
279 *
280 * ```ts
281 * Effect.provide(
282 * Layer.mergeAll(
283 * Cloudflare.R2.ReadWriteBucketBinding,
284 * Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
285 * Alchemy.Telemetry.layerOtlp({
286 * url: "https://api.honeycomb.io",
287 * headers: { "x-honeycomb-team": apiKey },
288 * }),
289 * ),
290 * )
291 * ```
292 */
293export const layerOtlp = (options: OtlpOptions): Layer.Layer<never> =>
294 Layer.effect(
295 reference,
296 Effect.gen(function* () {
297 const rc = yield* CurrentRuntimeContext;
298 if (rc !== undefined && !globalThis.__ALCHEMY_RUNTIME__) {
299 // Accumulate this destination with any bound by sibling layers on
300 // the same host, and rebind the full list (rebinding the same key
301 // just overwrites, so build order doesn't matter).
302 const list = rcDestinations.get(rc) ?? [];
303 list.push(options);
304 rcDestinations.set(rc, list);
305 yield* rc.set(EXPORTERS_KEY, destinationsOutput(list));
306 if (options.serviceName !== undefined) {
307 yield* rc.set(
308 "OTEL_SERVICE_NAME",
309 Output.asOutput(options.serviceName as never),
310 );
311 }
312 }
313 // The runtime half reads the bound destinations back per event (or
314 // once per process via `provideProcessTelemetry`).
315 return fromBoundConfig;
316 }),
317 );