Obserwowalność w Next.js: OpenTelemetry i ClickStack krok po kroku

MJ

Mateusz JanotaCEO & Founder

W tym poradniku podłączymy OpenTelemetry do aplikacji Next.js tak, żeby od razu widzieć konkretne żądania, zapytania do danych i własne operacje biznesowe. Nie kończymy na „zainstaluj paczkę”. Będzie plik instrumentation.ts, zmienne środowiskowe, helper do spanów, przykład akcji serwerowej, route handler i wrapper na fetch.

OpenTelemetry jest najbardziej przydatne wtedy, gdy odpowiada na proste pytania: gdzie aplikacja traci czas, który request się wywalił i która integracja opóźnia użytkownika. ClickStack daje lokalne miejsce, w którym te dane można od razu zobaczyć.

Co zbudujemy

Uruchomimy lokalny ClickStack w Dockerze. Potem skonfigurujemy aplikację Next.js, wyślemy trace’y przez OTLP, dodamy własne spany i sprawdzimy dane w HyperDX.

Po przejściu poradnika będziesz mieć taki zestaw plików:

txt
src/
  instrumentation.ts
  app/
    api/
      contact/
        route.ts
  lib/
    otel.ts
    traced-fetch.ts
    sanity.ts
  server-actions/
    pageviews.ts
.env.local
package.json

Krok 1: uruchom ClickStack lokalnie

Najprostszy wariant na start to obraz all-in-one. Dostajesz ClickHouse, kolektor OpenTelemetry i HyperDX w jednym kontenerze.

bash
docker run --name clickstack \
  -p 8123:8123 \
  -p 8080:8080 \
  -p 4317:4317 \
  -p 4318:4318 \
  clickhouse/clickstack-all-in-one:latest

Po starcie sprawdź, czy usługi odpowiadają:

bash
curl -f http://localhost:8080 >/dev/null && echo "HyperDX działa"
curl -f http://localhost:8123/ping && echo "ClickHouse działa"

HyperDX powinien być dostępny pod http://localhost:8080. OTLP przez HTTP działa na porcie 4318.

Krok 2: dodaj pakiety do Next.js

bash
pnpm add @vercel/otel @opentelemetry/api

Jeśli nie używasz pnpm:

bash
npm install @vercel/otel @opentelemetry/api
# albo
bun add @vercel/otel @opentelemetry/api

@vercel/otel ustawia SDK po stronie Next.js. @opentelemetry/api daje API, którego użyjesz do własnych spanów.

Krok 3: utwórz instrumentation.ts

Jeśli projekt ma katalog src/app, plik powinien trafić do src/instrumentation.ts. Jeśli masz app w root projektu, utwórz instrumentation.ts obok niego.

ts
// src/instrumentation.ts
import { registerOTel } from '@vercel/otel'

export function register() {
  registerOTel({
    serviceName: process.env.OTEL_SERVICE_NAME ?? 'zanreal-next-app',
  })
}

W Next.js 15 i nowszych ten plik działa bez dawnej flagi experimental.instrumentationHook. Jeśli siedzisz na starszej wersji Next.js, sprawdź dokumentację swojej wersji.

Krok 4: ustaw zmienne środowiskowe

Lokalnie kierujemy OTLP prosto do ClickStack:

dotenv
# .env.local
OTEL_SERVICE_NAME=zanreal-next-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
NEXT_OTEL_VERBOSE=1

NEXT_OTEL_VERBOSE=1 jest przydatne tylko lokalnie. Wypisuje spany w terminalu, więc szybciej widzisz, czy instrumentacja działa. Na produkcji zwykle tego nie włączamy.

Krok 5: dodaj komendę do pracy lokalnej

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

Uruchom aplikację:

bash
pnpm dev:otel

Wejdź na kilka stron aplikacji. W terminalu powinny pojawić się spany generowane automatycznie przez Next.js. W HyperDX wyszukaj usługę zanreal-next-app.

