# Add structured application logs to Vercel Functions

**Author:** Nic Dillon

---

Vercel's platform observability stack covers execution duration, outbound API timing, peak memory, and request metadata through [Runtime Logs](https://vercel.com/docs/logs/runtime), [Session Tracing](https://vercel.com/docs/tracing/session-tracing), and [Log Drains](https://vercel.com/docs/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.

## Prerequisites

- **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.

* * *

## 1\. Configure Next.js logging and source maps

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](https://nextjs.org/docs/app/api-reference/config/next-config-js/logging) 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](https://vercel.com/docs/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.
  

## 2\. Use a structured logger utility

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](https://vercel.com/docs/logs/runtime#log-filters) and parseable by [Log Drain](https://vercel.com/docs/drains) services like Datadog, Splunk, or LogDNA.

If you'd rather not build one yourself, [evlog.dev](http://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.

``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 })); } } };``

### Controlling stack traces

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.

## 3\. Add application-level timing and memory context to API routes

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:

`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 } ); } }`

### Correlating with Vercel's platform logs

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.

### Fluid Compute considerations

- **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`](https://vercel.com/docs/functions/functions-api-reference#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](https://vercel.com/docs/logs/runtime) volume (capped at 256 KB per log output / 1 MB per request - [Vercel Runtime Limits](https://vercel.com/docs/logs/runtime#limits)). If you use a [Log Drain](https://vercel.com/docs/drains), 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.
  

## 4\. Track database query and internal service latency

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:

`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. ## 5\. Follow secure logging practices Following [OWASP logging guidelines](https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/#how-to-prevent), sanitize all user data before it reaches your logs:

`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()`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) (Node.js 17+) or a library like [rfdc](https://github.com/davidmarkclements/rfdc) that handles circular references and edge cases.

This is especially important if you use [Log Drains](https://vercel.com/docs/drains). Everything your function writes to `console.*` is shipped to your external provider. Sanitization ensures sensitive data doesn't leave Vercel's infrastructure.

## 6\. Toggle detailed logs with Flags SDK

The [Flags SDK](https://flags-sdk.dev/) and [Flags Explorer](https://vercel.com/docs/flags/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:**

`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:**

`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**](https://vercel.com/docs/flags/flags-explorer) by exposing your flags via a route handler:

`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 }`

## 7\. View your custom logs

Once deployed, access your custom logs through:

- **Vercel Dashboard**: Navigate to your project and [select the](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Flogs&title=Open+Runtime+Logs) [**Logs**](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Flogs&title=Open+Runtime+Logs) [tab](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Flogs&title=Open+Runtime+Logs). 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](https://vercel.com/docs/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](https://vercel.com/docs/tracing/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`](https://vercel.com/docs/tracing/instrumentation) package to capture framework and custom spans via OpenTelemetry. See the [Tracing docs](https://vercel.com/docs/tracing) for setup details.

## When to consider OpenTelemetry

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](https://vercel.com/docs/tracing/instrumentation) 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](https://www.ibm.com/think/insights/observability-pillars) for a framework on when to use each.

* * *

## Expected outcome

After implementing this guide, your [Vercel Runtime Logs](https://vercel.com/docs/logs/runtime) 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](https://vercel.com/docs/fluid-compute) instance at log time, useful for spotting pressure trends but not attributable to a single request.

* * *

## Related resources

**Vercel docs**

- [Runtime Logs](https://vercel.com/docs/logs/runtime)
  
- [Log Drains](https://vercel.com/docs/drains)
  
- [Trace Drains](https://vercel.com/docs/drains/reference/traces)
  
- [Session Tracing](https://vercel.com/docs/tracing/session-tracing)
  
- [Fluid Compute](https://vercel.com/docs/fluid-compute)
  
- [Vercel Flags](https://vercel.com/docs/flags)
  
- [Flags Explorer](https://vercel.com/docs/flags/flags-explorer)
  

**Tools mentioned**

- [Flags SDK](https://flags-sdk.dev/)
  
- [Pino Logging Template](https://vercel.com/templates/next.js/pino-logging)
  
- [evlog.dev](http://evlog.dev)
  

**External references**

- [Next.js logging config](https://nextjs.org/docs/app/api-reference/config/next-config-js/logging)
  
- [OWASP logging guidelines](https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/#how-to-prevent)
  
- [The Three Pillars of Observability](https://www.ibm.com/think/insights/observability-pillars)
  

* * *

## Get help

If you encounter issues not covered by this guide:

- Visit [Vercel Support](https://vercel.com/help).
  
- Check [Vercel Community](https://community.vercel.com/) for community Q&A.

---

[View full KB sitemap](/kb/sitemap.md)
