Jake Teton‑Landis

Perfection enthusiast and byte craftsman splitting time between Miami, FL and New York, NY.

Interested in human productivity, big levers, and design.

GithubMastodon
TwitterLinkedIn

Observing NextJS in Production

April 2021

NextJS offers a great developer experience and impressive performance, but has a number of frustrating limitations that make it difficult to deploy in production: There's no way to plug in performance monitoring like Datadog APM, or customize error handling to use a reporting system like Sentry, and logs and errors are printed to STDOUT/STDERR unstructured with no way to customize them.

The strategies for hosting NextJS apps in production basically boil down to:

  1. Make your site totally static, so that there's nothing to worry about. Use the static host of your choice. Give up all API features, previewing, regeneration, etc.
  2. Deploy with Vercel, but give up all control over builds and hosting. No option to use Datadog APM or other dependencies that require an "agent".
  3. Deploy manually with next start, but be blind to state of the system in production.
  4. Deploy manually and use a custom server entrypoint to add error handling and performance monitoring, but give up performance and features.

What we want

  1. A way to load Datadog APM dd-trace NodeJS package before importing NextJS, so that dd-trace can inject performance monitoring into lower level packages like http.
  2. A way to customizing logging so that logs can be parsed by our logging pipeline. Ideally, all logs would be JSON written to STDOUT or a UDP port.

Why not use a custom entrypoint?

The "escape hatch" is to use a "custom server" entrypoint instead of the recommended next start command, but this has significant drawbacks, as the docs warn:

Before deciding to use a custom server please keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements. A custom server will remove important performance optimizations, like serverless functions and Automatic Static Optimization.

Yikes; the whole reason we're using NextJS is for Automatic Static Optimization, so this is unacceptable.

Using preloading

The NodeJS CLI has an option to require a node module before importing and executing the entrypoint script: -r module / --require module. If we put our customization in a module that we preload before next start, we can work around many of NextJS's issues. We'll be able to use vanilla next start self-hosting, but get to have Datadog APM, error reporting, and customized logging.

1. Create your preload

Create a server-preload.js file in your NextJS project root. Here's a lightly-edited version of Notion's server-preload.js. Below you'll see how we set up Datadog tracing and fix the logging situation.

// @ts-check
"use strict"
/**
* Set up datadog tracing. This should be called first, so Datadog can hook
* all the other dependencies like `http`.
*/
function setUpDatadogTracing() {
const { tracer: Tracer } = require('dd-trace')
const tracer = Tracer.init({
// Your options here.
runtimeMetrics: true,
logInjection: true,
})
}
/**
* Polyfill DOMParser for react-intl
* Otherwise react-intl spews errors related to formatting
* messages with <xml>in them</xml>
*/
function setUpDOMParser() {
const xmldom = require("xmldom")
global["DOMParser"] = xmldom.DOMParser
}
/**
* Set up logging. Monkey patches a bunch of stuff.
*/
function setUpLogging() {
// pino is a simple JSON logger with Datadog integration.
// By default it logs to STDOUT.
const pino = require('pino')
const logger = pino({
// Your options here.
})
function getLoggingFunction(/** @type {string} */ levelName) {
const baseLogFn = (logger[levelName] || logger.info).bind(logger)
return function patchedLog(/** @type {any[]} */ ...parts) {
/** @type {object | undefined} */
let data = undefined
/** @type {object | undefined} */
let error = undefined
/** @type {object | undefined} */
const nativeError = parts.find(
it =>
(it && it instanceof Error) ||
(it && typeof it === "object" && "name" in it && "message" in it)
)
if (nativeError) {
error = cleanObjectForSerialization(nativeError)
// If you use Sentry, Rollbar, etc, you could capture the error here.
// ErrorThingy.report(nativeError)
}
// If next is trying to log funky stuff, put it into the data object.
if (parts.length > 1) {
data = data || {}
data.parts = parts.map(part => cleanObjectForSerialization(part))
}
const messages =
nativeError && parts.length === 1 ? [nativeError.toString()] : parts
baseLogFn({ data, error, type: levelName }, ...messages)
}
}
// Monkey-patch Next.js logger.
// See https://github.com/atkinchris/next-logger/blob/main/index.js
// See https://github.com/vercel/next.js/blob/canary/packages/next/build/output/log.ts
const nextBuiltInLogger = require("next/dist/build/output/log")
for (const [property, value] of Object.entries(nextBuiltInLogger)) {
if (typeof value !== "function") {
continue
}
nextBuiltInLogger[property] = getLoggingFunction(property)
}
/**
* Monkey-patch global console.log logger. Yes. Sigh.
* @type {Array<keyof typeof console>}
*/
const loggingProperties = ["log", "debug", "info", "warn", "error"]
for (const property of loggingProperties) {
console[property] = getLoggingFunction(property)
}
// Add general error logging.
process.on("unhandledRejection", (error, promise) => {
logger.error(
{
type: "unhandledRejection",
error: cleanObjectForSerialization(error),
data: { promise: cleanObjectForSerialization(promise) },
},
`${error}`
)
})
process.on("uncaughtException", error => {
logger.error(
{ type: "uncaughtException", error: cleanObjectForSerialization(error) },
`${error}`
)
})
}
function cleanObjectForSerialization(value) {
// Clean up or copy `value` so our logger or error reporting system
// can record it.
//
// Because our logger `pino` uses JSON.stringify, we need to do
// the following here:
//
// 1. Remove all cycles. JSON.stringify throws an error when you pass
// a value with cyclical references.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value
// 2. Because JSON.stringify only serializes enumerable properties, we
// need to copy interesting, but non-enumerable properties like
// value.name and value.message for errors:
// JSON.stringify(new Error('nothing serialized')) returns '{}'
//
// Implementing this correctly is beyond the scope of my example.
return value
}
setUpDatadogTracing()
setUpDOMParser()
setUpLogging()

(Not seeing a big code block? Try refreshing, or click here to view the gist directly.)

2. Require your preload when you next start

Now you'll need to load your preload file when you start NextJS on the server. Everywhere you have next start, replace with:

node --require ./server-preload.js ./node_modules/.bin/next start
#    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#    Load our preload before running `next start`

You may beed to adjust paths to fit your app depending on your working directory where your app runs in production.

That's all there is to it!

Direct your questions/comments/concerns to @jitl on twitter.