Logging to DataDog from Next.js

Here's how to log to DataDog from Next.js.

You could use this approach to do most any JSON logging from Next.js, but some of the tips here are specific to DataDog.

Why JSON Log

You want logging on your app for visibility when debugging. You want JSON logging because it provides more structure to your log lines. Instead of just filtering a log based on string matches, you can filter in a more faceted way on fields that you send to the log as attributes in a JSON object. DataDog is a good log platform supports consuming these kinds of logs.

Client and Server

Next.js blurs the lines between server and client. It allows you to write code that runs separately in either server or client. But code that runs on the client is pre-rendered on the server, so it actually runs in both places.

The details of logging will be bit different in the client vs. the server. But we want the way that the logging API feels to be the same on either side of that chasm.

To that end, let's create a common log interface in logging-types.ts:

export interface Logger {
  debug: LogFn
  info: LogFn
  warn: LogFn
  error: LogFn
}

export interface LogFn {
  (msg: string, obj?: object): void
}

We'll keep it simple, to 4 levels of logging. We'll always put our string message first and then an optional json object to follow.

To standardize another detail, we'll use process.env.NEXT_PUBLIC_LOG_LEVEL to set the log level on both the client and the server.

Pino the Server

To log on the server, we'll use pino:

npm install pino

By default, pino logs to stdout. By default, this is where our DataDog agent will be picking up logs, so this is great for our case.

We'll create a server-logging.ts module. It'll have a singleton logger that we'll initialize on first use. The logger is available via an sLog function ("s" for server):

import pino from 'pino'
import { Logger } from './logging-types'

let logger: Logger

export function sLog() {
  if (!logger && isServer()) {
    logger = initLogger()
  }
  return logger ?? console
}

function initLogger() {
  const jsonLogger = pino({
    base: null,
    level: process.env.NEXT_PUBLIC_LOG_LEVEL ?? 'info',
  })

  const serverLogger: Logger = {
    debug(msg, obj) {
      jsonLogger.debug(obj, msg)
    },
    info(msg, obj) {
      jsonLogger.info(obj, msg)
    },
    warn(msg, obj) {
      jsonLogger.warn(obj, msg)
    },
    error(msg, obj) {
      jsonLogger.error(obj, msg)
    },
  }
  return serverLogger
}

function isServer() {
  return typeof window === 'undefined'
}

Note that our serverLogger implementation is reversing the pino log API order to match our Logger interface.

Two unapparent details are important to a good DataDog integration.

First, pino allows a transport option, but it shouldn't be used if you can help it. Even if we try to explicitly set the transport to stdout, as follows:

transport: {
  target: 'pino/file',
  options: {
    destination: 1, // 1 = stdout
  },
},

Then when this pino code is run on the client, we'll get an error like this, as separately reported on StackOverflow:

- error uncaughtException: Error: Cannot find module 'C:\starttwo-productionlot-frontend\.next\server\app\home\lib\worker.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
    at Module._load (node:internal/modules/cjs/loader:920:27)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at MessagePort. (node:internal/main/worker_thread:164:24)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:737:20)
    at exports.emitMessage (node:internal/per_context/messageport:23:28) {
  digest: undefined
}

Secondly, by default the base logger in pino will add pid and hostname attributes to the JSON log line. These attributes interfere with proper DataDog tagging (even though they're not on the list of DD reserved attributes. We set base: null to remove them.

@datadog/browser-logs on Client

In the browser, we don't have a DataDog agent to pick up stdout automatically. Instead, we use a library from DataDog called @datadog/browser-logs. When we call our log function, it'll ship the log info off to DataDog via HTTP requests.

We'll create a similar client-logging.ts module and implement our Logger interface for browser logging, per DD docs:

'use client'

import { StatusType, datadogLogs } from '@datadog/browser-logs'
import { Logger } from './logging-types'
import { sLog } from './server-logging'

let logger: Logger

export function cLog() {
  if (!logger && isClient()) {
    logger = initLogger()
  }
  return logger ?? sLog()
}

function initLogger() {
  const CLIENT_TOKEN = process.env.NEXT_PUBLIC_DATA_DOG_CLIENT_TOKEN

  if (!CLIENT_TOKEN) throw new Error('Missing client logging config')

  datadogLogs.init({
    clientToken: CLIENT_TOKEN as string,
    forwardErrorsToLogs: true,
    service: 'my-app-name-in-dd',
    sessionSampleRate: 100,
  })

  const jsonLogger = datadogLogs.createLogger('client', {
    level: (process.env.NEXT_PUBLIC_LOG_LEVEL as StatusType) ?? StatusType.info,
  })

  const clientLogger: Logger = {
    debug(msg, obj) {
      jsonLogger.debug(msg, obj)
    },
    info(msg, obj) {
      jsonLogger.info(msg, obj)
    },
    warn(msg, obj) {
      jsonLogger.warn(msg, obj)
    },
    error(msg, obj) {
      jsonLogger.error(msg, obj)
    },
  }

  return clientLogger
}

function isClient() {
  return typeof window !== 'undefined'
}

This looks very familiar to the server side of things. The most interesting things is the fallback in cLog to call sLog(). This is for the case that a client component which is being prerendered on the server and calls cLog(). We're not on exactly in the browser yet (ie, !isClient()), so we'll go use that pino logger we set up for the server. In other words, the "client" logger does both: it logs as the client wants and as the server wants. Yay, Next.js.

Use the Logger

Now you can import the server logger anywhere on the server:

import { sLog } from './server-logging'

sLog().info('Hello from the server', { wow: 'data' })

And similarly on the client:

'use client'

import { cLog } from './client-logging'

cLog().info('Hello from the client', { still: 'data' })

Testing Logging

Want to test your logging now? Deploy and load some pages in your app. You should see browser logs. If you have some client interaction you can perform to trigger in-browser renders, you'll see the client logs.

To trigger a test browser log, you can also directly call the following in the browser devtools:

window.DD_LOGS.logger.info('Jake testing from browser', { some: 'thing' })

And that's the setup, start to end. How do ya'll do app logging from Next.js?