앱 계측 가이드
otel.ts 한 파일을 프로젝트에 복사하면 in/out 트래픽·4xx/5xx 에러·캐시·proxy 트래픽을 otel-logging.flunti.io 로 전송합니다.
otel.ts 다운로드수집 토큰 (OTEL_INGEST_TOKEN)
수집 엔드포인트는 토큰 인증이 필요합니다. 다운로드한 otel.ts 상단의 const OTEL_INGEST_TOKEN = '' 은 빈 값입니다. 토큰은 서버가 발급하며 로그인한(승인된) 사용자에게만 아래에 표시됩니다. 이 값을 그 상수에 붙여넣으세요. (빈 값이면 계측이 무음 처리되어 앱은 정상 동작)
1. 설치
# 1. otel.ts 를 프로젝트에 복사
curl -o src/shared/otel.ts https://otel-logging.flunti.io/download/otel.ts2. 환경변수
# 2. 환경변수 (배포/로컬)
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-logging.flunti.io
OTEL_SERVICE_NAME=my-project # 대시보드에 표시될 이름3. 노드 계측 (Next.js)
앱 라우트·fetch 스팬을 자동 수집합니다.
// src/instrumentation.ts (Next.js — 앱 라우트/fetch 스팬을 우리 서버로)
import { OTEL_ENDPOINT, OTEL_INGEST_TOKEN, OTEL_SERVICE_NAME } from '@/shared/otel'
export const register = async () => {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerOTel, OTLPHttpJsonTraceExporter } = await import('@vercel/otel')
registerOTel({
serviceName: OTEL_SERVICE_NAME,
traceExporter: new OTLPHttpJsonTraceExporter({
url: `${OTEL_ENDPOINT}/v1/traces`,
headers: { 'x-ingest-token': OTEL_INGEST_TOKEN },
}),
})
}
}
// next.config: serverExternalPackages: ['import-in-the-middle', 'require-in-the-middle']
// ※ traceExporter(JSON)를 명시해야 우리 서버로 전송됨. 생략하면 @vercel/otel 이 아무 데도 안 보냄(= fetch 계측 안 뜸).4. proxy(미들웨어) 트래픽
// proxy(미들웨어) — 응답 return 직전 1줄 (fire-and-forget)
import { reportProxyTraffic } from '@/shared/otel'
export const proxy = async (req) => {
const startMs = Date.now()
// ...기존 로직 그대로...
reportProxyTraffic(req, startMs, response)
return response
}5. 수동 revalidate
// 수동 revalidate 계측
import { reportRevalidate } from '@/shared/otel'
revalidateTag(tag)
reportRevalidate([tag], layout) // event.name=cache.revalidate 로 대시보드 캐시 타임라인에 표시6. 임의 span / 로그
// 임의 span / 로그 직접 전송
import { reportProxySpan, shipLogs } from '@/shared/otel'
reportProxySpan({ name: 'job import', startMs, endMs: Date.now(), status: 'ok' })
shipLogs([{ message: 'worker done', severityText: 'INFO' }])7. proxy/edge에서 나가는 fetch 계측
미들웨어(edge)의 백엔드 호출은 @vercel/otel이 못 잡습니다. `otelFetch(name, url, init)`로 감싸면 각 호출의 상태·소요·예외가 우리 서버로 전송됩니다.
// proxy/미들웨어(edge)에서 나가는 백엔드 fetch 계측
// @vercel/otel 은 edge 미지원 → 여기선 이 래퍼로 잡는다.
import { otelFetch } from '@/shared/otel'
// 기존 fetch(url, init) 를 otelFetch(name, url, init) 로 교체
const res = await otelFetch('users-me', `${API}/v1/users/me`, { headers })
// 각 호출이 span(상태·URL·소요시간)으로, 예외는 에러 로그로 전송됨(throw 는 그대로 재던짐)8. 클라이언트(TanStack Query) 계측
브라우저에서 직접 보내면 토큰이 노출됩니다. 클라 에러는 앱의 얇은 API 라우트로 보내고 서버가 otel로 forward 합니다(토큰은 서버에만). QueryCache/MutationCache 전역 onError로 한 번에.
// src/app/api/otel/route.ts — 서버가 토큰 쥐고 forward (토큰 미노출)
import { NextRequest, NextResponse } from 'next/server'
import { shipLogs } from '@/shared/otel'
type ClientTelemetryEvent = { message: string; severityText?: string; severityNumber?: number; attributes?: Record<string, string | number | boolean> }
const MAX_EVENTS = 20
export const POST = async (req: NextRequest) => {
const events = (await req.json()) as ClientTelemetryEvent[]
if (!Array.isArray(events)) return NextResponse.json({ ok: false }, { status: 400 })
await shipLogs(events.slice(0, MAX_EVENTS).map((event) => ({ ...event, attributes: { 'log.source': 'client', ...event.attributes } })))
return NextResponse.json({ ok: true })
}// queryClient 전역 계측 — 4xx/5xx 에러만 위 라우트로 전송 (200/네트워크에러 skip → 로그 절약)
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query'
const HTTP_ERROR_STATUS_MIN = 400
const OTEL_SEVERITY_ERROR = 17
const reportClient = (error: unknown, source: string, attributes?: Record<string, string | number | boolean>) => {
if (typeof window === 'undefined') return
const status = (error as { response?: { status?: number } })?.response?.status
if (typeof status !== 'number' || status < HTTP_ERROR_STATUS_MIN) return
void fetch('/api/otel', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([{
message: `${source} ${status}: ${(error as Error)?.message ?? ''}`,
severityText: 'ERROR',
severityNumber: OTEL_SEVERITY_ERROR,
attributes: { 'http.response.status_code': status, 'client.path': window.location.pathname, ...attributes },
}]),
keepalive: true,
}).catch(() => undefined)
}
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => reportClient(error, 'query error', { 'query.key': JSON.stringify(query.queryKey) }),
}),
mutationCache: new MutationCache({
onError: (error, _v, _c, mutation) => reportClient(error, 'mutation error', { 'mutation.key': JSON.stringify(mutation.options.mutationKey ?? []) }),
}),
defaultOptions: { queries: { staleTime: 60_000 } },
})