Skip to content

Add structured application logs to Vercel Functions

Learn how to add structured application logs to Vercel Functions to help troubleshoot function issues in real time.

13 min read
Last updated May 28, 2026

Vercel's platform observability stack covers execution duration, outbound API timing, peak memory, and request metadata through Runtime Logs, Session Tracing, and Log Drains.

To see inside your application code (which database query was slow, what payload caused an error, how to correlate failures by ID), you need custom logs at the application level. Add a structured JSON logger to your Vercel Functions so fields like error IDs, durations, and query counts become filterable in the Runtime Logs dashboard and parseable by any Log Drain consumer.

  • Next.js 14+ App Router project. The route handler syntax and next.config.js options in this guide assume App Router.
  • Node.js 18+ runtime. The logger uses crypto.randomUUID() and performance.now(), both available in Node.js 18+.
  • Vercel CLI. Install with npm i -g vercel to use vercel logs in the final step.
  • Familiarity with next.config.js, route handlers, and Vercel Environment Variables.

Steps 1 and 6 (Next.js config, structured logger, Flags SDK) are Next.js-specific. Steps 2, 4, and 5 (database wrapper, sanitization) are framework-agnostic and apply to any Node.js Vercel Function.


Before adding custom logs, configure Next.js so that errors surfaced in Vercel's Runtime Logs include readable stack traces pointing to your original source files.

Note: the logging options below (logging.fetches and logging.incomingRequests) apply to local development only. They have no effect on production builds.

Enable built-in logging filters and source maps in your next.config.js:

module.exports = {
logging: {
fetches: {
fullUrl: true,
hmrRefreshes: true
},
incomingRequests: {
ignore: [
/\\/api\\/health/,
/\\/_next\\//,
/\\/favicon\\.ico/
]
}
},
experimental: {
serverSourceMaps: true
}
}

Then add the following Environment Variable in your Vercel project settings:

NODE_OPTIONS=--enable-source-maps

Important notes:

  • Scope source maps to Preview only. serverSourceMaps: true generates source map files at build time for all environments, including Production. Without NODE_OPTIONS=--enable-source-maps at runtime, Node.js won't resolve them, but the map files do ship in the build artifact. To prevent Production builds from including source maps at all, use serverSourceMaps: process.env.VERCEL_ENV !== 'production' in next.config.js. If you prefer to keep serverSourceMaps: true globally, set NODE_OPTIONS=--enable-source-maps for Preview only. See Vercel Environment Variables for per-environment configuration.
  • logging config is dev-only. The logging.fetches and logging.incomingRequests options only apply during local development and have no effect in production builds.

Vercel Runtime Logs capture everything your function sends to console.log, console.warn, and console.error. By outputting structured JSON, your custom fields (error IDs, durations, counts) become filterable and searchable directly in the Vercel Logs dashboard and parseable by Log Drain services like Datadog, Splunk, or LogDNA.

If you'd rather not build one yourself, evlog.dev is a lightweight structured logger built for Vercel Functions. Alternatively, the implementation below shows how to build a custom one, which is useful if you need to tailor behavior like error ID generation, stack trace gating, or the Flags SDK verbose toggle. The examples include an explicit level field in each JSON payload. The Runtime Logs dashboard filter (level:error, level:warning, level:info) reads the level inferred from the console method used, not the JSON body, so the dashboard filter works regardless of what the level field contains. The explicit field is for Log Drain consumers that parse the JSON body directly, where you may want consistent field names for structured queries.

lib/logger.js
import crypto from 'crypto';
// true only when running locally via `next dev` or `vercel dev`
const isDevelopment = process.env.NODE_ENV === 'development';
// true on Vercel Preview when ENABLE_STACK_TRACES=true is set in that environment
const enableStackTraces = process.env.ENABLE_STACK_TRACES === 'true';
export const toMB = (bytes) => Math.round(bytes / 1024 / 1024 * 100) / 100;
export const logger = {
info: (message, data = {}, { verbose = false } = {}) => {
const payload = verbose
? { level: 'INFO', message, timestamp: new Date().toISOString(), ...data }
: { level: 'INFO', message, timestamp: new Date().toISOString(), durationMs: data.durationMs };
console.log(JSON.stringify(payload));
},
warn: (message, data = {}) => {
console.warn(JSON.stringify({
level: 'WARN',
message,
timestamp: new Date().toISOString(),
...data
}));
},
error: (message, error, data = {}) => {
const errorId = crypto.randomUUID();
console.error(JSON.stringify({
level: 'ERROR',
message,
errorId,
errorType: error?.constructor?.name || 'Unknown',
errorMessage: error?.message,
timestamp: new Date().toISOString(),
...data
}));
if ((isDevelopment || enableStackTraces) && error) {
console.error(JSON.stringify({
level: 'ERROR_DETAIL',
errorId,
errorMessage: error.message,
stack: error.stack
}));
}
return errorId;
},
debug: (message, data = {}) => {
if (isDevelopment) {
console.debug(JSON.stringify({
level: 'DEBUG',
message,
timestamp: new Date().toISOString(),
...data
}));
}
}
};

