앱 계측 가이드
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, startTrace } from '@/shared/otel'
export const proxy = async (req) => {
const startMs = Date.now()
const trace = startTrace(req) // 요청 단위 컨텍스트 — 들어온 traceparent 가 유효하면 이어받는다
// ...기존 로직 그대로... (otelFetch 에 trace 를 넘기면 같은 트레이스의 자식 span 이 된다)
reportProxyTraffic(req, startMs, response, undefined, trace)
return response
}
// trace 를 넘기지 않으면 종전대로 호출마다 독립 trace 다(하위호환)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 }, trace)
// 4번째 인자(trace)는 startTrace(req) 의 반환값 — 넘기면 proxy span 의 자식 CLIENT span 이 되고
// 나가는 요청에 traceparent 헤더가 주입된다(수집 엔드포인트 제외·기존 헤더는 안 덮음). 생략하면 종전과 동일.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 } },
})9. 배선 확인 (앱에서 보내보기)
대시보드 /system 의 자체 점검은 이 서버가 자기 자신에게 보내는 것이라, 앱의 토큰·엔드포인트·네트워크가 맞는지는 확인해 주지 못합니다. 아래 라우트를 앱에 한 번 붙여 호출하면 앱에서 나가는 경로 전체가 검증됩니다. 확인이 끝나면 지워도 됩니다.
// src/app/api/otel-selftest/route.ts — 앱에서 한 번 호출해 배선을 확인한다
import { NextResponse } from 'next/server'
import { newSpanId, newTraceId, reportProxySpan, shipLogs } from '@/shared/otel'
const MARKER_PREFIX = 'flunti-otel.selftest'
export const GET = async () => {
const marker = `${MARKER_PREFIX}.${newTraceId().slice(0, 8)}`
const traceId = newTraceId()
const startMs = Date.now()
reportProxySpan({
name: marker,
kind: 2,
startMs,
endMs: Date.now(),
status: 'ok',
traceId,
spanId: newSpanId(),
attributes: { 'http.request.method': 'GET', 'http.route': '/__selftest', 'http.response.status_code': 200 },
})
shipLogs([{ message: marker, severityText: 'INFO', traceId, attributes: { 'log.source': 'selftest', 'selftest.marker': marker } }])
return NextResponse.json({ marker, traceId })
}# 앱에서 한 번 호출하고, 돌아온 값으로 대시보드에서 찾는다
curl -s https://내-앱-도메인/api/otel-selftest
# → {"marker":"flunti-otel.selftest.1a2b3c4d","traceId":"289c1813...715b"}
# 1) 트레이스 — span 과 log 가 한 화면에 같이 뜬다 (가장 확실)
# /trace/<traceId>
# 2) 로그만
# /logs?source=all&q=attr.selftest.marker:<marker>
# 3) /services 에 OTEL_SERVICE_NAME 이 뜨면 resource 배선까지 정상이다
# ※ /errors 에는 안 뜬다 — 성공(200) 스팬이라 에러 목록의 대상이 아니다.아무것도 안 보이면 순서대로 의심합니다 — 토큰(401) · 엔드포인트 오타 · OTEL_INGEST_TOKEN 이 빈 값(계측이 무음 처리됨) · 수집 거부. 거부 사유는 수집 진단 에서 분류별로 볼 수 있습니다.