6 min read
63

Optimizing Next.js with OpenTelemetry

Unlock the full potential of your Next.js application with my comprehensive guide on performance optimization using OpenTelemetry and Docker.

Setting the Stage

Performance is the lifeblood of any successful web application. Users expect seamless experiences, and slow-loading pages can lead to frustration and abandonment.

Let's lay the groundwork by understanding the critical role of instrumentation in achieving optimal performance for your Next.js application.

Why does Instrumentation Matter?

Instrumentation involves adding code to your application to collect data on various aspects of its behavior. This data is invaluable for identifying bottlenecks, understanding resource consumption, and making informed decisions for optimization. In the context of web development, effective instrumentation provides insights into how your application interacts with its environment, enabling you to pinpoint areas for improvement.

Introduction to OpenTelemetry

Enter OpenTelemetry, a powerful and flexible observability framework. OpenTelemetry allows you to collect traces, metrics, and logs from your application, providing a comprehensive view of its performance. Traces represent the journey of a request through your application, while metrics offer quantitative measurements of various aspects, and logs provide detailed information for debugging.

Key Concepts: Tracks and Spans

As we dive into instrumentation, it's crucial to understand two fundamental concepts: tracks and spans. Tracks represent the broader paths that a request takes through your application, while spans break down these tracks into smaller, more manageable units. Think of tracks as chapters in a book and spans as individual sentences within each chapter. Together, they form a narrative that helps you comprehend your application's performance story.

Getting Your Docker Toolbox Ready

Now, we need to prepare our local envoironment. We will use Jaeger with Prometheus and Zipkin to visualize our traces and metrics.

Installing Docker

You can learn more about docker in my Docker Guide and Docker Compose Guide to go further without any problems.

Setting local environment

Now we need to clone a ready-to-use repository made by Vercel.

git clone https://github.com/vercel/opentelemetry-collector-dev-setup

so, just go into that repository

cd opentelemetry-collector-dev-setup

and run the following command to start the docker containers.

docker-compose up -d

That's it! Now we can access Jaeger UI using 0.0.0.0:16686.

Next.js Instrumentation

Creating a new project

If you want to instrument your project at the beggining starting from stratch, you can use the following command to create a new Next.js project using the OpenTelemetry example:

Repository URL

or just create a new project using the following command:

pnpm create next-app --example with-opentelemetry your-app-name

Instrumenting existing projects

First of all, we need to tell Next.js that we will be using an EXPERIMENTAL feature. To do that, we need to add the following lines to our next.config.js file.

next.config.js
const nextConfig = {
  // ...
  experimental: {
    instrumentationHook: true
  }
  // ...
}

Now, we need to install the following packages:

pnpm add @opentelemetry/api @vercel/otel

And at the final stage of basic instrumentation we need to create an instrumentation.ts file in same level as our pages of app folder.

instrumentation.ts
import { registerOTel } from '@vercel/otel';
 
export function register() {
  registerOTel('your-service-name');
}

Remember that if you have as src folder, you need to create this file in it.

Aaaaaaand, we're done with that part!

Testing instrumentation

We currently want to get as many spans as possible, so we will use the following command to run our project:

NEXT_OTEL_VERBOSE=1 next dev

If you want to have an easier access to it, just add new script to your package.json as I've done:

package.json
{
  "scripts": {
    "dev:otel": "NEXT_OTEL_VERBOSE=1 next dev"
  }
}

To make an test of our instrumentation, we just need... to open our browser and go to localhost:3000 and we shall see new traces incoming in our Jaeger UI.

Adding custom spans

OTel Wrapper for Next.js

I've made a small wrapper for Next.js to make it easier to use OpenTelemetry in your Next.js project. I'm placing it in a lib folder on my project's root.

@/lib/otel.ts
'use server';
 
import { type Span, trace } from '@opentelemetry/api';
 
export async function otel<T>(
  fnName: string,
  fn: (...args: any[]) => Promise<T>,
  ...props: any[]
): Promise<T> {
  const tracer = trace.getTracer(fnName);
  return tracer.startActiveSpan(fnName, async (span: Span) => {
    try {
      return await fn(...props);
    } finally {
      span.end();
    }
  });
}

But how to use it? It's simple! Just import it and use it as a wrapper for your functions, server actions, db calls or just anything.

actions.ts
'use server';
 
import { Redis } from '@upstash/redis';
import { otel } from '@/lib/otel';
 
const redis = Redis.fromEnv();
 
export async function increment(slug: string, prefix: string): Promise<void> {
  return otel('your-component', async () => {
    await redis.incr(['pageviews', prefix, slug].join(':'));
  });
}

or pass function with it's props to it ;)

actions.ts
'use server';
 
import { Redis } from '@upstash/redis';
import { otel } from '@/lib/otel';
 
const redis = Redis.fromEnv();
 
async function increment(slug: string, prefix: string): Promise<void> {
  await redis.incr(['pageviews', prefix, slug].join(':'));
}
 
export async function incrementWithOtel(
  slug: string,
  prefix: string
): Promise<void> {
  return otel('your-component', increment, slug, prefix);
}

Just like that! Now you can use it in your project and see how it works.

Adding custom spans via @opentelemetry/api

If you don't want to use my wrapper, you can use the following code to add custom spans to your project.

Active Span

actions.ts
'use server';
 
import { Redis } from '@upstash/redis';
import { trace } from '@opentelemetry/api';
 
const redis = Redis.fromEnv();
 
export async function increment(slug: string, prefix: string): Promise<void> {
  return trace
    .getTracer('your-component')
    .startActiveSpan('your-operation', async (span) => {
      try {
        await redis.incr(['pageviews', prefix, slug].join(':'));
      } finally {
        span.end();
      }
    });
}

Manual Span

actions.ts
'use server';
 
import { Redis } from '@upstash/redis';
import { trace } from '@opentelemetry/api';
 
const redis = Redis.fromEnv();
 
export async function increment(slug: string, prefix: string): Promise<void> {
  const span = trace.getTracer("your-component").startSpan("your-operation");
  await redis.incr(['pageviews', prefix, slug].join(':'));
  span.end();
}

Conclusion

As you wrap up the initial steps in this practical guide, your Dockerized environment is ready to capture and export crucial data through OpenTelemetry. The foundation is set for a deep dive into optimizing Next.js performance, armed with the insights needed to make informed decisions on where and how to enhance your application's speed and efficiency.

Get ready to transform your Next.js application into a high-performance masterpiece. The journey continues as you explore advanced instrumentation techniques and delve into the art of performance optimization with OpenTelemetry.

Now you know how to instrument your Next.js project using OpenTelemetry and Docker. I hope you enjoyed this article and learned something new. If you have any questions, feel free to reach out to me via contact.