Krok 6: dodaj helper do własnych spanów

Automatyczne spany są dobre na start, ale prawdziwa wartość pojawia się przy operacjach biznesowych: formularz kontaktowy, zapis leadu, pobranie wpisu z CMS, wysłanie maila, płatność, generowanie raportu.

ts
// src/lib/otel.ts
import { SpanStatusCode, trace } from '@opentelemetry/api'

type SpanAttributes = Record<string, string | number | boolean | undefined>

export async function withSpan<T>(
  name: string,
  attributes: SpanAttributes,
  callback: () => Promise<T>,
): Promise<T> {
  const tracer = trace.getTracer('zanreal-next-app')

  return tracer.startActiveSpan(name, { attributes }, async (span) => {
    try {
      const result = await callback()
      span.setStatus({ code: SpanStatusCode.OK })
      return result
    } catch (error) {
      span.recordException(error as Error)
      span.setStatus({ code: SpanStatusCode.ERROR })
      throw error
    } finally {
      span.end()
    }
  })
}

Ten helper robi trzy rzeczy: nadaje nazwę operacji, dopisuje atrybuty i pilnuje, żeby span zawsze został zamknięty.

Krok 7: opakuj akcję serwerową

Przykład: zliczanie odsłon wpisu blogowego w Redisie.

ts
// src/server-actions/pageviews.ts
'use server'

import { Redis } from '@upstash/redis'
import { withSpan } from '@/lib/otel'

const redis = Redis.fromEnv()

export async function incrementPageview(slug: string, locale: string) {
  return withSpan(
    'blog.increment_pageview',
    {
      'app.feature': 'blog',
      'app.locale': locale,
      'blog.slug': slug,
      'db.system': 'redis',
    },
    async () => {
      return redis.incr(`pageview:${locale}:${slug}`)
    },
  )
}

Po wejściu na wpis blogowy w HyperDX powinien pojawić się span blog.increment_pageview. Dzięki atrybutom możesz filtrować po języku, slugu i systemie zewnętrznym.

Krok 8: opakuj route handler

Route handler jest dobrym miejscem na własne spany, bo często łączy walidację, zapis do bazy i wysyłkę maila.

ts
// src/app/api/contact/route.ts
import { NextResponse } from 'next/server'
import { withSpan } from '@/lib/otel'
import { saveLead } from '@/lib/leads'
import { sendLeadEmail } from '@/lib/email'

export async function POST(request: Request) {
  return withSpan(
    'contact.submit',
    {
      'app.feature': 'contact',
      'http.route': '/api/contact',
      'http.method': 'POST',
    },
    async () => {
      const body = await request.json()

      const lead = await withSpan(
        'contact.save_lead',
        { 'db.system': 'postgres' },
        () => saveLead(body),
      )

      await withSpan(
        'contact.send_email',
        { 'external.service': 'resend' },
        () => sendLeadEmail(lead),
      )

      return NextResponse.json({ ok: true })
    },
  )
}

W HyperDX zobaczysz jeden trace dla contact.submit i dwa zagnieżdżone spany: zapis leada oraz wysyłkę maila. Jeśli mail zwalnia, nie musisz zgadywać.

Krok 9: dodaj wrapper na fetch

Nie chcesz ręcznie opisywać każdego wywołania zewnętrznego API od zera. Warto zrobić mały wrapper.

ts
// src/lib/traced-fetch.ts
import { withSpan } from '@/lib/otel'

type TracedFetchOptions = RequestInit & {
  service: string
  operation?: string
}

export async function tracedFetch(url: string, options: TracedFetchOptions) {
  const { service, operation = 'http.request', ...init } = options

  return withSpan(
    `external.${service}.${operation}`,
    {
      'external.service': service,
      'http.method': init.method ?? 'GET',
    },
    async () => {
      const response = await fetch(url, init)

      if (!response.ok) {
        throw new Error(`${service} responded with ${response.status}`)
      }

      return response
    },
  )
}

