Skip to content

Cloudflare HttpApi handlers capture initialization logger and bypass request-scoped OTel logging #1956

Description

@harrysolovay

Effect HttpApi handlers built during Cloudflare Worker initialization capture
Alchemy's console logger and override the request-scoped OTel logger. Request
traces and logs emitted outside the HttpApi handler reach the collector, but
logs emitted inside the handler appear only in the local console.

Versions and scope

  • alchemy@2.0.0-beta.80
  • effect@4.0.0
  • Node.js 24.18.0, Linux
  • Reproduced with alchemy dev, a local Cloudflare Worker, and Axiom's OTLP
    endpoints via Axiom.Telemetry.

Observed in local dev mode; deployed Workers and other runtimes have not been
tested for this failure.

Reproduction

Build the router once during Worker initialization. The reduced example below
uses a generic OTLP collector; replace the URL and supply any required headers.
The live reproduction used Axiom.Telemetry({ token, traces, logs }), which
delegates to the same binding. This generic variant has not been live-tested.

HttpApi definition

// api.ts
import { Schema } from "effect"
import {
  HttpApi,
  HttpApiEndpoint,
  HttpApiGroup,
  HttpApiSchema
} from "effect/http-api"

class Health extends HttpApiGroup.make("health").add(
  HttpApiEndpoint.get("health", "/health", {
    success: Schema.Literal("ok").pipe(HttpApiSchema.asText())
  })
) {}

export class Api extends HttpApi.make("Api").add(Health) {}

Worker and handlers

// worker.ts
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Http from "alchemy/Http"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/http"
import { HttpApiBuilder } from "effect/http-api"

import { Api } from "./api.ts"

const handlers = HttpApiBuilder.group(Api, "health", (handlers) =>
  handlers.handle("health", () =>
    Effect.logInfo("inside-httpapi-handler").pipe(
      Effect.as("ok" as const),
      Effect.withSpan("health.handler")
    )
  )
)

const api = HttpApiBuilder.layer(Api).pipe(
  Layer.provide(handlers),
  Layer.provide(Http.Platform)
)

export class Worker extends Cloudflare.Worker<Worker>()("Worker") {}

export default Worker.make(
  { main: import.meta.url },
  Effect.gen(function* () {
    const fetch = yield* HttpRouter.toHttpEffect(api)

    return {
      fetch: fetch.pipe(
        Effect.tap(() => Effect.logInfo("outside-httpapi-handler"))
      )
    }
  }).pipe(
    Effect.provide(
      Alchemy.Telemetry.layerOtlp({
        url: "https://collector.example.com"
      })
    )
  )
)

Stack

// alchemy.run.ts
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import { Effect } from "effect"

import layerWorker, { Worker } from "./worker.ts"

export default Alchemy.Stack(
  "httpapi-telemetry-repro",
  { providers: Cloudflare.providers(), state: Cloudflare.state() },
  Effect.gen(function* () {
    const worker = yield* Worker
    return { url: worker.url.as<string>() }
  }).pipe(Effect.provide(layerWorker))
)

With provider credentials and state configured:

  1. Run alchemy dev --stage dev_otel_repro.
  2. Request /health on the printed Worker URL.
  3. Query the collector for inside-httpapi-handler and
    outside-httpapi-handler over the reproduction time window.
  4. Repeat the request to check subsequent requests on the same Worker instance.

Expected vs. actual

Both messages should reach the collector with request-scoped trace/span IDs.
Instead, /health returns 200 OK and the HTTP request span and
outside-handler log reach Axiom, but the inside-handler log appears only in the
local console. Repeated Axiom API queries confirmed the missing log.

Source analysis

The installed source indicates a context-lifetime mismatch:

  1. Alchemy's WorkerBridge.ts builds the Worker instance with
    Logger.layer([Logger.consolePretty()]) in its platform context.
  2. Effect's HttpApiBuilder.group captures the current context, omitting only
    Scope.Scope, when constructing routes.
  3. Alchemy's WorkerBridge.processEvent installs fresh request-scoped OTel
    exporters through buildEventTelemetry.
  4. Effect's handlerToHttpEffect applies the captured initialization context
    through Effect.provideContext(context). The captured
    Logger.CurrentLoggers shadows the request's OTel loggers.

Telemetry.layerOtlp configures bindings during initialization; the actual
exporters are built per request. Providing the binding at initialization is the
documented usage.

Relevant source files:

  • Alchemy: packages/alchemy/src/Cloudflare/Workers/WorkerBridge.ts
  • Alchemy: packages/alchemy/src/TelemetryRuntime.ts
  • Alchemy: packages/alchemy/src/Telemetry.ts
  • Effect: packages/effect/src/http-api/HttpApiBuilder.ts

Verified workaround

Omitting initialization-time telemetry while constructing the router allows
handlers to inherit request telemetry:

import { Context, Effect, Logger, type Scope, Tracer } from "effect"

const makeHttpEffect = HttpRouter.toHttpEffect(api).pipe(
  Effect.updateContext((context: Context.Context<Scope.Scope>) =>
    Context.omit(
      Logger.CurrentLoggers,
      Tracer.Tracer,
      Tracer.ParentSpan
    )(context)
  )
)

Yield makeHttpEffect during initialization instead of the original router
construction. This restored handler logs and correctly correlated them with the
child span and HTTP request trace in Axiom.

The directly observed failure concerns loggers. We have not independently
demonstrated a failure for each of Tracer.Tracer and Tracer.ParentSpan;
those were also omitted to avoid capturing initialization-time tracing services.

Constructing the router per request also restored telemetry, but requires
rebuilding the routing layer for every request.

Desired resolution

Support one-time router construction while preserving request-scoped telemetry,
without application-level context stripping. The appropriate fix may be in
Alchemy's initialization context or Effect's HttpApi context capture; explicit
application logger/tracer overrides should remain supported.

A regression test should initialize the router once and verify that successive
requests export handler logs and child spans with their own telemetry contexts
and IDs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions