130 lines · 6.1 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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, OtlpTracer } from "effect/unstable/observability";import { Layer } from "effect"; import { UrlRedactingOtlpSerializationJson } from "@executor-js/sdk"; 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<string, string> | 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_<SIGNAL>_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<string, string | undefined>): 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<string, string> | 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<string, string | undefined> = process.env,): Layer.Layer<never> => {  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(    // The redacting serialization is the scrub for this exporter path: no    // cloud span processor runs here, so credential-bearing URL components    // (query values, userinfo, fragments) are stripped from the trace and log    // payloads at the serialization seam every exported span and log record    // passes through. Removal only — this path adds no stripped-keys    // diagnostic.    Layer.provide(UrlRedactingOtlpSerializationJson),    // 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),  );};