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 } 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 } },
})