Ross Hagan

Observability of a Next.js app using OpenTelemetry

January 9, 2022

A brief summary of some time spent trying to get OpenTelemetry data out of a Next.js app and into the fantastic Honeycomb.

If you've found a better way, particularly not having to write a custom Next.js server, then please do give me a shout on Twitter!

Key Takeaways

OpenTelemetry Setup

The first thing we need is to start the SDK. In this case it's the NodeSDK, but you'd look for a similar SDK in other languages.

We're using grpc here to export data over to honeycomb, but there is an http exporter too!

The dependencies:

npm install @grpc/grpc-js
npm install @opentelemetry/sdk-node
npm install @opentelemetry/auto-instrumentations-node
npm install @opentelemetry/resources
npm install @opentelemetry/semantic-conventions
npm install @opentelemetry/exporter-trace-otlp-grpc

The core script, we'll refer to this as start.js:

const process = require("process")
const { Metadata, credentials } = require("@grpc/grpc-js")
const opentelemetry = require("@opentelemetry/sdk-node")
const {
  getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node")
const { Resource } = require("@opentelemetry/resources")
const {
  SemanticResourceAttributes,
} = require("@opentelemetry/semantic-conventions")
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-grpc")

// Run your custom nextjs server, we'll show more on this next
const { startServer } = require("./server")

const metadata = new Metadata()
metadata.set("x-honeycomb-team", "<YOUR HONEYCOMB API KEY>")
metadata.set("x-honeycomb-dataset", "<NAME OF YOUR TARGET HONEYCOMB DATA SET>")
const traceExporter = new OTLPTraceExporter({
  url: "grpc://api.honeycomb.io:443/",
  credentials: credentials.createSsl(),
  metadata,
})

// configure the SDK to export telemetry data to the console
// enable all auto-instrumentations from the meta package
const sdk = new opentelemetry.NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: "<your-app-name>",
  }),
  traceExporter,
  instrumentations: [
    getNodeAutoInstrumentations({
    
      // Each of the auto-instrumentations
      // can have config set here or you can
      // npm install each individually and not use the auto-instruments
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: [
          // Pattern match to filter endpoints
          // that you really want to stop altogether
          "/ping",
          
          // You can filter conditionally
          // Next.js gets a little too chatty
          // if you trace all the incoming requests
          ...(process.env.NODE_ENV !== "production"
            ? [/^\/_next\/static.*/]
            : []),
        ],
        
        // This gives your request spans a more meaningful name
        // than `HTTP GET`
        requestHook: (span, request) => {
          span.setAttributes({
            name: `${request.method} ${request.url || request.path}`,
          })
        },
        
        // Re-assign the root span's attributes
        startIncomingSpanHook: (request) => {
          return {
            name: `${request.method} ${request.url || request.path}`,
            "request.path": request.url || request.path,
          }
        },
      }
    }),
  ],
})

// initialize the SDK and register with the OpenTelemetry API
// this enables the API to record telemetry
sdk
  .start()
  .then(() => console.log("Tracing initialized"))
  .then(() => startServer())
  .catch((error) =>
    console.log("Error initializing tracing and starting server", error)
  )

// gracefully shut down the SDK on process exit
process.on("SIGTERM", () => {
  sdk
    .shutdown()
    .then(() => console.log("Tracing terminated"))
    .catch((error) => console.log("Error terminating tracing", error))
    .finally(() => process.exit(0))
})

Custom Next.js server

We now need to actually write the startServer method we imported in the start.js script.

Here's what it looks like to get a basic custom server in place.

This should be in a file called server.js to make the above code work, but rename as you see fit!

const { createServer } = require("http")
const { parse } = require("url")
const next = require("next")

const dev = process.env.NODE_ENV !== "production"
const app = next({ dev })
const handle = app.getRequestHandler()

module.exports = {
  startServer: async function startServer() {
    return app.prepare().then(() => {
      createServer((req, res) => {
        const parsedUrl = parse(req.url, true)
        handle(req, res, parsedUrl)
      }).listen(5000, (err) => {
        if (err) throw err
        console.log("> Ready on http://localhost:5000")
      })
    })
  },
}

Now you'll need to target those in your package.json if you'd like to keep using npm run dev. Update the scripts > dev to read node src/start.js

So now when you run npm run dev, it's firing up the start script. That script is waking up the OpenTelemetry NodeSDK, and configuring the exporter.

Once the SDK is started, then you're good to start the Next.js server without having to worry about whether it will race to be instrumented.

Instrumenting the app

At this point we've got some pretty solid instrumentation, thanks to the auto instrumentation libraries. This is usually only part of the story and you'll likely end up wanting to add some customisation to the spans.

To do that we need the api library:

npm install @opentelemetry/api

Now you've got that installed, anywhere in your real Next.js app code can pull in a tracer. Note that this is only for the backend, so calls from within the getStaticProps, getServerSideProps, getInitialProps.

For instrumenting your client-side code, something I'll be looking at next, see the opentelemetry browser setup guide.

Personally, I created a tiny tracer.ts module just to save repeating some code:

import opentelemetry from '@opentelemetry/api'

export function getTracer() {
    return opentelemetry.trace.getTracer("your-application-name")
}

From there I'd say pop over to the OpenTelemetry.io JavaScript tracing guide to get a good read on how to get things done.

The most basic form of how to get a child span to appear:

import opentelemetry from '@opentelemetry/api'

const span = opentelemetry.trace.getTracer("<your-app-name>").startSpan("<custom span name>")
// do some work
span.end()

Alternative to a custom server

I originally started off with a completely separate tracing.js script with the contents of our start.js script without the startServer call.

This separates the telemetry SDK startup from the server. You can then keep the Next.js built-in startup behaviours by using the node --require (-r) to load in a module before starting the Next app.

In your npm run dev script in your package.json this looks like:

node -r tracing.js ./node_modules/.bin/next dev

I switched away from this after frustration getting the node command to run in a Dockerfile as this was destined for a Google Kubernetes Engine runtime. Also, some concern about use of the --require flag.

See if it works for you to do it this way, as a Next.js custom server comes with some consequences documented in their docs!

Wrap up

Unsurprisingly, there's a lot more to take this to a polished state.

For example, there's no sampler set up here so beware a flood of data if your newly instrumented app is high traffic. Take a look at reducing the sampling rate on a per-url basis before you completely filter out a url in the http instrument too.

I hope this has given you some starting points to get to that first eureka moment of seeing traces in Honeycomb!