Logging
Furin ships with evlog wired into the server runtime by default. Browser-side logging is available too, but it is opt-in so the default client bundle stays small.
What Ships by Default
When you call furin(), server logging is enabled 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 — disabled by default. The generated hydration entry does not initialize the HTTP drain unless you enable clientLogging, so browser events are not batched to /_furin/ingest by default.
Internal asset paths are excluded from request logging by default:
/_client/**
/public/**
/favicon.ico
/_bun_hmr_entry/**
/_furin/data is logged intentionally, but its event path is rewritten to the logical route being navigated to. /_furin/ingest is also logged so browser-side events can be re-emitted through the server drain.
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.
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:
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:
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:
| Context | Behaviour |
|---|---|
| SSR (live request) | Request-scoped evlog logger — all set() calls accumulate on the HTTP wide event |
| ISR background revalidation | Detached logger scoped to the render — emitted to your drain at the end |
| SSG pre-render | Same 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
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
import { createAxiomDrain } from "evlog/adapters/axiom"
await furin({
logger: {
drain: createAxiomDrain({
token: process.env.AXIOM_TOKEN!,
dataset: "my-app",
}),
},
})
OTLP (OpenTelemetry)
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:
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:
await furin({
logger: {
redact: {
fields: ["user.email", "body.password"],
patterns: [/Bearer\s+\S+/gi],
},
},
})
Client-Side Logging
Enable browser-side logging with clientLogging: true. This initializes the browser HTTP drain and points it at /_furin/ingest.
import { defineConfig } from "@teyik0/furin/config"
export default defineConfig({
clientLogging: true,
})
You can also enable it directly in the runtime plugin:
await furin({
clientLogging: true,
})
Once enabled, navigation events (hydrate_complete, navigate_server_error, navigate_failed, etc.) are logged from RouterProvider automatically. To emit your own events from a component or browser utility, import log directly:
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:
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.