Logging

Furin ships with evlog wired in on both sides of the stack — no setup required for the basics.

What Ships by Default

When you call furin(), two things happen automatically:

Server side — the evlog/elysia middleware runs on every request and emits a structured wide event with method, path, status, duration, and any fields you add during the request.

Client side — the generated hydration entry calls initLogger with an HTTP drain pointed at /_furin/ingest. Navigation events (hydrate_complete, navigate_server_error, navigate_failed, etc.) are logged from RouterProvider automatically. Those events are batched, sent to the server, and re-emitted there as service: "furin:browser" so everything lands in one stream.

Internal paths are excluded from request logging by default:

text
/_client/**
/public/**
/favicon.ico
/_furin/* (except /_furin/ingest itself)
/_bun_hmr_entry/**

Customising the Logger

Pass a logger option to furin(). It accepts the full EvlogElysiaOptions shape — drain adapters, enrichers, redaction, tail sampling, and extra path exclusions.

src/server.ts
import { Elysia } from "elysia"
import { furin } from "@teyik0/furin"

const app = new Elysia()
  .use(
    await furin({
      pagesDir: "./src/pages",
      logger: {
        exclude: ["/healthz", "/metrics"],
      },
    })
  )
  .listen(3000)

Adding Fields During a Request

log is available directly in the loader context — no import needed:

src/pages/blog/[slug].tsx
import { route } from "./_route"

export default route.page({
  loader: async ({ params, log }) => {
    log.set({ slug: params.slug })
    const post = await getPost(params.slug, log)
    return { post }
  },
  component: ({ post }) => <BlogPost post={post} />,
})

Pass log explicitly to service functions that need it:

src/lib/posts.ts
import type { RequestLogger } from "evlog"

export async function getPost(slug: string, log: RequestLogger) {
  log.set({ action: "db.query", table: "posts" })
  const post = await db.query("SELECT * FROM posts WHERE slug = ?", [slug])
  log.set({ found: post.length > 0 })
  return post[0] ?? null
}

log resolves the correct logger for every rendering context:

ContextBehaviour
SSR (live request)Request-scoped evlog logger — all set() calls accumulate on the HTTP wide event
ISR background revalidationDetached logger scoped to the render — emitted to your drain at the end
SSG pre-renderSame as ISR background

Drain Adapters

To forward logs to an external platform, pass a drain function to the logger option. evlog ships built-in adapters for the most common providers.

Datadog

ts
import { Elysia } from "elysia"
import { furin } from "@teyik0/furin"
import { createDatadogDrain } from "evlog/adapters/datadog"

const app = new Elysia()
  .use(
    await furin({
      pagesDir: "./src/pages",
      logger: {
        drain: createDatadogDrain({
          apiKey: process.env.DD_API_KEY!,
          site: "datadoghq.eu", // default: "datadoghq.com"
        }),
      },
    })
  )
  .listen(3000)

Axiom

ts
import { createAxiomDrain } from "evlog/adapters/axiom"

await furin({
  logger: {
    drain: createAxiomDrain({
      token: process.env.AXIOM_TOKEN!,
      dataset: "my-app",
    }),
  },
})

OTLP (OpenTelemetry)

ts
import { createOtlpDrain } from "evlog/adapters/otlp"

await furin({
  logger: {
    drain: createOtlpDrain({
      endpoint: "http://localhost:4318/v1/logs",
    }),
  },
})

Other built-in adapters: evlog/adapters/sentry, evlog/adapters/hyperdx, evlog/adapters/better-stack, evlog/adapters/posthog, evlog/adapters/fs.

Enriching Events

Use enrich to add context that is the same for every request — deployment metadata, region, release version:

ts
await furin({
  logger: {
    enrich: (ctx) => {
      ctx.event.region = process.env.FLY_REGION
      ctx.event.release = process.env.RELEASE_SHA
    },
  },
})

PII Redaction

Pass redact: true to enable all built-in PII patterns (emails, IPs, bearer tokens, credit card numbers, etc.), or supply a fine-grained config:

ts
await furin({
  logger: {
    redact: {
      fields: ["user.email", "body.password"],
      patterns: [/Bearer\s+\S+/gi],
    },
  },
})

Client-Side Logging

The hydration entry already calls initLogger for you. To emit your own events from a component or browser utility, import log directly:

ts
import { log } from "evlog"

function trackSearch(query: string) {
  log.info({ action: "search", query })
}

Events are batched and flushed to /_furin/ingest automatically. On page hide/unload, sendBeacon is used to reduce the chance of dropped events.

The server tags re-emitted browser events with service: "furin:browser", so you can filter server-side logs vs. client-side logs in your drain platform using that field.

Filesystem Drain (Development)

For local development without a SaaS account, write logs to disk:

ts
import { createFsDrain } from "evlog/adapters/fs"

await furin({
  logger: {
    drain: createFsDrain({ path: "./.logs/app.ndjson" }),
  },
})

Each line is a JSON-encoded wide event. Pair with tail -f .logs/app.ndjson | bunx pino-pretty or any NDJSON viewer.

Comments