Complete Guide to Setting Up OpenTelemetry with Next.js 15 for Production Observability

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Introduction

In the world of modern applications, it is not enough for your app to work — you need to know how it works. Are responses slow? Where does the server spend most of its time? What are the bottlenecks? This is where OpenTelemetry (OTel) comes in — the open standard for observability and distributed tracing.

In this comprehensive guide, you will learn how to:

  • Set up OpenTelemetry with Next.js 15 from scratch
  • Automatically trace API requests and pages
  • Add custom spans to measure critical operation performance
  • Export data to Jaeger for tracing and Prometheus for metrics
  • Visualize everything in an integrated Grafana dashboard
  • Deploy the setup in production with Docker Compose

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed on your machine
  • Docker and Docker Compose for local services
  • Basic knowledge of Next.js and TypeScript
  • Familiarity with HTTP and REST API concepts
  • A code editor (VS Code recommended)

What You Will Build

You will build a Next.js 15 application with a complete observability stack including:

  1. Automatic tracing for all HTTP requests and pages
  2. Custom spans for database operations and external services
  3. Performance metrics like response time and error rate
  4. Monitoring dashboard integrated in Grafana
  5. Alerts when performance thresholds are exceeded

Step 1: Create a Next.js 15 Project

Start by creating a new Next.js project:

npx create-next-app@latest otel-nextjs-demo --typescript --tailwind --app --src-dir
cd otel-nextjs-demo

Choose the default options when prompted. After installation, verify the project runs:

npm run dev

Step 2: Install OpenTelemetry Packages

Install the required OpenTelemetry packages:

npm install @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/sdk-trace-node \
  @opentelemetry/sdk-metrics \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/instrumentation-http \
  @opentelemetry/instrumentation-fetch \
  @vercel/otel

The @vercel/otel package provides optimized integration with Next.js and greatly simplifies the initial setup. You can use it with any OpenTelemetry provider, not just Vercel.

Step 3: Configure OpenTelemetry in Next.js

Enable the Experimental Feature

First, enable OpenTelemetry support in next.config.ts:

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  experimental: {
    instrumentationHook: true,
  },
};
 
export default nextConfig;

Create the Instrumentation File

Create src/instrumentation.ts — this is the entry point for OpenTelemetry:

// src/instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { NodeSDK } = await import("@opentelemetry/sdk-node");
    const { OTLPTraceExporter } = await import(
      "@opentelemetry/exporter-trace-otlp-http"
    );
    const { OTLPMetricExporter } = await import(
      "@opentelemetry/exporter-metrics-otlp-http"
    );
    const { PeriodicExportingMetricReader } = await import(
      "@opentelemetry/sdk-metrics"
    );
    const { Resource } = await import("@opentelemetry/resources");
    const {
      ATTR_SERVICE_NAME,
      ATTR_SERVICE_VERSION,
      ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
    } = await import("@opentelemetry/semantic-conventions");
    const { HttpInstrumentation } = await import(
      "@opentelemetry/instrumentation-http"
    );
 
    const resource = new Resource({
      [ATTR_SERVICE_NAME]: "nextjs-otel-demo",
      [ATTR_SERVICE_VERSION]: "1.0.0",
      [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]:
        process.env.NODE_ENV || "development",
    });
 
    const traceExporter = new OTLPTraceExporter({
      url:
        process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
        "http://localhost:4318/v1/traces",
    });
 
    const metricExporter = new OTLPMetricExporter({
      url:
        process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
        "http://localhost:4318/v1/metrics",
    });
 
    const sdk = new NodeSDK({
      resource,
      traceExporter,
      metricReader: new PeriodicExportingMetricReader({
        exporter: metricExporter,
        exportIntervalMillis: 10000,
      }),
      instrumentations: [new HttpInstrumentation()],
    });
 
    sdk.start();
 
    process.on("SIGTERM", () => {
      sdk.shutdown().then(
        () => console.log("OpenTelemetry SDK shut down successfully"),
        (err) => console.error("Error shutting down OpenTelemetry SDK", err)
      );
    });
  }
}

The process.env.NEXT_RUNTIME === "nodejs" check is essential because the OpenTelemetry SDK only works in the Node.js runtime and does not support Edge Runtime. Without this check, your app will fail when using Middleware or Edge Functions.

Step 4: Set Up Monitoring Services with Docker Compose

