1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as FetchHttpClient from "effect/http/FetchHttpClient";
4import * as OtlpLogger from "effect/observability/OtlpLogger";
5import * as OtlpMetrics from "effect/observability/OtlpMetrics";
6import * as OtlpSerialization from "effect/observability/OtlpSerialization";
7import * as OtlpTracer from "effect/observability/OtlpTracer";
8
9import packageJson from "../../package.json" with { type: "json" };
10
11import { collectAttributes, isTelemetryDisabled } from "./Attributes.ts";
12
13const TRACES_URL = "https://otel.alchemy.run/v1/traces";
14const METRICS_URL = "https://otel.alchemy.run/v1/metrics";
15const LOGS_URL = "https://otel.alchemy.run/v1/logs";
16
17const SERVICE_NAME = "alchemy-cli";
18
19const buildOtlpLayer = (
20 attrs: Record<string, unknown>,
21): Layer.Layer<never, never, never> => {
22 const resource = {
23 serviceName: SERVICE_NAME,
24 serviceVersion: packageJson.version,
25 attributes: attrs,
26 };
27
28 // Short export intervals so even sub-second CLI invocations flush at
29 // least one batch before the process exits.
30 const tracer = OtlpTracer.layer({
31 url: TRACES_URL,
32 resource,
33 exportInterval: "1 second",
34 });
35 const metrics = OtlpMetrics.layer({
36 url: METRICS_URL,
37 resource,
38 exportInterval: "1 second",
39 });
40 // Stack on top of whatever loggers are already installed. Entrypoints
41 // provide this layer *over* their terminal/file logger layer
42 // (`Layer.provideMerge(TelemetryLive, ConsoleLogLive)`), so the terminal
43 // logger has already replaced Effect's default stdout logger by the time
44 // this runs and the OTLP logger is simply added alongside it.
45 //
46 // `mergeWithExisting: false` here would make telemetry *replace* the
47 // terminal logger whenever this layer happened to be merged after it —
48 // exactly what silenced `alchemy dev`'s console output in the exec child.
49 const logger = OtlpLogger.layer({
50 url: LOGS_URL,
51 resource,
52 exportInterval: "1 second",
53 mergeWithExisting: true,
54 });
55
56 return Layer.mergeAll(tracer, metrics, logger).pipe(
57 Layer.provide(OtlpSerialization.layerJson),
58 Layer.provide(FetchHttpClient.layer),
59 );
60};
61
62/**
63 * The CLI's telemetry layer. Builds an OTLP HTTP exporter that ships spans
64 * to {@link TRACES_URL} and metrics to {@link METRICS_URL}, attaching
65 * {@link collectAttributes} as resource-level attributes so every signal
66 * carries user/project/runtime context.
67 *
68 * The OTLP logger merges with the loggers already installed, so provide this
69 * layer on top of the entrypoint's terminal/file logger layer — e.g.
70 * `Layer.provideMerge(TelemetryLive, ConsoleLogLive)` — never as a sibling in
71 * a `Layer.mergeAll` (the last `CurrentLoggers` in a merge wins, silently
72 * dropping either the terminal output or the telemetry).
73 *
74 * If the user has opted out (via `DO_NOT_TRACK`, `NO_TRACK`,
75 * `ALCHEMY_TELEMETRY_DISABLED`, or `~/.alchemy/telemetry-disabled`), this
76 * resolves to {@link Layer.empty}. Effect's default `Tracer` is a no-op,
77 * so all `withSpan`/`Effect.fn` instrumentation in core stays free.
78 */
79export const TelemetryLive: Layer.Layer<never, never, never> = Layer.unwrap(
80 Effect.gen(function* () {
81 if (yield* isTelemetryDisabled) {
82 return Layer.empty;
83 }
84 const attrs = yield* collectAttributes;
85 return buildOtlpLayer(attrs as unknown as Record<string, unknown>);
86 }),
87);