flunti-otel대시보드 →

앱 계측 가이드

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.ts

2. 환경변수

# 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 이 빈 값(계측이 무음 처리됨) · 수집 거부. 거부 사유는 수집 진단 에서 분류별로 볼 수 있습니다.