Create a docker-compose.yml file at the project root to run Jaeger, Prometheus, Grafana, and the OpenTelemetry Collector:

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP
      - "8889:8889"   # Prometheus metrics
    depends_on:
      - jaeger
 
  jaeger:
    image: jaegertracing/all-in-one:latest
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - "16686:16686" # Jaeger UI
      - "14268:14268" # Jaeger collector
 
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
 
  grafana:
    image: grafana/grafana:latest
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin123
    volumes:
      - grafana-data:/var/lib/grafana
    ports:
      - "3001:3000"
    depends_on:
      - prometheus
      - jaeger
 
volumes:
  grafana-data:

Configure the OpenTelemetry Collector

Create otel-collector-config.yml:

# otel-collector-config.yml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
 
processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
 
exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: "0.0.0.0:8889"
    namespace: "nextjs"
 
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

Configure Prometheus

Create prometheus.yml:

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
 
scrape_configs:
  - job_name: "otel-collector"
    static_configs:
      - targets: ["otel-collector:8889"]
  - job_name: "nextjs-app"
    static_configs:
      - targets: ["host.docker.internal:3000"]

Start all services:

docker compose up -d

Step 5: Add Custom Spans

Create a Tracing Helper

Create src/lib/tracing.ts for easy-to-use tracing utilities:

// src/lib/tracing.ts
import { trace, SpanStatusCode, Span, context } from "@opentelemetry/api";
 
const tracer = trace.getTracer("nextjs-app", "1.0.0");
 
export function withSpan<T>(
  name: string,
  fn: (span: Span) => Promise<T>,
  attributes?: Record<string, string | number | boolean>
): Promise<T> {
  return tracer.startActiveSpan(name, async (span) => {
    try {
      if (attributes) {
        Object.entries(attributes).forEach(([key, value]) => {
          span.setAttribute(key, value);
        });
      }
      const result = await fn(span);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error instanceof Error ? error.message : "Unknown error",
      });
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end();
    }
  });
}
 
export function createDbSpan<T>(
  operation: string,
  table: string,
  fn: (span: Span) => Promise<T>
): Promise<T> {
  return withSpan(`db.${operation}`, fn, {
    "db.system": "postgresql",
    "db.operation": operation,
    "db.sql.table": table,
  });
}
 
export function createExternalApiSpan<T>(
  service: string,
  endpoint: string,
  fn: (span: Span) => Promise<T>
): Promise<T> {
  return withSpan(`external.${service}`, fn, {
    "http.url": endpoint,
    "peer.service": service,
  });
}
 
export { tracer };

Use Spans in API Routes

Create an API route that uses custom tracing:

// src/app/api/users/route.ts
import { NextResponse } from "next/server";
import { withSpan, createDbSpan } from "@/lib/tracing";
 
// Simulated database
async function fetchUsersFromDb() {
  return createDbSpan("SELECT", "users", async (span) => {
    // Simulate database latency
    await new Promise((resolve) => setTimeout(resolve, 50));
 
    span.setAttribute("db.rows_affected", 10);
 
    return [
      { id: 1, name: "Ahmed", email: "ahmed@example.com" },
      { id: 2, name: "Sara", email: "sara@example.com" },
      { id: 3, name: "Mohamed", email: "mohamed@example.com" },
    ];
  });
}
 
async function enrichUserData(users: any[]) {
  return withSpan(
    "enrichUserData",
    async (span) => {
      span.setAttribute("users.count", users.length);
 
      // Simulate enrichment from external service
      await new Promise((resolve) => setTimeout(resolve, 30));
 
      return users.map((user) => ({
        ...user,
        avatar: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
      }));
    },
    { "enrichment.source": "dicebear" }
  );
}
 
export async function GET() {
  return withSpan("GET /api/users", async (span) => {
    span.setAttribute("http.method", "GET");
    span.setAttribute("http.route", "/api/users");
 
    const users = await fetchUsersFromDb();
    const enrichedUsers = await enrichUserData(users);
 
    span.setAttribute("response.count", enrichedUsers.length);
 
    return NextResponse.json({
      users: enrichedUsers,
      total: enrichedUsers.length,
    });
  });
}

Step 6: Add Custom Metrics

Create src/lib/metrics.ts to define metrics:

// src/lib/metrics.ts
import { metrics } from "@opentelemetry/api";
 
