관측성 스택 — Prometheus · Loki · Grafana 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
web(3000)·socket(3001) 두 서버가 각각 prom-client로 /metrics를 노출하고, Prometheus가 scrape → Grafana 대시보드로 본다. 버그 분석마다 인용되는 "Grafana ppi-socket"이 바로 이 socket 메트릭이다. 로그는 서버에서 Loki 호환 JSON Lines로, 브라우저에서 human-readable로 출력된다.
socket은 SFU/mediasoup/Redis/reaper까지 풍부한 메트릭을 15초 주기 gauge 수집 + 이벤트 시점 Counter/Histogram으로 기록한다. 핵심 운영 포인트는 다중 인스턴스 구분(instance_id 라벨)과 route 정규화(카디널리티 폭발 방지)다.
두 서버의 메트릭 노출
GET /metricsregister.setDefaultLabels({ instance_id }) + collectDefaultMetrics()(Node 런타임). SFU/mediasoup/Redis/reaper 메트릭. register.metrics() + register.contentType 반환.GET /metricsppi_web_external_operation_duration_seconds). idempotent 등록(getSingleMetric ?? new)으로 hot reload 대비.console.log JSON Lines → Loki 수집.socket 메트릭 카탈로그
apps/socket/src/metrics.ts. 도메인별:
HTTP
http_requests_total{method,route,status}http_request_duration_seconds(buckets)
Socket.io
ppi_socket_connected_clients{namespace}ppi_socket_rooms_active..._connections_total/..._disconnections_total{reason}
Mediasoup (SFU)
ppi_mediasoup_{workers,routers,transports,producers,consumers}ppi_socket_total_peers..._worker_cpu_user{worker_pid}..._transport_create_total{direction,status,code}ppi_socket_ffmpeg_processes(녹음)
Redis · 다중 인스턴스
..._redis_op_duration_seconds{operation,status}..._redis_adapter_connected(1/0)..._room_meta_mismatch_total{field}..._room_pin_redirect_total/..._attempts..._monitoring_claim_mismatch_total..._guest_state_updates_total{field}
Reaper
..._instance_reaper_runs_total..._instance_reaper_killed_rooms_total
15초 gauge 수집 — collectGauges
이벤트로 즉시 못 올리는 "현재 상태" gauge는 주기적으로 폴링한다. metrics.ts:202-244:
async function collectGauges() {
const rooms = routerManager.getAllRooms();
roomsActive.set(rooms.length);
for (const room of rooms) { // 룸별 합산
peerCount += room.peerCount;
transportCount += transportManager.getTransportsByRoom(room.roomId).length;
producerCount += ...; consumerCount += ...;
}
totalPeers.set(peerCount); mediasoupTransports.set(transportCount); ...
const workerStats = await workerManager.getWorkerStats();
workerStats.forEach(s => mediasoupWorkerCpu.labels(String(s.pid)).set(s.usage.ru_utime / 1e6));
ffmpegProcesses.set(recordingManager.getActiveFfmpegCount());
}
startMetricsCollection(): collectGauges() 즉시 1회 + setInterval(collectGauges, 15000);
web 메트릭 — metrics.js
http_requests_total{method,route,status} ·http_request_duration_secondsppi_web_external_operation_duration_seconds{operation,provider,status} — 외부 provider/장시간 내부 작업 측정. TTS handler가observeExternalOperationDuration("tts_request", provider, status, startedAt)로 기록.normalizeRoute/normalizeApiRoute—/api/lessons/[userId]/[index]같은 동적 경로를 정규화해 라벨 카디널리티를 묶는다.shouldExclude—/metrics·/health등 self-monitoring 경로 제외.- 등록이 idempotent:
getSingleMetric(name) ?? new Counter/Histogram(...)— Next.js hot reload/중복 import 시 "already registered" 에러 방지.
로깅 — logger.js (Loki 호환)
apps/web/logger.js. typeof window === "undefined"로 서버/브라우저 분기:
// 서버: Loki 호환 JSON Lines
const entry = { timestamp: new Date().toISOString(), level, prefix: context, msg };
if (serialized) entry.data = serialized;
console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](JSON.stringify(entry));
// 브라우저: [timestamp] [LEVEL] [context] message (human-readable)
createLogger(context)로 prefix를 붙인다(예: TTS_API·LESSON_SESSION_SERVICE). stdout JSON Lines가 그대로 Loki로 수집된다.
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| apps/socket/src/metrics.ts | socket 메트릭 정의 + collectGauges(15초) + timeRedisOp + register |
| apps/socket/src/server.ts:115 | /metrics 엔드포인트, METRICS_EXCLUDED_PATHS |
| apps/web/metrics.js | web 메트릭 + normalizeRoute + observeExternalOperationDuration + idempotent 등록 |
| apps/web/logger.js | 서버 JSON Lines(Loki) / 브라우저 human-readable, createLogger |
읽을 때 주의할 함정
1. instance_id 라벨이 다중 인스턴스의 전제다. register.setDefaultLabels({ instance_id })로 모든 메트릭에 인스턴스 식별자가 붙는다(metrics.ts:11). 이게 없으면 여러 socket 인스턴스의 gauge가 섞여 합산/구분이 불가능하다. Grafana 쿼리도 이 라벨 기준.
2. gauge는 15초 polling — 실시간이 아니다. mediasoup peers/transports 등 "현재 상태"는 collectGauges가 15초마다 찍는다(metrics.ts:243). 15초보다 짧은 스파이크는 놓칠 수 있다. 반대로 Counter/Histogram(요청·redirect·reaper)은 이벤트 시점에 즉시 기록되므로 누락 없음.
3. route 정규화 없으면 카디널리티 폭발. /api/lessons/{userId}/{index}를 raw로 라벨에 넣으면 사용자×회차만큼 시계열이 생겨 Prometheus 메모리가 터진다. normalizeApiRoute가 동적 세그먼트를 묶는다(metrics.js:205). 새 동적 라우트를 추가할 때 정규화 대상에 포함됐는지 확인.
4. web만 idempotent 등록 패턴을 쓴다. getSingleMetric(name) ?? new ...는 Next.js dev hot reload에서 모듈이 재평가돼도 중복 등록 에러가 안 나게 한다(metrics.js:10). socket은 단순 new(프로세스 1회 로드)라 이 가드가 없다 — socket 코드를 web식으로 베끼면 안 된다.
5. mismatch/redirect 메트릭은 Redis 다중 인스턴스 진단용. room_meta_mismatch_total·monitoring_claim_mismatch_total는 메모리 vs Redis 불일치를 센다(*_SOURCE=both 토글에서 의미). room_pin_redirect_*는 JOIN_ROOM이 다른 인스턴스로 튄 횟수다 — 입장 실패·소유권 디버깅의 핵심 신호.
6. collectGauges는 실패를 삼킨다. try/catch{}로 수집 실패를 무시한다(서버 종료 중 등, metrics.ts:233). 덕분에 메트릭이 서버를 죽이진 않지만, 수집이 조용히 멈추면 gauge가 stale해질 수 있으니 "값이 안 변한다"는 collect 예외도 의심.