These two constants follow the same pattern but cover different environments:

  • isDevelopment is true only when running locally via next dev or vercel dev
  • enableStackTraces is the hook for Vercel Preview

Set ENABLE_STACK_TRACES=true in your Preview environment to get full call stacks during pre-production testing, while keeping Production logs free of internal file paths. In Production, the logger still captures errorType, errorMessage, and errorId for triage. Combined with serverSourceMaps from Step 1, Preview stack traces will reference your original source files.

The debug log level is also gated by isDevelopment, so logger.debug() calls are silently skipped on all Vercel deployments. Use logger.info() for anything you want visible in the Runtime Logs dashboard.

Vercel's Runtime Logs already show overall execution duration, peak memory, and external API timing at the platform level. Custom logs add the layer beneath that, letting you measure specific operations within your handler (e.g., how long the database query took vs. the JSON serialization) and capture application-specific context like result counts or payload sizes that the platform can't infer:

app/api/users/route.ts
import { logger, toMB } from '@/lib/logger';
export async function GET(request) {
const startTime = performance.now();
try {
const users = await fetchUsers();
const duration = performance.now() - startTime;
const mem = process.memoryUsage();
logger.info('GET /api/users completed', {
durationMs: Math.round(duration),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
rssMB: toMB(mem.rss),
resultCount: users.length
});
return Response.json(users);
} catch (error) {
const duration = performance.now() - startTime;
const mem = process.memoryUsage();
const errorId = logger.error('GET /api/users failed', error, {
durationMs: Math.round(duration),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
rssMB: toMB(mem.rss)
});
return Response.json(
{ error: 'Internal Server Error', errorId },
{ status: 500 }
);
}
}

Correlation between your custom logs and Vercel's platform view is automatic. No additional fields are required to make it work. In the Runtime Logs dashboard, all log lines for a given request are grouped under that invocation alongside Vercel's platform metrics (duration, memory, outbound requests). If you use a Log Drain, Vercel's pipeline includes requestId in the metadata for every log entry, so Log consumers can correlate records without any application-level field.

  • Memory values reflect the entire VM instance, not a single request: a single Fluid Compute instance may execute multiple functions across concurrent loads, so a spike may not be directly attributable to the request you're debugging. If point-in-time snapshots suggest a problem but don't pinpoint the cause, heap snapshot dumps are a last-resort diagnostic option. Use Node.js's built-in v8 module in local development or Preview only:
    // app/api/your-route/route.ts (temporary — remove after debugging)
    import v8 from 'v8';
    // Gate behind isDevelopment. Do not run in production
    if (isDevelopment) {
    const filename = v8.writeHeapSnapshot();
    console.log(`Heap snapshot written to: ${filename}`);
    }
    Open the .heapsnapshot file in Chrome DevTools under the Memory tab to inspect object allocations and identify what's retaining memory.
  • Timing is request-scoped and safe. performance.now() values are stored in local variables, so each request tracks its own duration. CPU contention under heavy concurrency may inflate durations slightly.
  • Keep the logger stateless. The logger above is concurrency-safe by design. Avoid extending it with global buffers, batched writes, or request-scoped singletons that could leak data between concurrent requests.
  • Shipping observability data without blocking response. If you want to send memory snapshots or detailed metrics to an external endpoint without using a Log Drain, use waitUntil to dispatch the data after the response has been returned, keeping your function's response time unaffected by the outbound observability call.

Do not use global exception handlers. process.on('uncaughtException') and process.on('unhandledRejection') are process-level. Under Fluid Compute, calling process.exit(1) kills all concurrent in-flight requests, not just the failing one. Use try/catch in route handlers instead, and rely on Vercel's built-in fatal error capture for true crashes (OOM, segfaults).

  • Be intentional about logging volume. Custom logging adds overhead: JSON.stringify() on large objects can be CPU-expensive, and every console.log call adds to your Runtime Logs volume (capped at 256 KB per log output / 1 MB per request - Vercel Runtime Limits). If you use a Log Drain, more log output means more data shipped to your provider, which directly affects cost. Log the fields you actually need for debugging in production (timing, error IDs, counts), and use the Flags SDK verbose toggle to enable verbose fields like memory snapshots only when actively troubleshooting.

