| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 4 | import * as OtlpLogger from "effect/observability/OtlpLogger"; |
| 5 | import * as OtlpMetrics from "effect/observability/OtlpMetrics"; |
| 6 | import * as OtlpSerialization from "effect/observability/OtlpSerialization"; |
| 7 | import * as OtlpTracer from "effect/observability/OtlpTracer"; |
| 8 | |
| 9 | import packageJson from "../../package.json" with { type: "json" }; |
| 10 | |
| 11 | import { collectAttributes, isTelemetryDisabled } from "./Attributes.ts"; |
| 12 | |
| 13 | const TRACES_URL = "https://otel.alchemy.run/v1/traces"; |
| 14 | const METRICS_URL = "https://otel.alchemy.run/v1/metrics"; |
| 15 | const LOGS_URL = "https://otel.alchemy.run/v1/logs"; |
| 16 | |
| 17 | const SERVICE_NAME = "alchemy-cli"; |
| 18 | |
| 19 | const 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 | */ |
| 79 | export 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 | ); |