관측성 스택 — Prometheus · Loki · Grafana 코드레벨 동작 흐름

마지막 업데이트 2026-07-22

관측성 / Observability prom-client /metrics instance_id 라벨 15초 gauge 수집 Loki JSON Lines route 정규화 백로그 P0 #7

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 정규화(카디널리티 폭발 방지)다.

두 서버의 메트릭 노출

1
socket — GET /metrics
apps/socket/src/server.ts:115 · metrics.ts
register.setDefaultLabels({ instance_id }) + collectDefaultMetrics()(Node 런타임). SFU/mediasoup/Redis/reaper 메트릭. register.metrics() + register.contentType 반환.
↓ Prometheus scrape
2
web — GET /metrics
apps/web/metrics.js
HTTP 요청 + 외부 operation(ppi_web_external_operation_duration_seconds). idempotent 등록(getSingleMetric ?? new)으로 hot reload 대비.
3
Grafana / Loki
로그 = JSON Lines
Prometheus 메트릭 → Grafana 대시보드. 서버 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

로깅 — 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.tssocket 메트릭 정의 + collectGauges(15초) + timeRedisOp + register
apps/socket/src/server.ts:115/metrics 엔드포인트, METRICS_EXCLUDED_PATHS
apps/web/metrics.jsweb 메트릭 + 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 예외도 의심.

관련 문서

Redis 인프라 (heartbeat·leader·reaper) — reaper/redirect/mismatch 메트릭의 동작 맥락 SFU 서버 (Socket.io + mediasoup) — mediasoup gauge가 측정하는 대상 재현 경로 파악 흐름 시각화 — "Grafana ppi-socket" 메트릭을 교차검증에 쓰는 흐름 운영·관리·인프라 문서화 백로그 — 이 문서는 P0 #7 항목 (P0 전체 완료)