Przykład użycia dla Sanity:

ts
// src/lib/sanity.ts
import { tracedFetch } from '@/lib/traced-fetch'

export async function fetchPost(slug: string) {
  const response = await tracedFetch('https://example.api.sanity.io/v2025-01-01/data/query/production', {
    service: 'sanity',
    operation: 'fetch_post',
    method: 'POST',
    body: JSON.stringify({
      query: '*[_type == "post" && slug.current == $slug][0]',
      params: { slug },
    }),
  })

  return response.json()
}

Nie zapisuj pełnego URL z tokenami ani całego payloadu w atrybutach. Nazwa usługi i operacji zwykle wystarczą.

Krok 10: sprawdź dane w HyperDX

Po wykonaniu kilku żądań sprawdź, czy dane faktycznie dotarły.

bash
curl -f http://localhost:8080 >/dev/null && echo "HyperDX działa"
curl -f http://localhost:8123/ping && echo "ClickHouse działa"

W HyperDX szukaj po:

  • service name: zanreal-next-app

  • span name: contact.submit

  • span name: blog.increment_pageview

  • atrybut: external.service = sanity

Jeśli widzisz automatyczne spany Next.js, ale nie widzisz własnych, problem jest zwykle prosty: kod nie przechodzi przez helper albo span kończy się poza miejscem, które faktycznie wykonuje pracę.

Atrybuty, które warto zbierać

Dobre atrybuty pomagają filtrować dane bez przenoszenia danych wrażliwych do systemu obserwowalności.

ts
const attributes = {
  'app.feature': 'checkout',
  'app.locale': 'pl',
  'http.route': '/api/contact',
  'db.system': 'postgres',
  'external.service': 'resend',
  'cache.hit': false,
}

Zbieraj nazwy funkcji produktu, trasę jako wzorzec, nazwę integracji, typ bazy i informację o cache. Nie zbieraj pełnych maili, numerów telefonu, tokenów ani pełnych promptów użytkownika.

Produkcja i Vercel

Na produkcji zmienia się głównie endpoint i autoryzacja. Sam mechanizm zostaje taki sam.

dotenv
OTEL_SERVICE_NAME=zanreal-next-app
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer <token>

Na Vercelu uważaj na ilość danych. Nie musisz zbierać 100% trace’ów, żeby wiedzieć, co się dzieje. Zacznij od krytycznych ścieżek: formularz kontaktowy, checkout, wyszukiwanie, logowanie i integracje z AI.

Najczęstsze problemy

Brak danych w HyperDX: sprawdź port 4318, protokół http/protobuf i to, czy kontener ClickStack nadal działa.

Spany są w terminalu, ale nie ma ich w ClickStack: problem leży w eksporcie OTLP albo w dostępie do kolektora.

Widzisz spany Next.js, ale nie własne: sprawdź, czy dana akcja faktycznie wywołuje withSpan.

Własne spany są płaskie, bez zagnieżdżenia: użyj startActiveSpan, a nie ręcznie odpalonego startSpan bez kontekstu.

Za dużo szumu: opisuj operacje biznesowe, nie każdą małą funkcję pomocniczą.

Dobrze wdrożone OpenTelemetry nie daje kolejnego ładnego panelu. Daje odpowiedź na pytanie: gdzie użytkownik albo system naprawdę czeka.

Masz projekt w głowie?

Napisz do nas

Porozmawiajmy o tym, jak możemy pomóc w realizacji Twoich pomysłów.

Zanek

Nie nadążasz za zmianami w świecie AI?

Pozwól, że weźmiemy to na siebie. Co tydzień destylujemy najważniejsze wydarzenia ze świata AI w skupiony, 5-minutowy przegląd - żebyś był na bieżąco bez szumu.

Dowiedz się więcej
Tygodnik AIonline
Wyselekcjonowane wiadomości AI do porannej kawy. Co tydzień.
Wyślij mi swój email, żeby się zapisać.