// --------------------------------------------------------------------------- // Self-host OTLP export. // // The shared packages this server runs are already instrumented — `withSpan` // wraps tool execution, OAuth, connection health, and storage — but without a // tracer those spans go nowhere. That is why a slow request on someone else's // hardware can only be reported as "it takes five seconds": nothing the // operator can read says which phase the time went to. Pointing this at a local // collector turns that into a waterfall. // // Export is opt-in and off by default. Self-host runs on the operator's own // machine, so sending their traces anywhere without being asked is not ours to // decide; the default is no exporter, no HTTP client, no buffer. // // This uses Effect's own OTLP modules rather than the OpenTelemetry SDK. Cloud // needs `@effect/opentelemetry` because workerd forces a hand-built // WebTracerProvider, but self-host has no such constraint, and the built-in // path needs only an HttpClient — no `@opentelemetry/*` dependencies, nothing // extra in the container image. // --------------------------------------------------------------------------- import { FetchHttpClient } from "effect/unstable/http"; import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { Layer } from "effect"; import packageJson from "../package.json" with { type: "json" }; const SERVICE_NAME = "executor-selfhost"; const SERVICE_VERSION: string = packageJson.version; /** Resolved OTLP export settings, or `undefined` when export is off. */ interface TelemetryTarget { readonly tracesUrl: string; /** `undefined` when log export is off, or when no URL can be built for it. */ readonly logsUrl: string | undefined; readonly headers: Record | undefined; } // Signal URLs follow the OTLP spec: OTEL_EXPORTER_OTLP_ENDPOINT is a base that // each signal appends its own path to, while OTEL_EXPORTER_OTLP__ENDPOINT // is already the full URL for that one signal. Appending unconditionally would // POST to /v1/traces/v1/traces, which a collector answers with a 404 that never // reaches the operator — the export just silently produces nothing. const resolveTarget = (env: Record): TelemetryTarget | undefined => { const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim().replace(/\/+$/, ""); const tracesUrl = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ?? (base ? `${base}/v1/traces` : undefined); if (!tracesUrl) return undefined; // Logs are opt-in separately from traces: they are the noisier and more // sensitive of the two, carrying every request line and whatever the // operator's own log calls hold. Traces answer "where did the time go" by // themselves, so shipping logs stays a second, deliberate choice. Set the // signal endpoint explicitly if only the traces endpoint is configured — // there is no base to derive a logs URL from, and guessing one would export // to a path the operator never named. const logsUrl = isTruthy(env.EXECUTOR_OTEL_EXPORT_LOGS) ? (env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT?.trim() ?? (base ? `${base}/v1/logs` : undefined)) : undefined; return { tracesUrl, logsUrl, headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS) }; }; const isTruthy = (value: string | undefined): boolean => value === "1" || value === "true" || value === "yes"; // OTEL_EXPORTER_OTLP_HEADERS carries auth for a hosted collector, in the spec's // "k=v,k2=v2" form. A malformed pair is skipped rather than failing the boot: // losing traces must never cost the operator their server. const parseHeaders = (raw: string | undefined): Record | undefined => { const entries = (raw ?? "") .split(",") .map((pair) => pair.trim()) .filter((pair) => pair.length > 0) .flatMap((pair) => { const separator = pair.indexOf("="); if (separator <= 0) return []; return [[pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()] as const]; }); return entries.length > 0 ? Object.fromEntries(entries) : undefined; }; /** * OTLP export layer for the self-hosted server, or {@link Layer.empty} when no * endpoint is configured — so the default deployment neither exports nor pays * for an exporter it will not use. * * Provide this UNDER the app layer (`Layer.provide`), not merged beside it: the * tracer has to be in scope while the app's own layers build, or spans created * during construction find the no-op tracer instead. */ export const makeTelemetryLive = ( env: Record = process.env, ): Layer.Layer => { const target = resolveTarget(env); if (!target) return Layer.empty; const resource = { serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }; const TracerLive = OtlpTracer.layer({ url: target.tracesUrl, resource, headers: target.headers, }); const logsUrl = target.logsUrl; const LoggerLive = logsUrl ? OtlpLogger.layer({ url: logsUrl, resource, headers: target.headers }) : Layer.empty; console.info( `[executor] exporting traces to ${target.tracesUrl}${logsUrl ? ` and logs to ${logsUrl}` : ""}`, ); return Layer.merge(TracerLive, LoggerLive).pipe( Layer.provide(OtlpSerialization.layerJson), // The exporter gets a plain fetch client, deliberately not the guarded // hosted client: the collector is an address the operator configured, so // the SSRF guard's job (stopping agent-chosen URLs from reaching the // private network) does not apply, and applying it would block the most // common setup of all — a collector on localhost. Layer.provide(FetchHttpClient.layer), ); };