Vercel automatically tracks outbound fetch requests with timing and cache status. You can see these as "Outgoing Requests" in any Runtime Log entry. But database queries using native drivers (Postgres, MySQL, Redis), internal service calls over non-HTTP protocols, and other operations that don't use fetch are invisible to the platform. These are often the true source of timeouts. Wrap them with timing:

lib/db.js
import { logger } from './logger';
export async function queryDatabase(sql, params = []) {
const start = performance.now();
try {
const result = await db.query(sql, params);
const duration = performance.now() - start;
logger.info('Database query completed', {
durationMs: Math.round(duration),
rowCount: result.rows?.length || 0,
query: sql.substring(0, 80)
});
if (duration > 1000) {
logger.warn('Slow database query detected', {
durationMs: Math.round(duration),
query: sql.substring(0, 80)
});
}
return result;
} catch (error) {
const duration = performance.now() - start;
logger.error('Database query failed', error, {
durationMs: Math.round(duration),
query: sql.substring(0, 80)
});
throw error;
}
}

Apply the same wrapper pattern to any internal operation you want to measure, including cache lookups, file processing, queue publishes, or calls to internal microservices that aren't outbound fetch requests visible in Vercel's platform-level logs.

Following OWASP logging guidelines, sanitize all user data before it reaches your logs:

lib/secure-logger.js
export const sanitizeForLogging = (data) => {
// Deep copy (structuredClone for Node 17+) to avoid mutating the caller's object.
// JSON.parse/stringify works for simple data; for production,
// use a fast library with circular reference protection (e.g. rfdc, structuredClone).
const sanitized = JSON.parse(JSON.stringify(data));
// Remove sensitive fields entirely
const sensitiveFields = [
'password', 'token', 'secret', 'key', 'ssn',
'creditCard', 'cvv', 'pin', 'sessionId'
];
sensitiveFields.forEach(field => {
if (sanitized[field]) {
delete sanitized[field];
}
});
// Mask PII fields
if (sanitized.email) {
sanitized.email = sanitized.email.replace(/(.{2}).*(@.*)/, '$1***$2');
}
if (sanitized.phone) {
sanitized.phone = sanitized.phone.replace(/(\\d{3})\\d{3}(\\d{4})/, '$1***$2');
}
return sanitized;
};

Follow these rules for all log output:

  • Sanitize before logging. Remove passwords, tokens, and Personally Identifiable Information (PII) before any field reaches console.*.
  • Use error IDs for correlation. Pass errorId to the client response instead of raw error messages or stack traces.
  • Emit full stack traces only in Preview. Gate stack trace output behind enableStackTraces as shown in Steps 1 and 2.
  • Audit logs periodically. Review what's shipping to your Log Drain for accidental sensitive data exposure.

For production use, consider replacing JSON.parse(JSON.stringify(...)) with structuredClone() (Node.js 17+) or a library like rfdc that handles circular references and edge cases.

This is especially important if you use Log Drains. Everything your function writes to console.* is shipped to your external provider. Sanitization ensures sensitive data doesn't leave Vercel's infrastructure.

The Flags SDK and Flags Explorer let you toggle verbose logging at runtime from the Vercel Toolbar per-session, without redeploying. This keeps baseline logs lean while giving you access to detailed diagnostics when troubleshooting.

Install the Flags SDK:

npm install flags

Create a FLAGS_SECRET environment variable in your Vercel project settings. Generate it with:

node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Define the flag:

flags.ts
import { flag } from 'flags/next';
export const detailedLogs = flag({
key: 'detailed-logs',
defaultValue: false,
description: 'Enable verbose structured logs with memory snapshots and full request context',
decide() {
return false;
}
});

The logger defined in Step 2 already supports the verbose option, so no changes to lib/logger.js are needed here. Update your route handler to pass the flag value:

Use the flag in your API routes:

app/api/users/route.ts
import { logger, toMB } from '@/lib/logger';
import { detailedLogs } from '@/flags';
export async function GET(request) {
const verbose = await detailedLogs();
const startTime = performance.now();
try {
const users = await fetchUsers();
const duration = performance.now() - startTime;
const mem = process.memoryUsage();
logger.info('GET /api/users completed', {
durationMs: Math.round(duration),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
rssMB: toMB(mem.rss),
resultCount: users.length
}, { verbose });
return Response.json(users);
} catch (error) {
const duration = performance.now() - startTime;
const mem = process.memoryUsage();
const errorId = logger.error('GET /api/users failed', error, {
durationMs: Math.round(duration),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
rssMB: toMB(mem.rss)
});
return Response.json({ error: 'Internal Server Error', errorId }, { status: 500 });
}
}

Set up the Flags Explorer by exposing your flags via a route handler:

app/.well-known/vercel/flags/route.ts
import { getProviderData, createFlagsDiscoveryEndpoint } from 'flags/next';
import * as flags from '@/flags';
export const GET = createFlagsDiscoveryEndpoint(async () => {
return getProviderData(flags);
});

