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:
src/
instrumentation.ts
app/
api/
contact/
route.ts
lib/
otel.ts
traced-fetch.ts
sanity.ts
server-actions/
pageviews.ts
.env.local
package.jsonsrc/
instrumentation.ts
app/
api/
contact/
route.ts
lib/
otel.ts
traced-fetch.ts
sanity.ts
server-actions/
pageviews.ts
.env.local
package.jsonKrok 1: uruchom ClickStack lokalnie
Najprostszy wariant na start to obraz all-in-one. Dostajesz ClickHouse, kolektor OpenTelemetry i HyperDX w jednym kontenerze.
docker run --name clickstack \
-p 8123:8123 \
-p 8080:8080 \
-p 4317:4317 \
-p 4318:4318 \
clickhouse/clickstack-all-in-one:latestdocker run --name clickstack \
-p 8123:8123 \
-p 8080:8080 \
-p 4317:4317 \
-p 4318:4318 \
clickhouse/clickstack-all-in-one:latestPo starcie sprawdź, czy usługi odpowiadają:
curl -f http://localhost:8080 >/dev/null && echo "HyperDX działa"
curl -f http://localhost:8123/ping && echo "ClickHouse działa"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
pnpm add @vercel/otel @opentelemetry/apipnpm add @vercel/otel @opentelemetry/apiJeśli nie używasz pnpm:
npm install @vercel/otel @opentelemetry/api
# albo
bun add @vercel/otel @opentelemetry/apinpm 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.
// src/instrumentation.ts
import { registerOTel } from '@vercel/otel'
export function register() {
registerOTel({
serviceName: process.env.OTEL_SERVICE_NAME ?? 'zanreal-next-app',
})
}// 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:
# .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# .env.local
OTEL_SERVICE_NAME=zanreal-next-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
NEXT_OTEL_VERBOSE=1NEXT_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
{
"scripts": {
"dev:otel": "NEXT_OTEL_VERBOSE=1 next dev"
}
}{
"scripts": {
"dev:otel": "NEXT_OTEL_VERBOSE=1 next dev"
}
}Uruchom aplikację:
pnpm dev:otelpnpm dev:otelWejdź 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.
// 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()
}
})
}// 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.
// 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}`)
},
)
}// 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.
// 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 })
},
)
}// 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.
// 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
},
)
}// 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:
// 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()
}// 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.
curl -f http://localhost:8080 >/dev/null && echo "HyperDX działa"
curl -f http://localhost:8123/ping && echo "ClickHouse działa"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-appspan name:
contact.submitspan name:
blog.increment_pageviewatrybut:
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.
const attributes = {
'app.feature': 'checkout',
'app.locale': 'pl',
'http.route': '/api/contact',
'db.system': 'postgres',
'external.service': 'resend',
'cache.hit': false,
}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.
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>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.