const meter = metrics.getMeter("nextjs-app", "1.0.0");
 
// Request counter
export const requestCounter = meter.createCounter("http_requests_total", {
  description: "Total number of HTTP requests",
  unit: "requests",
});
 
// Response time histogram
export const responseTimeHistogram = meter.createHistogram(
  "http_response_duration_ms",
  {
    description: "HTTP response duration in milliseconds",
    unit: "ms",
  }
);
 
// Error counter
export const errorCounter = meter.createCounter("http_errors_total", {
  description: "Total number of HTTP errors",
  unit: "errors",
});
 
// Active connections gauge
export const activeConnections = meter.createUpDownCounter(
  "active_connections",
  {
    description: "Number of currently active connections",
    unit: "connections",
  }
);

Integrate Metrics in Middleware

Create middleware to automatically record metrics:

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const start = Date.now();
  const response = NextResponse.next();
 
  // Add tracing headers
  response.headers.set("X-Request-Start", start.toString());
  response.headers.set(
    "X-Request-Id",
    crypto.randomUUID()
  );
 
  return response;
}
 
export const config = {
  matcher: ["/api/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
};

Create an API Route with Full Metrics

// src/app/api/health/route.ts
import { NextResponse } from "next/server";
import {
  requestCounter,
  responseTimeHistogram,
  errorCounter,
} from "@/lib/metrics";
import { withSpan } from "@/lib/tracing";
 
export async function GET() {
  const start = Date.now();
 
  return withSpan("GET /api/health", async (span) => {
    try {
      // Record request
      requestCounter.add(1, {
        method: "GET",
        route: "/api/health",
      });
 
      // Simulate health checks
      const checks = {
        database: "healthy",
        cache: "healthy",
        external_api: "healthy",
        uptime: process.uptime(),
        timestamp: new Date().toISOString(),
      };
 
      // Record response time
      const duration = Date.now() - start;
      responseTimeHistogram.record(duration, {
        method: "GET",
        route: "/api/health",
        status: "200",
      });
 
      span.setAttribute("health.status", "healthy");
      span.setAttribute("health.duration_ms", duration);
 
      return NextResponse.json(checks);
    } catch (error) {
      errorCounter.add(1, {
        method: "GET",
        route: "/api/health",
        error_type: "health_check_failed",
      });
      throw error;
    }
  });
}

Step 7: Build a Monitoring Dashboard Page

Create a simple page to display monitoring status:

// src/app/dashboard/page.tsx
"use client";
 
import { useEffect, useState } from "react";
 
interface HealthStatus {
  database: string;
  cache: string;
  external_api: string;
  uptime: number;
  timestamp: string;
}
 
export default function DashboardPage() {
  const [health, setHealth] = useState<HealthStatus | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    const fetchHealth = async () => {
      try {
        const res = await fetch("/api/health");
        const data = await res.json();
        setHealth(data);
      } catch (err) {
        console.error("Failed to fetch health:", err);
      } finally {
        setLoading(false);
      }
    };
 
    fetchHealth();
    const interval = setInterval(fetchHealth, 5000);
    return () => clearInterval(interval);
  }, []);
 
  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
      </div>
    );
  }
 
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">
        Monitoring Dashboard
      </h1>
 
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
        {health && (
          <>
            <StatusCard title="Database" status={health.database} />
            <StatusCard title="Cache" status={health.cache} />
            <StatusCard title="External API" status={health.external_api} />
          </>
        )}
      </div>
 
      <div className="bg-gray-900 rounded-lg p-6">
        <h2 className="text-xl font-semibold mb-4">Quick Links</h2>
        <div className="space-y-3">
          <ExternalLink
            href="http://localhost:16686"
            label="Jaeger UI — View Traces"
          />
          <ExternalLink
            href="http://localhost:9090"
            label="Prometheus — Query Metrics"
          />
          <ExternalLink
            href="http://localhost:3001"
            label="Grafana — Dashboards"
          />
        </div>
      </div>
    </div>
  );
}
 