Once deployed, open the Vercel Toolbar on any deployment and enable detailed-logs in the Flags Explorer panel. This sets an encrypted cookie for your session only, leaving other users to see normal log levels. Errors and warnings always log full context regardless of the flag; it only controls whether info-level logs include memory snapshots and other verbose metrics.

For deployments processing more than 100 req/s, consider percentage-based sampling rather than a binary on/off toggle. Enabling verbose logging for 1–5% of requests gives you representative diagnostic data without adding cost or noise across all traffic. You can implement this inside the flag's decide() function:

decide() {
return Math.random() < 0.05; // capture verbose logs for ~5% of requests
}

Once deployed, access your custom logs through:

  • Vercel Dashboard: Navigate to your project and select the Logs tab. Filter by level:error, level:warning, or level:info, and search by any field (such as an errorId) to find specific entries.
  • Vercel CLI: Run vercel logs to tail logs from the terminal.
  • Log Drains: Configure Log Drains to send logs to external observability platforms (Datadog, Splunk, LogDNA/Mezmo, or a custom webhook) for centralized analysis and alerting.

Beyond logs, use traces for request-level debugging. Session Tracing lets you visualize how a single request flows through Vercel's infrastructure, middleware, functions, and outbound API calls as a timeline of spans. Use a Page Trace to capture a single page load, or start a Session Trace to capture all requests during a debugging session. For deeper visibility, add the @vercel/otel package to capture framework and custom spans via OpenTelemetry. See the Tracing docs for setup details.

The structured logging pattern in this guide works for debugging within a single function invocation. If a request regularly touches more than two or three services (for example, an API route calling a database, a cache, an external API, and a queue), OpenTelemetry tracing gives you a unified timeline of spans across all of them, making it easier to pinpoint where latency or failures originate. Structured logs and OTel traces are complementary: logs capture the detail (payload sizes, business context, error messages), while traces capture the shape of a request across service boundaries. See The Three Pillars of Observability for a framework on when to use each.


After implementing this guide, your Vercel Runtime Logs will include structured entries alongside Vercel's platform metrics. In the Logs dashboard, you can filter by level:error to surface failures, search for an errorId to find a specific incident, or filter by level:warning to catch slow queries before they cause timeouts. Here's what your custom entries will look like:

Production baseline (lean fields only):

{
"level": "INFO",
"message": "GET /api/users completed",
"durationMs": 142,
"timestamp": "2026-01-15T10:30:45.123Z"
}
{
"level": "WARN",
"message": "Slow database query detected",
"durationMs": 2340,
"query": "SELECT * FROM orders WHERE user_id = $1 ORDER BY cre...",
"timestamp": "2026-01-15T10:30:47.463Z"
}
{
"level": "ERROR",
"message": "GET /api/orders failed",
"errorId": "a3f8c2d1-...",
"errorType": "TimeoutError",
"errorMessage": "Request timed out after 10000ms",
"durationMs": 10002,
"heapUsedMB": 98.7,
"heapTotalMB": 128.0,
"rssMB": 156.3,
"timestamp": "2026-01-15T10:31:00.125Z"
}

Preview with ENABLE_STACK_TRACES=true and detailed-logs flag enabled (full diagnostics):

{
"level": "INFO",
"message": "GET /api/users completed",
"durationMs": 142,
"heapUsedMB": 12.45,
"heapTotalMB": 48.2,
"rssMB": 64.1,
"resultCount": 25,
"timestamp": "2026-01-15T10:30:45.123Z"
}
{
"level": "ERROR",
"message": "GET /api/orders failed",
"errorId": "a3f8c2d1-...",
"errorType": "TimeoutError",
"errorMessage": "Request timed out after 10000ms",
"durationMs": 10002,
"heapUsedMB": 98.7,
"heapTotalMB": 128.0,
"rssMB": 156.3,
"timestamp": "2026-01-15T10:31:00.125Z"
}
{
"level": "ERROR_DETAIL",
"errorId": "a3f8c2d1-...",
"errorMessage": "Request timed out after 10000ms",
"stack": "TimeoutError: Request timed out after 10000ms\\n at fetchOrders (src/lib/orders.ts:42:11)\\n at GET (src/app/api/orders/route.ts:18:23)\\n at ..."
}

Use lean Production logs for triage in categories like slow dependencies, memory pressure, unhandled exceptions, etc., then reproduce in Preview with full stacks and verbose metrics to diagnose the root cause. Memory values reflect the shared Fluid Compute instance at log time, useful for spotting pressure trends but not attributable to a single request.


Vercel docs

Tools mentioned

External references


If you encounter issues not covered by this guide:

Was this helpful?

supported.