Logging creates a record of what your application is doing. Every sign in, every subscription purchase, every webhook delivery, every administrative action can leave a trace that helps you understand what happened after the fact.
While Sentry is designed to capture failures that need investigation, logging is designed to capture everything, whether it succeeded or failed. The two systems are complementary, not interchangeable.
Oolvay produces structured logs out of the box, with no setup required. You can use console output as is, or forward logs to a centralized platform such as Axiom or Better Stack as your application grows.
Logging in Oolvay is opt in at the call site. Nothing is logged automatically just because a request happened. Instead, specific events are logged deliberately wherever they matter, such as:
Each log entry includes a severity level, a message, a timestamp, and optional structured metadata describing the event in detail.
The logger is built as a small pipeline. Each call to logger.debug(), logger.info(), logger.warn(), logger.error(), or logger.fatal() passes through a series of checks and transformations before anything is written anywhere.
| File | Responsibility |
|---|---|
lib/logger/index.ts | The public API. Exposes logger.debug/info/warn/error/fatal and logger.child(). |
lib/logger/level.ts | Filters log entries using the minimum level configured in config/site.ts. |
lib/logger/sampling.ts | Applies per level sampling rates in production. |
lib/logger/redact.ts | Strips sensitive values out of metadata before anything is written. |
lib/logger/stamp.ts | Attaches environment, service name, and app version to every entry. |
lib/logger/transport.ts | Selects Console, Axiom, or Better Stack based on config/site.ts. |
lib/logger/transports/batched.ts | Groups entries together before sending them, to reduce request volume. |
0 / 2,000 characters
lib/logger/transports/queued.ts | Buffers entries in memory and protects the app from a slow or failing transport. |
When you call logger.info("message", { meta }), the following happens in order:
enabled is set to false in config/site.ts, the entry is dropped immediately and nothing else runs. This is explained in full under Configuration below.info and can be overridden by setting level to debug, info, warn, error, or fatal in config/site.ts. See below for what each value does.Error objects inside the metadata are serialized into a plain object containing the name, message, and stack trace.[REDACTED].Redaction happens after merging and before both the console output and the transport, so it protects every destination equally.
Redaction only applies to metadata, not to the message string itself. Never interpolate a password, token, or other sensitive value directly into a log message. Always pass it as metadata instead, so it passes through redaction.
// Wrong: the token is plainly visible in the message string
logger.info(`User authenticated with token ${token}`)
// Right: the token is in metadata and will be redacted
logger.info("User authenticated", { token })Every log entry has a level, ranked from least to most severe:
| Level | Weight | Use it for |
|---|---|---|
debug | 10 | Granular diagnostic detail, useful only while actively investigating something |
info | 20 | Normal operational events, such as a user signing in or a subscription being created |
warn | 30 | Something unexpected happened but the application recovered on its own |
error | 40 | An operation failed and needs attention |
fatal | 50 | A severe failure that likely requires immediate intervention |
Only entries at or above the configured minimum level are logged. If the minimum level is info, every debug call is dropped before it ever reaches sampling or redaction.
Pair fatal level logs with Sentry.captureException() for the same event.
Logging records that the failure happened. Sentry alerts you the moment it
does and gives you a stack trace to investigate it with.
The log level, the logging on/off switch, the sampling rates, and the active transport provider all live in config/site.ts. None of these are secrets, so none of them belong in .env.local — they are read directly from this file rather than through the validated @/env schema used elsewhere in the codebase.
observability: {
logging: {
enabled: true,
level: "info" as LogLevel, // "debug" | "info" | "warn" | "error" | "fatal"
transportProvider: "console" as "console" | "axiom" | "betterstack",
sampling: {
debug: 0.1, // log 10% of debug entries
info: 0.5, // log 50% of info entries
warn: 1.0, // log all warnings
error: 1.0, // log all errors
fatal: 1.0, // log all fatals
},
},
},If true, logging is on. Set it to false to turn logging off entirely, including console output. This is a stronger switch than choosing console as the transport provider. console still produces console output; it just does not forward anywhere else. Setting enabled to false stops every log call at the very first check, before the level check, before sampling, and before the console output itself.
You will rarely need to set this. It exists mainly as an emergency switch, for example to silence logging temporarily while debugging an unrelated issue in a shared environment.
Defaults to info. This means debug entries are dropped and everything from info upward is kept, which is a sensible default for most applications.
| Value | Effect |
|---|---|
debug | See everything, including verbose detail |
info | The default |
warn | Quieter, only warnings and above |
error | Only errors and fatal failures |
fatal | Only the most severe failures |
Because level is a typed field rather than a free-form string read from the environment, an invalid value is caught by TypeScript at build time rather than silently dropping every log call at runtime.
Controls where logs are sent in addition to the console. console means console output only. axiom and betterstack forward logs to the respective platform once credentials are configured. See Choosing a transport provider below.
Controls what fraction of entries at each level are actually logged, expressed as a number between 0 and 1.
| Value | Meaning |
|---|---|
1.0 | Log every entry at this level |
0.5 | Log roughly half of entries at this level |
0.1 | Log roughly one in ten entries at this level |
0.0 | Never log entries at this level |
Sampling only applies in production. In development, shouldSample() returns
true unconditionally, so every entry that passes the level check is logged
regardless of the configured rate. This is intentional. Sampling exists to
control cost and volume at scale, which is a production concern, not a
development one.
The defaults favor keeping everything important while trimming routine noise: warn, error, and fatal are always kept, while debug and info are sampled down, since those tend to be the highest volume, lowest urgency entries.
Before any entry leaves the application, its metadata is checked against a fixed list of sensitive key names in lib/logger/redact.ts. Matching keys have their value replaced with [REDACTED], regardless of how deeply they are nested inside objects or arrays.
The match is case insensitive and ignores dashes, underscores, and spaces, so apiKey, API_KEY, api-key, and Api Key are all treated identically.
The denylist currently includes credentials and authentication values (password, token, accessToken, refreshToken, sessionToken, apiKey, secret, clientSecret, webhookSecret, privateKey, and related variants), payment and identity values (creditCard, cardNumber, cvv, cvc, ssn, dob, passport, license), and a handful of transport level values such as cookie and authorization.
If your application introduces a new field that should never appear in logs, add its key name to the SENSITIVE_KEYS set in lib/logger/redact.ts directly. There is no configuration entry point for this in config/site.ts, since the list is meant to be a deliberate, reviewed addition rather than something toggled casually.
Every log entry automatically includes three pieces of context, defined in lib/logger/stamp.ts:
export const stamp: LogStamp = {
env: process.env.NODE_ENV ?? "development",
service: siteConfig.brand.name,
version: process.env.NEXT_PUBLIC_APP_VERSION ?? "unknown",
}This means every log entry, from every part of the application, is automatically tagged with which environment produced it, which service it came from, and which deployed version was running. This is especially useful once logs are flowing into a centralized platform where you may be looking at entries from multiple environments or releases at once.
Calling logger.child(meta) returns a new logger that automatically merges the given metadata into every entry it produces, without you needing to repeat it on every call.
Oolvay uses this to attach a request ID to every log entry produced while handling a single request, so that entries from the same request can be correlated even if they are logged from different functions.
import { logger } from "@/lib/logger"
import { getRequestId } from "@/lib/observability/get-request-id"
export async function actionLogger(action: string) {
const requestId = await getRequestId()
return logger.child({
requestId,
action,
type: "server-action",
})
}import { logger } from "@/lib/logger"
import type { NextRequest } from "next/server"
export function requestLogger(req: NextRequest) {
const requestId =
req.headers.get("x-request-id") ??
req.headers.get("x-vercel-id") ??
crypto.randomUUID()
return logger.child({
requestId,
method: req.method,
path: new URL(req.url).pathname,
})
}The request ID itself originates in proxy.ts, which generates a new one for every incoming request and attaches it as an x-request-id header before the request reaches your route handlers and server actions. getRequestId() simply reads that header back out.
Use actionLogger(actionName) inside server actions and requestLogger(req) inside route handlers, rather than calling the base logger directly, whenever you want entries to be correlated by request.
export const runtime = "nodejs"
import { NextRequest, NextResponse } from "next/server"
import { requestLogger } from "@/lib/logger/request-logger"
export async function POST(req: NextRequest) {
const log = requestLogger(req)
try {
// your handler logic
log.info("Operation completed")
return NextResponse.json({ success: true })
} catch (error) {
log.error("Operation failed", { error })
return NextResponse.json(
{ error: "Something went wrong." },
{ status: 500 }
)
}
}Passing an Error object as part of the metadata, as shown above, is intentional. The logger automatically serializes it into a plain object containing the name, message, and stack trace, so the full error detail is preserved in structured form rather than collapsed into a string.
When a centralized provider is configured, log entries do not get sent to Axiom or Better Stack one at a time. They pass through two wrapping layers first.
createBatchedTransport groups entries together, flushing either once 50 entries have accumulated or every 2 seconds, whichever happens first. This reduces the number of outbound requests considerably under normal load.
createQueuedTransport sits in front of the batcher and holds entries in an in memory queue, draining it every 100 milliseconds. The queue holds up to 1,000 entries. If it fills up faster than it can drain, typically because the remote provider is slow or temporarily unreachable, the oldest entries are dropped to make room for new ones, and onDrop logs a console warning with the number discarded.
A failing or slow transport never crashes the application and never blocks a
request. The cost of that resilience is that log entries can be silently
dropped during a sustained outage of your logging provider, with only a
console warning to mark it. If you suspect missing logs during an incident,
check your server console output for [Logger] warnings about a full queue.
Both layers attempt to flush on beforeExit, SIGTERM, and SIGINT, so a graceful shutdown does not lose buffered entries. This is best effort rather than guaranteed, particularly in serverless environments where a function can be terminated without those signals firing.
Console logging, meaning console output only, is the default and requires no setup. It is enough for most early stage applications.
| Console | Axiom | Better Stack | |
|---|---|---|---|
| Prerequisites | None | API token and dataset | Source token (plus an optional custom ingest URL) |
| Centralized search | No | Yes | Yes |
| Long term retention | Limited to your hosting platform | Yes | Yes |
| Dashboards and alerting | No | Yes | Yes |
| Best for | Solo founders, MVPs, low traffic apps | Teams that want generous free tier ingestion and fast search | Teams that want built in uptime monitoring alongside logs |
Consider moving to a centralized transport provider once multiple people need access to logs, you need retention longer than your hosting platform provides by default, or you find yourself regularly digging through scattered deployment logs to investigate an issue.
If you have decided to move beyond console logging, here is how Axiom and Better Stack compare directly:
| Axiom | Better Stack | |
|---|---|---|
| Query experience | Fast, piped query language (APL) with partial SQL support, built for digging through high volume structured data | Live tail and structured search, generally friendlier for non technical teammates |
| Alerting | Available, centered around log queries | Built in, and pairs naturally with Better Stack’s uptime monitoring if you use it |
| Beyond logging | Logs only | Can also handle uptime monitoring and incident alerting in the same dashboard |
| Best for | Teams comfortable writing queries against high log volume | Teams that want logs, uptime checks, and alerting in one place |
Either one is a reasonable choice. If you already use, or plan to use, uptime monitoring, Better Stack consolidates that alongside your logs. If your primary need is fast search over a large volume of structured log data, Axiom is built for that specifically.
Create an account at axiom.co if you do not already have one.
Create a dataset.
In the Axiom dashboard, go to Datasets and click New Dataset. Give it a name, such as your application’s name. Copy the dataset name into your environment variables:
AXIOM_DATASET=Create an API token.
Go to Settings, API Tokens and create a new token with ingest permission for the dataset you just created. Copy the token into your environment variables:
AXIOM_TOKEN=Set the active transport provider in config/site.ts:
observability: {
logging: {
transportProvider: "axiom" as "console" | "axiom" | "betterstack",
},
},Restart your development server.
bun devThe transport is selected once, at module load time. Changes will not take effect until the server restarts.
Create an account at betterstack.com if you do not already have one, and open the Logs product.
Create a source.
Go to Sources and click Connect source. Choose a name, such as your application’s name, and select a platform (any HTTP based option works, since logs are sent as plain JSON over HTTPS). Copy the generated source token into your environment variables:
BETTERSTACK_SOURCE_TOKEN=Optionally set a custom ingest URL.
Better Stack provides a default ingest endpoint that Oolvay falls back to automatically. You only need to set this if your account uses a region specific or custom ingest host, which Better Stack will indicate on the source’s setup page.
BETTERSTACK_INGEST_URL=Set the active transport provider in config/site.ts:
observability: {
logging: {
transportProvider: "betterstack" as "console" | "axiom" | "betterstack",
},
},Restart your development server.
bun devIf transportProvider is set to axiom or betterstack but the required
credentials are missing, Oolvay does not throw an error. It
logs a console warning and silently falls back to console-only logging. See
Common issues below.
logging: {
enabled: true,
level: "debug",
transportProvider: "console" as "console" | "axiom" | "betterstack",
sampling: {
debug: 1.0,
info: 1.0,
warn: 1.0,
error: 1.0,
fatal: 1.0,
},
},Sampling is bypassed in development regardless of these values, so this is mainly for clarity. Console output is usually all you need locally.
logging: {
enabled: true,
level: "info",
transportProvider: "axiom" as "console" | "axiom" | "betterstack", // or Better Stack
sampling: {
debug: 0.0,
info: 0.3,
warn: 1.0,
error: 1.0,
fatal: 1.0,
},
},Dropping debug entries entirely and sampling info down keeps volume manageable while ensuring nothing at warn or above is ever missed.
Check, in order: that enabled is not set to false in config/site.ts, that the level you are logging at is at or above your configured level (an info call will never appear if level is warn), and, if you are in production, that the sampling rate for that level in config/site.ts is not set to 0.0.
This almost always means the required credentials are missing. When transportProvider is set to axiom or betterstack but the corresponding token, dataset, or ingest URL is absent, Oolvay falls back to console logging automatically and prints a one line warning to the console, such as:
[Logger] Axiom selected but AXIOM_TOKEN or AXIOM_DATASET is missing. Falling back to console logging.Check your server console output for this warning, and confirm the required credentials are present and correctly named in .env.local, and that transportProvider is set correctly in config/site.ts.
This is expected behavior, not a bug. Sampling only applies when NODE_ENV is production. If your sampling rate for a given level is below 1.0, some entries at that level will be dropped intentionally to manage volume. Raise the rate for that level in config/site.ts if you need full visibility while investigating something specific, then lower it again afterward.
Redaction only inspects metadata, not the message string. If a sensitive value was interpolated directly into the message text rather than passed as metadata, it will not be caught. Move the value into the metadata object instead. If the value is using a key name that is not in the denylist, add it to SENSITIVE_KEYS in lib/logger/redact.ts.
Logs sent through a centralized transport pass through a batching layer (up to a 2 second delay, or sooner once 50 entries accumulate) and a queueing layer (draining every 100 milliseconds). This means there is always a small delay between a log being written and it arriving at Axiom or Better Stack, and a sudden process termination can lose whatever was still sitting in the queue, despite the best effort flush handlers on shutdown signals.
This happens when logger is called directly instead of through actionLogger() or requestLogger(). The base logger has no request context attached. Use the child logger helpers consistently inside server actions and route handlers if you want entries to be correlated by request.
Logging and Sentry are designed to work together, not in competition. Logging gives you a complete record of what your application did. Sentry tells you the moment something needs your attention, and gives you the context to fix it quickly. Most production applications benefit from both.