function StatusCard({
  title,
  status,
}: {
  title: string;
  status: string;
}) {
  const isHealthy = status === "healthy";
  return (
    <div className="bg-gray-800 rounded-lg p-6">
      <h3 className="text-sm font-medium text-gray-400 mb-2">{title}</h3>
      <div className="flex items-center gap-2">
        <div
          className={`w-3 h-3 rounded-full ${
            isHealthy ? "bg-green-500" : "bg-red-500"
          }`}
        />
        <span className={isHealthy ? "text-green-400" : "text-red-400"}>
          {isHealthy ? "Healthy" : "Unhealthy"}
        </span>
      </div>
    </div>
  );
}
 
function ExternalLink({
  href,
  label,
}: {
  href: string;
  label: string;
}) {
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
    >
      <span>&rarr;</span>
      {label}
    </a>
  );
}

Step 8: Set Up Grafana Dashboard

After starting the services with docker compose up -d, open Grafana at http://localhost:3001 and log in with admin / admin123.

Add Data Sources

  1. Go to Configuration then Data Sources
  2. Add Prometheus with URL: http://prometheus:9090
  3. Add Jaeger with URL: http://jaeger:16686

Create a Custom Dashboard

Create a grafana-dashboard.json file for importing into Grafana:

{
  "dashboard": {
    "title": "Next.js Application Monitoring",
    "panels": [
      {
        "title": "Request Rate",
        "type": "timeseries",
        "targets": [
          {
            "expr": "rate(nextjs_http_requests_total[5m])",
            "legendFormat": "Requests/sec"
          }
        ]
      },
      {
        "title": "Response Time (p95)",
        "type": "gauge",
        "targets": [
          {
            "expr": "histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m]))",
            "legendFormat": "p95 Latency"
          }
        ]
      },
      {
        "title": "Error Rate",
        "type": "stat",
        "targets": [
          {
            "expr": "rate(nextjs_http_errors_total[5m])",
            "legendFormat": "Errors/sec"
          }
        ]
      }
    ]
  }
}

You can import this file through the Grafana UI via Dashboards, then Import, then paste the JSON content. You will get a ready-made dashboard with three panels.

Step 9: Advanced Tracing — Connecting Frontend to Backend

To correlate browser traces with server traces, create a client-side utility:

// src/lib/client-tracing.ts
export function createTracedFetch(
  originalFetch: typeof fetch
): typeof fetch {
  return async (input, init) => {
    const requestId = crypto.randomUUID();
    const start = performance.now();
 
    const headers = new Headers(init?.headers);
    headers.set("X-Request-Id", requestId);
    headers.set("X-Client-Start", start.toString());
 
    try {
      const response = await originalFetch(input, {
        ...init,
        headers,
      });
 
      const duration = performance.now() - start;
      console.debug(
        `[Trace] ${
          typeof input === "string" ? input : input.url
        } - ${duration.toFixed(1)}ms`
      );
 
      return response;
    } catch (error) {
      const duration = performance.now() - start;
      console.error(
        `[Trace Error] ${
          typeof input === "string" ? input : input.url
        } - ${duration.toFixed(1)}ms`,
        error
      );
      throw error;
    }
  };
}

Use the Traced Fetch in Components

// src/hooks/useTracedFetch.ts
"use client";
 
import { useCallback } from "react";
import { createTracedFetch } from "@/lib/client-tracing";
 
const tracedFetch = createTracedFetch(fetch);
 
export function useTracedFetch() {
  const fetchWithTrace = useCallback(
    async <T>(url: string, options?: RequestInit): Promise<T> => {
      const response = await tracedFetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      return response.json();
    },
    []
  );
 
  return { fetch: fetchWithTrace };
}

Step 10: Set Up Alerts

Prometheus Alert Rules

Create alert-rules.yml:

# alert-rules.yml
groups:
  - name: nextjs-alerts
    rules:
      - alert: HighErrorRate
        expr: rate(nextjs_http_errors_total[5m]) > 0.1
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High error rate in Next.js application"
          description: "Error rate exceeded 10% over the last 5 minutes"
 
      - alert: SlowResponseTime
        expr: histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m])) > 2000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Slow response time detected"
          description: "p95 response time exceeded 2000ms"
 
      - alert: HighMemoryUsage
        expr: process_resident_memory_bytes > 512 * 1024 * 1024
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "High memory usage"
          description: "Memory usage exceeded 512MB"

Update prometheus.yml to include alert rules:

# prometheus.yml (updated)
global:
  scrape_interval: 15s
  evaluation_interval: 15s
 
rule_files:
  - "alert-rules.yml"
 
