---
title: "Effect"
description: "routerLayer gives every HttpRouter or HttpApi request one wide event, EvlogLogger reads it from any effect, and withLogger covers jobs and alarms."
canonical_url: "https://www.evlog.dev/integrate/frameworks/effect"
---
# Effect

> routerLayer gives every HttpRouter or HttpApi request one wide event, EvlogLogger reads it from any effect, and withLogger covers jobs and alarms.

`evlog/effect` turns each request served by Effect's `HttpRouter` or `HttpApi` into one wide event, and any other effect (a job, a queue message, a Durable Object alarm) into one with `withLogger`. The event travels in the Effect context as the `EvlogLogger` service, so handlers read it with `yield* EvlogLogger` and the event records the `Cause` the effect failed with. It needs `effect` 4.

<callout color="warning" icon="i-lucide-flask-conical">

**Beta.** `evlog/effect` is new, and Effect still marks `effect/http` as unstable. Its exports and the fields it writes may change in a minor release of evlog, including breaking changes. Pin your evlog version and read the changelog when you upgrade. Feedback is welcome on [GitHub](https://github.com/evloghq/evlog/issues).

</callout>

<prompt :actions="["copy","cursor","claude"]" description="Set up evlog in my Effect app" icon="i-simple-icons-effect">

Set up evlog in my Effect HTTP app.

- Install evlog: pnpm add evlog (effect 4 is a peer dependency)
- Create evlog.config.ts with export default defineEvlog({ service: 'my-api' })
- Provide initLoggerLayer(toLoggerConfig(config)) from 'evlog/effect' to the server layer
- Provide routerLayer(toMiddlewareOptions(config)) from 'evlog/effect' to the HttpRouter routes, and pass disableLogger: true to HttpRouter.serve or HttpRouter.toWebHandler
- Read the event with yield* EvlogLogger, or yield* useLogger<fields>

() for typed fields, and call log.set() to accumulate context

</fields>
- Wrap jobs, queue consumers and Durable Object alarms with withLogger('action-name')
- Provide loggerLayer() so Effect.log* calls land on the current wide event

Docs: [https://www.evlog.dev/integrate/frameworks/effect](https://www.evlog.dev/integrate/frameworks/effect)
Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview)

</prompt>

## Quick Start

### 1. Install

<code-group>

```bash [pnpm]
pnpm add evlog effect @effect/platform-node
```

```bash [bun]
bun add evlog effect @effect/platform-node
```

```bash [yarn]
yarn add evlog effect @effect/platform-node
```

```bash [npm]
npm install evlog effect @effect/platform-node
```

</code-group>

`@effect/platform-node` serves the router on Node. On Cloudflare Workers you leave it out and serve the router with `HttpRouter.toWebHandler`. See [Cloudflare Workers and Durable Objects](#cloudflare-workers-and-durable-objects).

### 2. Create evlog.config.ts

```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'my-api',
})
```

Every option lives in this file: sampling, drain, `enrich`, route filters. See [Configuration](https://www.evlog.dev/reference/configuration).

### 3. Provide the layers

`routerLayer` installs the evlog middleware on every route of the router and provides `EvlogLogger` to their handlers. `initLoggerLayer` calls `initLogger` when the layer builds:

```typescript [src/index.ts]
import { createServer } from 'node:http'
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'
import { Effect, Layer } from 'effect'
import { HttpRouter, HttpServerResponse } from 'effect/http'
import { toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { EvlogLogger, initLoggerLayer, routerLayer } from 'evlog/effect'
import config from '../evlog.config'

const Routes = HttpRouter.add('GET', '/health', Effect.gen(function* () {
  const log = yield* EvlogLogger
  log.set({ route: 'health' })
  return HttpServerResponse.jsonUnsafe({ ok: true })
}))

const Server = HttpRouter.serve(
  Routes.pipe(Layer.provide(routerLayer(toMiddlewareOptions(config)))),
  { disableLogger: true },
).pipe(
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
  Layer.provide(initLoggerLayer(toLoggerConfig(config))),
)

NodeRuntime.runMain(Layer.launch(Server))
```

`disableLogger: true` turns off the router's own `Sent HTTP response` line, which the wide event replaces. The same layer works with `HttpRouter.toWebHandler` and with `HttpApiBuilder.layer(Api)` for an `HttpApi`.

Without a router, `evlog()` is the raw `effect/http` middleware: `HttpServer.serve(app, evlog(options))`. `routerLayer(options)` is that middleware registered globally on the router.

## Wide Events

Build up context through the handler. One request is one wide event:

```typescript [src/routes/users.ts]
import { Effect } from 'effect'
import { HttpRouter, HttpServerResponse } from 'effect/http'
import { EvlogLogger } from 'evlog/effect'

export const UserRoute = HttpRouter.add('GET', '/users/:id', Effect.gen(function* () {
  const log = yield* EvlogLogger
  const { id } = yield* HttpRouter.params

  log.set({ user: { id } })

  const user = yield* Users.find(id)
  log.set({ user: { name: user.name, plan: user.plan } })

  const orders = yield* Orders.forUser(id)
  log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } })

  return HttpServerResponse.jsonUnsafe({ user, orders })
}))
```

Every field is merged into one event emitted when the response is ready. The request runs in an Effect span, so the event carries its `traceId` and `spanId`:

```bash [Terminal output]
16:03:30.441 INFO [my-api] GET /users/42 200 in 3ms
  ├─ requestId: 11362fc9-...
  ├─ traceId: 370aa48d6f233739a22e1297da7cabb5
  ├─ spanId: 6cf0065dae1b90e3
  ├─ user: id=42 name=Alice plan=pro
  └─ orders: count=2 totalRevenue=6298
```

## Reach the event from any effect

Other integrations hand the logger to code underneath through `AsyncLocalStorage`. `evlog/effect` uses the Effect context instead: `EvlogLogger` is a service, and a function that reads it declares the requirement in its type.

```typescript [src/services/payment.ts]
import { Effect } from 'effect'
import { EvlogLogger } from 'evlog/effect'

export const chargeCard = (amount: number) => Effect.gen(function* () {
  const log = yield* EvlogLogger
  log.set({ payment: { amount } })
})
```

`chargeCard` has type `Effect<void, never, EvlogLogger>`, so running it outside a unit of work fails to type-check. Library code that should not carry that requirement uses `set` instead, which merges fields into the current event and does nothing outside one, like `Effect.annotateCurrentSpan` outside a span:

```typescript [src/services/cache.ts]
import { Effect } from 'effect'
import { set } from 'evlog/effect'

export const recordCacheHit = Effect.gen(function* () {
  yield* set({ cache: { hit: true } })
})
```

There is no `AsyncLocalStorage` anywhere in the entry point, so it runs on Cloudflare Workers without the `nodejs_compat` flag. There is also no `log.fork()`: run background work through [`withLogger`](#jobs-queues-and-alarms), which emits its own event in the same trace.

### Typed fields

`useLogger<T>()` returns the same event, typed with your fields, so a wrong shape fails to compile:

```typescript [src/routes/checkout.ts]
import { Effect } from 'effect'
import { useLogger } from 'evlog/effect'

interface CheckoutFields {
  cart: { items: number, total: number }
  user: { id: string }
}

const checkout = Effect.gen(function* () {
  const log = yield* useLogger<CheckoutFields>()
  log.set({ cart: { items: 3, total: 9999 } })
  log.set({ cart: { items: 'three' } }) // type error
})
```

## Error Handling

When a handler fails, evlog reads the `Cause`. The primary error, an expected failure before a defect as Effect's `Cause.squash` ranks them, becomes the event's `error`. Any further failures and defects go under `error.suppressed` as `{ name, message }`. A unit that was only interrupted gets `status: 499`, evlog's cancelled-request status, and no `error`. A failure that is not an `Error` is recorded by its `_tag` (or as `UnknownError`) without copying its other properties, so a failure value carrying a prompt or a payload does not leak it into the event.

The event's `status` is the one the client received. Effect's router does not read `status` off an error, so a `createError({ status: 402 })` that reaches it is answered with `500`. To answer with the error's own status, catch it in the handler, record it, and build the response with `parseError()`:

```typescript [src/routes/checkout.ts]
import { Effect } from 'effect'
import { HttpRouter, HttpServerResponse } from 'effect/http'
import { createError, parseError } from 'evlog'
import { EvlogLogger } from 'evlog/effect'

const respondWithError = (error: unknown) => Effect.gen(function* () {
  const log = yield* EvlogLogger
  log.error(error instanceof Error ? error : String(error))
  const parsed = parseError(error)
  return HttpServerResponse.jsonUnsafe(
    { message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link },
    { status: parsed.status },
  )
})

export const CheckoutRoute = HttpRouter.add('POST', '/checkout', Effect.gen(function* () {
  const log = yield* EvlogLogger
  log.set({ cart: { items: 3, total: 9999 } })

  return yield* Effect.fail(createError({
    message: 'Payment failed',
    status: 402,
    why: 'Card declined by issuer',
    fix: 'Try a different payment method',
    link: 'https://docs.example.com/payments/declined',
  }))
}).pipe(Effect.catch(respondWithError)))
```

```bash [Terminal output]
16:03:30.452 ERROR [my-api] POST /checkout 402 in 2ms
  ├─ error: Payment failed
  │  Why: Card declined by issuer
  │  Fix: Try a different payment method
  │  More: https://docs.example.com/payments/declined
  ├─ requestId: ecb06410-...
  ├─ traceId: 74b88b043b5093c642d81d4aa6c54ab8
  └─ cart: items=3 total=9999
```

A route that does not exist is recorded as `404`, with the router's `HttpServerError` as the error.

<callout color="info" icon="i-lucide-info">

**HttpApi** encodes an endpoint's declared errors into responses before the router middleware sees them. The event gets that response's status (a declared `UserNotFound` with `httpApiStatus: 404` is recorded as `404`) but no `error` field. Defects and failures the endpoint does not declare are recorded as `error`. Call `log.error()` in the handler when a declared error should carry its details.

</callout>

<callout color="info" icon="i-lucide-info">

**Streamed responses** emit their event when the handler returns the `HttpServerResponse`, not when a streamed body finishes, so the stream's duration and any error while streaming are not on the event.

</callout>

## Jobs, queues and alarms

Work that is not an HTTP request becomes its own wide event with `withLogger`. It provides `EvlogLogger`, runs the effect in a span named after the action, and emits when the effect succeeds, fails or is interrupted:

```typescript [src/jobs/digest.ts]
import { Effect } from 'effect'
import { EvlogLogger, withLogger } from 'evlog/effect'

export const sendDigest = Effect.gen(function* () {
  const log = yield* EvlogLogger
  const users = yield* Users.subscribed()
  log.set({ digest: { recipients: users.length } })
  yield* Mailer.sendDigest(users)
}).pipe(withLogger('send-digest', { fields: { job: { queue: 'mail' } } }))
```

The event carries `action` in place of `method` and `path`. Run inside a request, the job's event shares the request's `traceId` and records the request span as its `parentSpanId`:

```bash [Terminal output]
16:03:30.461 INFO [my-api] in 4ms
  ├─ action: send-digest
  ├─ traceId: 23e560634c887a4a598c8ab52a044397
  ├─ spanId: bdbe7c5b55e6b542
  ├─ parentSpanId: 8858cf5e94ca2ad9
  ├─ job: queue=mail
  └─ digest: recipients=128
```

`withLogger(action, options)` takes `drain`, `enrich`, `keep`, `redact`, `plugins` and `waitUntil` as `routerLayer` does, plus:

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Effect on the event
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        fields
      </code>
    </td>
    
    <td>
      <code>
        Record<string, unknown>
      </code>
    </td>
    
    <td>
      Fields the event starts with
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        traceparent
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      Continue this W3C trace instead of the current span. A malformed value starts a new trace
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        tracestate
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      Forwarded on outgoing requests next to <code>
        traceparent
      </code>
    </td>
  </tr>
</tbody>
</table>

Pass the `traceparent` a queue message carries to continue the trace of the request that enqueued it.

## Cloudflare Workers and Durable Objects

`HttpRouter.toWebHandler` turns the routes into a Fetch handler. Drains need the request's `waitUntil` to outlive the response, so provide it per request through the handler's context argument:

```typescript [src/worker.ts]
import { Context, Layer } from 'effect'
import { HttpRouter } from 'effect/http'
import { WaitUntil, routerLayer } from 'evlog/effect'
import { createAxiomDrain } from 'evlog/axiom'
import { initWorkersLogger } from 'evlog/workers'

initWorkersLogger({ env: { service: 'my-worker' } })

const { handler } = HttpRouter.toWebHandler(
  Routes.pipe(Layer.provide(routerLayer({ drain: createAxiomDrain() }))),
  { disableLogger: true },
)

export default {
  fetch(request: Request, _env: Env, ctx: ExecutionContext) {
    return handler(request, Context.make(WaitUntil, ctx.waitUntil.bind(ctx)))
  },
}
```

Outside a router, `Effect.provideService(WaitUntil, fn)` sets it for any effect. Without `WaitUntil`, the unit awaits its drains before it settles.

A Durable Object `fetch` receives a raw `Request`. `withRequestLogger` wraps an effect that produces the `Response` and records it like a router request: method, path, status, and a failure's status taken from the error (`status` / `statusCode`), or `500`. An alarm has no request, so it uses `withLogger`, and since no `WaitUntil` is provided there, the alarm awaits its drains before it returns:

```typescript [src/room.ts]
import { DurableObject } from 'cloudflare:workers'
import { Effect } from 'effect'
import { withRequestLogger, withLogger } from 'evlog/effect'

export class Room extends DurableObject {
  fetch(request: Request) {
    return Effect.runPromise(join(request).pipe(withRequestLogger(request)))
  }

  alarm() {
    return Effect.runPromise(sweepIdleMembers.pipe(withLogger('room-alarm')))
  }
}
```

## Follow one action across services

Each unit continues an incoming W3C `traceparent` and keeps its `tracestate`, so a request that crosses a gateway, a Durable Object and two services produces one event per hop with the same `traceId`, and each event's `parentSpanId` points at the span that made the call.

Effect's `HttpClient` already sends `traceparent` for the current span. `withTracePropagation` adds the received `tracestate` to every request of a client:

```typescript [src/services/search.ts]
import { Effect } from 'effect'
import { HttpClient } from 'effect/http'
import { withTracePropagation } from 'evlog/effect'

export const search = (query: string) => Effect.gen(function* () {
  const client = withTracePropagation(yield* HttpClient.HttpClient)
  return yield* client.get(`https://search.internal/q?${new URLSearchParams({ query })}`)
})
```

Calls that do not go through `HttpClient`, such as a Durable Object stub `fetch`, take the headers from `traceHeaders`:

```typescript [src/routes/messages.ts]
import { Effect } from 'effect'
import { traceHeaders } from 'evlog/effect'

export const postMessage = (stub: DurableObjectStub, body: string) => Effect.gen(function* () {
  const headers = yield* traceHeaders
  return yield* Effect.promise(() => stub.fetch('https://room/messages', { method: 'POST', headers, body }))
})
```

## Fold Effect.log into the event

`Effect.logInfo` and friends print a separate line per call by default. `loggerLayer()` sends those calls to the current wide event instead, through the evlog method of the same severity (`fatal`, `error`, `warn`, `info`, `debug`, `trace`), so `Effect.logError` raises the event's level. Messages use evlog's printf-style formatting (`%s`, `%d`, `%j`), and `Effect.annotateLogs` annotations merge into the event as fields. Outside a unit, a call goes to the loggers that were installed before `loggerLayer()`, so your existing logger keeps working.

```typescript [src/index.ts]
import { loggerLayer } from 'evlog/effect'

const Server = HttpRouter.serve(App, { disableLogger: true }).pipe(
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
  Layer.provide(loggerLayer()),
)
```

```bash [Terminal output]
16:03:30.461 INFO [my-api] in 4ms
  ├─ action: send-digest
  ├─ digest: recipients=128
  └─ requestLogs: [{"level":"info","message":"digest rendered","timestamp":"..."}]
```

## Drain & Enrichers

`routerLayer` takes the same options as every evlog integration. Set `drain` and `enrich` in `evlog.config.ts` so `toMiddlewareOptions(config)` passes them, or inline. An inline key replaces the one from the file:

```typescript [src/index.ts]
import { createAxiomDrain } from 'evlog/axiom'
import { createUserAgentEnricher } from 'evlog/enrichers'
import { routerLayer } from 'evlog/effect'

const userAgent = createUserAgentEnricher()

const EvlogLive = routerLayer({
  drain: createAxiomDrain(),
  enrich: (ctx) => {
    userAgent(ctx)
    ctx.event.region = process.env.FLY_REGION
  },
})
```

`withLogger` and `withRequestLogger` take their own `drain` and `enrich`. A unit without a `drain` uses the one given to `initLogger`.

### Pipeline (Batching & Retry)

A `createDrainPipeline` drain buffers events, so it has to be flushed before the process exits. Give it to `initLoggerLayer`: the layer flushes it when its scope closes, which `NodeRuntime.runMain` does on shutdown. `routerLayer` and `withLogger` fall back to it when they have no `drain` of their own:

```typescript [src/index.ts]
import type { DrainContext } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { initLoggerLayer } from 'evlog/effect'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 50, intervalMs: 5000 },
  retry: { maxAttempts: 3 },
})

const EvlogLive = initLoggerLayer({
  env: { service: 'my-api' },
  drain: pipeline(createAxiomDrain()),
})
```

See the [pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for every option.

## Tail Sampling

Use `keep` to force-retain an event regardless of head sampling. It runs after the unit settles, with its duration, status and fields:

```typescript [src/index.ts]
routerLayer({
  keep: (ctx) => {
    if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
  },
})
```

`withLogger` takes the same `keep`, so a slow job is kept the way a slow request is. See [Sampling](https://www.evlog.dev/learn/sampling).

## Route Filtering

Control which requests become events with `include` and `exclude`, and assign a service per route with `routes`. An excluded route still runs: its handlers get a no-op `EvlogLogger`, and nothing is emitted.

```typescript [src/index.ts]
routerLayer({
  include: ['/api/**'],
  exclude: ['/_internal/**', '/health'],
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
  },
})
```

## Redaction

`redact` masks PII and secrets in every event before it reaches a drain, including fields set from Effect code. Pass `redact: true` for the built-in patterns, or list paths to drop content fields such as prompts and message bodies:

```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'my-api',
  redact: { paths: ['prompt', '**.content'] },
})
```

See [Redaction](https://www.evlog.dev/learn/redaction) for the patterns and path syntax.

## Run Locally

```bash [Terminal]
git clone https://github.com/evloghq/evlog.git
cd evlog
pnpm install
pnpm example effect
```

Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI.

<card-group>
<card icon="i-simple-icons-github" title="Source Code" to="https://github.com/evloghq/evlog/tree/main/examples/effect">

Browse the complete Effect example source on GitHub.

</card>
</card-group>

## Next Steps

- [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering
- [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more
- [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling
- [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields

---

- [Source Code](https://github.com/evloghq/evlog/tree/main/examples/effect)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