scrape_configs:
  - job_name: "otel-collector"
    static_configs:
      - targets: ["otel-collector:8889"]

Step 11: Configure Environment Variables

Create .env.local for local development:

# .env.local
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=nextjs-otel-demo
OTEL_LOG_LEVEL=info
NODE_ENV=development

And .env.production for production:

# .env.production
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.yourdomain.com
OTEL_SERVICE_NAME=nextjs-otel-production
OTEL_LOG_LEVEL=warn
NODE_ENV=production

Step 12: Test the Complete System

Start the Services

# Start monitoring services
docker compose up -d
 
# Start the Next.js app
npm run dev

Generate Trace Data

Use curl or the browser to send requests:

# Single request
curl http://localhost:3000/api/users
 
# Health check
curl http://localhost:3000/api/health
 
# Generate light load (10 requests)
for i in $(seq 1 10); do
  curl -s http://localhost:3000/api/users > /dev/null
  echo "Request $i sent"
done

Verify the Results

  1. Jaeger — Open http://localhost:16686

    • Select the nextjs-otel-demo service
    • You will see traces for each request with detailed timing
    • Click any trace to see the span tree
  2. Prometheus — Open http://localhost:9090

    • Try the query: nextjs_http_requests_total
    • Try: histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m]))
  3. Grafana — Open http://localhost:3001

    • Import the monitoring dashboard from the JSON file
    • Watch metrics in real time

Troubleshooting

No Traces Appearing in Jaeger

  • Verify the OpenTelemetry Collector is running: docker compose logs otel-collector
  • Check that OTEL_EXPORTER_OTLP_ENDPOINT points to the correct address
  • Make sure instrumentationHook: true is enabled in next.config.ts

Metrics Not Showing in Prometheus

  • Verify the Collector is exporting to Prometheus: curl http://localhost:8889/metrics
  • Check scrape_configs is properly configured in prometheus.yml

Errors in Edge Runtime Environment

  • OpenTelemetry SDK does not support Edge Runtime
  • Use the NEXT_RUNTIME === "nodejs" guard in your instrumentation file
  • Do not import OTel packages in Middleware or Edge API Routes

High Memory Consumption

  • Reduce send_batch_size in Collector settings
  • Increase exportIntervalMillis to reduce export frequency
  • Use sampling to reduce data volume in production

Production Best Practices

1. Use Sampling

In production, you do not need to trace every request. Use tail-based sampling:

// In instrumentation.ts
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-node";
 
const sdk = new NodeSDK({
  // Only trace 10% of requests
  sampler: new TraceIdRatioBasedSampler(0.1),
  // ... rest of config
});

2. Avoid Over-Tracing

  • Do not add spans for very fast operations (under 1ms)
  • Avoid recording sensitive data in attributes
  • Use appropriate log levels for each environment

3. Secure Endpoints

# In production, use TLS and Authentication
exporters:
  otlp/secure:
    endpoint: https://otel.yourdomain.com:4317
    tls:
      cert_file: /etc/ssl/certs/client.crt
      key_file: /etc/ssl/private/client.key
    headers:
      Authorization: "Bearer YOUR_TOKEN"

4. Monitor the OpenTelemetry Collector Itself

# Add Collector health monitoring
extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  zpages:
    endpoint: 0.0.0.0:55679
 
service:
  extensions: [health_check, zpages]

Next Steps

After completing this guide, you can expand to:

  • Distributed Tracing — Correlate traces across multiple microservices
  • Custom Metrics — Add business-specific metrics like active user counts
  • Log Correlation — Link logs to traces using trace_id
  • Real User Monitoring — Track browser performance with OpenTelemetry Browser SDK
  • CI/CD Integration — Add tracing to your deployment pipeline

Conclusion

In this guide, you learned how to:

  1. Set up OpenTelemetry with Next.js 15 using the instrumentation hook
  2. Trace requests automatically and add custom spans for specific operations
  3. Collect metrics like request rate, response time, and error rate
  4. Deploy a complete observability stack with Jaeger, Prometheus, and Grafana
  5. Configure alerts to detect issues before users notice them

Good observability is not a luxury — it is a necessity for any production application. With OpenTelemetry, you now have complete visibility into what is happening inside your application.


Want to read more tutorials? Check out our latest tutorial on React 19 Server Actions & useActionState: The Complete Form Handling Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles