---
name: llm-monitoring-dashboard
description: PM용 관리자 대시보드에 LLM 사용 모니터링 페이지를 자동 생성. Tokuin CLI 기반 토큰/비용/레이턴시 추적 + 사용자 랭킹 시스템 + 비사용자 추적 + 데이터 기반 PM 인사이트 자동 생성 + Cmd+K 글로벌 검색 + 사용자별 드릴다운 링크 탐색 포함. OpenAI/Anthropic/Gemini/OpenRouter 지원.
tags: [LLM, monitoring, dashboard, tokuin, pm-insights, ranking, user-tracking, cost-tracking, Next.js, React, admin]
platforms: [Claude, ChatGPT, Gemini, Codex]
---
# LLM 사용 모니터링 대시보드
> **Tokuin CLI** 기반으로 LLM API 비용·토큰·레이턴시를 추적하고,
> PM에게 데이터 기반 인사이트를 제공하는 관리자 대시보드를 자동 생성합니다.
---
## When to use this skill
- **LLM 비용 가시성 확보**: 팀/개인별 API 사용 비용을 실시간 모니터링하고 싶을 때
- **PM 보고용 대시보드 필요**: 누가 얼마나 어떻게 AI를 쓰는지 주간 리포트가 필요할 때
- **사용자 채택률 관리**: 비사용자를 추적하고 AI 도입률을 높이고 싶을 때
- **모델 최적화 근거 마련**: 데이터 기반으로 모델 전환/비용 절감 의사결정이 필요할 때
- **관리자 대시보드에 모니터링 탭 추가**: 기존 Admin 페이지에 LLM 모니터링 섹션을 붙일 때
---
## Prerequisites
### 1. Tokuin CLI 설치 확인
```bash
# 설치 여부 확인
which tokuin && tokuin --version || echo "미설치 — Step 1 먼저 실행"
```
### 2. 환경 변수 (실제 API 호출 시만 필요)
```bash
# .env 파일에 저장 (절대 코드에 직접 입력 금지)
OPENAI_API_KEY=sk-... # OpenAI
ANTHROPIC_API_KEY=sk-ant-... # Anthropic
OPENROUTER_API_KEY=sk-or-... # OpenRouter (400+ 모델)
# LLM 모니터링 설정
LLM_USER_ID=dev-alice # 사용자 식별자
LLM_USER_ALIAS=Alice # 표시명
COST_THRESHOLD_USD=10.00 # 비용 임계값 (초과 시 알림)
DASHBOARD_PORT=3000 # 대시보드 포트
MAX_COST_USD=5.00 # 단일 실행 최대 비용
SLACK_WEBHOOK_URL=https://... # 알림용 (선택)
```
### 3. 프로젝트 스택 요구사항
```
Option A (권장): Next.js 15+ + React 18 + TypeScript
Option B (경량): Python 3.8+ + HTML/JavaScript (의존성 최소)
```
---
## Instructions
### Step 0: 안전 체크 (항상 가장 먼저 실행)
**⚠️ 스킬 실행 전 반드시 이 스크립트를 실행하세요. FAIL 항목이 있으면 중단됩니다.**
```bash
cat > safety-guard.sh << 'SAFETY_EOF'
#!/usr/bin/env bash
# safety-guard.sh — LLM 모니터링 대시보드 실행 전 안전 게이트
set -euo pipefail
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
ALLOW_LIVE="${1:-}"; PASS=0; WARN=0; FAIL=0
log_pass() { echo -e "${GREEN}✅ PASS${NC} $1"; ((PASS++)); }
log_warn() { echo -e "${YELLOW}⚠️ WARN${NC} $1"; ((WARN++)); }
log_fail() { echo -e "${RED}❌ FAIL${NC} $1"; ((FAIL++)); }
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🛡 LLM Monitoring Dashboard — Safety Guard v1.0"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── 1. Tokuin CLI 설치 확인 ────────────────────────────────
if command -v tokuin &>/dev/null; then
log_pass "Tokuin CLI 설치됨: $(tokuin --version 2>&1 | head -1)"
else
log_fail "Tokuin 미설치 → 아래 명령어로 설치 후 재실행:"
echo " curl -fsSL https://raw.githubusercontent.com/nooscraft/tokuin/main/install.sh | bash"
fi
# ── 2. API 키 하드코딩 감지 ────────────────────────────────
HARDCODED=$(grep -rE "(sk-[a-zA-Z0-9]{20,}|sk-ant-[a-zA-Z0-9]{20,}|sk-or-[a-zA-Z0-9]{20,})" \
. --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \
--include="*.html" --include="*.sh" --include="*.py" --include="*.json" \
--exclude-dir=node_modules --exclude-dir=.git 2>/dev/null \
| grep -v "\.env" | grep -v "example" | wc -l || echo 0)
if [ "$HARDCODED" -eq 0 ]; then
log_pass "API 키 하드코딩 없음"
else
log_fail "⚠️ API 키 하드코딩 ${HARDCODED}건 감지! → 환경변수(.env)로 즉시 이동 필요"
grep -rE "(sk-[a-zA-Z0-9]{20,})" . \
--include="*.ts" --include="*.js" --include="*.html" \
--exclude-dir=node_modules 2>/dev/null | head -5 || true
fi
# ── 3. .env → .gitignore 등록 확인 ────────────────────────
if [ -f .env ]; then
if [ -f .gitignore ] && grep -q "\.env" .gitignore; then
log_pass ".env가 .gitignore에 등록됨"
else
log_fail ".env 존재하지만 .gitignore 미등록! → echo '.env' >> .gitignore"
fi
else
log_warn ".env 파일 없음 — 실제 API 호출 시 생성 필요"
fi
# ── 4. 실제 API 호출 모드 확인 ────────────────────────────
if [ "$ALLOW_LIVE" = "--allow-live" ]; then
log_warn "실제 API 호출 모드 활성화! 비용이 발생합니다."
log_warn "최대 비용 임계값: \$${MAX_COST_USD:-5.00} (MAX_COST_USD 환경변수로 조정)"
read -p " 실제 API 호출을 허용하시겠습니까? [y/N] " -r
echo
[[ $REPLY =~ ^[Yy]$ ]] || { echo "취소됨. dry-run 모드로 재실행하세요."; exit 1; }
else
log_pass "dry-run 모드 (기본값) — API 비용 발생 없음"
fi
# ── 5. 포트 충돌 확인 ─────────────────────────────────────
PORT="${DASHBOARD_PORT:-3000}"
if lsof -i ":${PORT}" &>/dev/null 2>&1; then
ALT_PORT=$((PORT + 1))
log_warn "포트 ${PORT} 사용 중 → 대신 ${ALT_PORT} 사용: export DASHBOARD_PORT=${ALT_PORT}"
else
log_pass "포트 ${PORT} 사용 가능"
fi
# ── 6. data/ 디렉토리 초기화 ──────────────────────────────
mkdir -p ./data
if [ -f ./data/metrics.jsonl ]; then
BYTES=$(wc -c < ./data/metrics.jsonl || echo 0)
if [ "$BYTES" -gt 10485760 ]; then
log_warn "metrics.jsonl이 10MB 초과 (${BYTES}B) → 롤링 정책 적용 권장"
echo " cp data/metrics.jsonl data/metrics-$(date +%Y%m%d).jsonl.bak && > data/metrics.jsonl"
else
log_pass "data/ 준비됨 (metrics.jsonl: ${BYTES}B)"
fi
else
log_pass "data/ 준비됨 (신규)"
fi
# ── 결과 요약 ─────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "결과: ${GREEN}PASS $PASS${NC} / ${YELLOW}WARN $WARN${NC} / ${RED}FAIL $FAIL${NC}"
if [ "$FAIL" -gt 0 ]; then
echo -e "${RED}❌ 안전 체크 실패. 위 FAIL 항목을 해결한 후 재실행하세요.${NC}"
exit 1
else
echo -e "${GREEN}✅ 안전 체크 통과. 스킬 실행을 계속합니다.${NC}"
exit 0
fi
SAFETY_EOF
chmod +x safety-guard.sh
# 실행 (FAIL 있으면 즉시 중단됨)
bash safety-guard.sh
```
---
### Step 1: Tokuin CLI 설치 및 dry-run 검증
```bash
# 1-1. 설치 (macOS / Linux)
curl -fsSL https://raw.githubusercontent.com/nooscraft/tokuin/main/install.sh | bash
# Windows PowerShell:
# irm https://raw.githubusercontent.com/nooscraft/tokuin/main/install.ps1 | iex
# 1-2. 설치 확인
tokuin --version
which tokuin # 기대: /usr/local/bin/tokuin 또는 ~/.local/bin/tokuin
# 1-3. 기본 토큰 카운트 테스트
echo "Hello, world!" | tokuin --model gpt-4
# 1-4. dry-run 비용 추정 (API 키 불필요 ✅)
echo "Analyze user behavior patterns from the following data" | \
tokuin load-test \
--model gpt-4 \
--runs 50 \
--concurrency 5 \
--dry-run \
--estimate-cost \
--output-format json | python3 -m json.tool
# 기대 출력 구조:
# {
# "total_requests": 50,
# "successful": 50,
# "failed": 0,
# "latency_ms": { "average": ..., "p50": ..., "p95": ... },
# "cost": { "input_tokens": ..., "output_tokens": ..., "total_cost": ... }
# }
# 1-5. 다중 모델 비교 (dry-run)
echo "Translate this to Korean" | tokuin --compare gpt-4 gpt-3.5-turbo claude-3-haiku --price
# 1-6. Prometheus 형식 출력 확인
echo "Benchmark" | tokuin load-test --model gpt-4 --runs 10 --dry-run --output-format prometheus
# 기대: "# HELP", "# TYPE", "tokuin_" 접두사 메트릭
```
---
### Step 2: 사용자 컨텍스트 포함 데이터 수집 파이프라인
```bash
# 2-1. 프롬프트 자동 카테고리 분류 모듈 생성
cat > categorize_prompt.py << 'PYEOF'
#!/usr/bin/env python3
"""프롬프트를 키워드 기반으로 자동 분류"""
import hashlib
CATEGORIES = {
"코딩": ["code", "function", "class", "implement", "debug", "fix", "refactor", "코드", "구현", "함수"],
"분석": ["analyze", "compare", "evaluate", "assess", "분석", "비교", "평가", "검토"],
"번역": ["translate", "translation", "번역", "영어로", "한국어로"],
"요약": ["summarize", "summary", "tldr", "brief", "요약", "정리"],
"작성": ["write", "draft", "create", "generate", "작성", "생성", "만들어"],
"질문": ["what is", "how to", "explain", "why", "무엇", "어떻게", "설명", "왜"],
"데이터": ["data", "table", "csv", "json", "sql", "데이터", "테이블", "쿼리"],
}
def categorize(prompt: str) -> str:
p = prompt.lower()
for cat, keywords in CATEGORIES.items():
if any(k in p for k in keywords):
return cat
return "기타"
def hash_prompt(prompt: str) -> str:
"""SHA-256 앞 16자 (원문 대신 저장 — 개인정보 보호)"""
return hashlib.sha256(prompt.encode()).hexdigest()[:16]
def truncate_preview(prompt: str, limit: int = 100) -> str:
return prompt[:limit] + ("…" if len(prompt) > limit else "")
if __name__ == "__main__":
import sys
prompt = sys.argv[1] if len(sys.argv) > 1 else ""
print(categorize(prompt))
PYEOF
# 2-2. 사용자 컨텍스트 포함 메트릭 수집 스크립트 생성
cat > collect-metrics.sh << 'COLLECT_EOF'
#!/usr/bin/env bash
# collect-metrics.sh — Tokuin 실행 + 사용자 컨텍스트 저장 (dry-run 기본값)
set -euo pipefail
# 사용자 정보
USER_ID="${LLM_USER_ID:-$(whoami)}"
USER_ALIAS="${LLM_USER_ALIAS:-$USER_ID}"
SESSION_ID="${LLM_SESSION_ID:-$(date +%Y%m%d-%H%M%S)-$$}"
PROMPT="${1:-Benchmark prompt}"
MODEL="${MODEL:-gpt-4}"
PROVIDER="${PROVIDER:-openai}"
RUNS="${RUNS:-50}"
CONCURRENCY="${CONCURRENCY:-5}"
TAGS="${LLM_TAGS:-[]}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
CATEGORY=$(python3 categorize_prompt.py "$PROMPT" 2>/dev/null || echo "기타")
PROMPT_HASH=$(echo -n "$PROMPT" | sha256sum | cut -c1-16 2>/dev/null || echo "unknown")
PROMPT_LEN=${#PROMPT}
# Tokuin 실행 (dry-run 기본값)
RESULT=$(echo "$PROMPT" | tokuin load-test \
--model "$MODEL" \
--provider "$PROVIDER" \
--runs "$RUNS" \
--concurrency "$CONCURRENCY" \
--output-format json \
${ALLOW_LIVE:+""} ${ALLOW_LIVE:-"--dry-run --estimate-cost"} 2>/dev/null)
# 사용자 컨텍스트 포함하여 JSONL 저장
python3 - << PYEOF
import json, sys
result = json.loads('''${RESULT}''')
latency = result.get("latency_ms", {})
cost = result.get("cost", {})
record = {
"id": "${PROMPT_HASH}-${SESSION_ID}",
"timestamp": "${TIMESTAMP}",
"model": "${MODEL}",
"provider": "${PROVIDER}",
"user_id": "${USER_ID}",
"user_alias": "${USER_ALIAS}",
"session_id": "${SESSION_ID}",
"prompt_hash": "${PROMPT_HASH}",
"prompt_category": "${CATEGORY}",
"prompt_length": ${PROMPT_LEN},
"tags": json.loads('${TAGS}'),
"is_dry_run": True,
"total_requests": result.get("total_requests", 0),
"successful": result.get("successful", 0),
"failed": result.get("failed", 0),
"input_tokens": cost.get("input_tokens", 0),
"output_tokens": cost.get("output_tokens", 0),
"cost_usd": cost.get("total_cost", 0),
"latency_avg_ms": latency.get("average", 0),
"latency_p50_ms": latency.get("p50", 0),
"latency_p95_ms": latency.get("p95", 0),
"status_code": 200 if result.get("successful", 0) > 0 else 500,
}
with open("./data/metrics.jsonl", "a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
print(f"✅ 저장: [{record['user_alias']}] {record['prompt_category']} | ${record['cost_usd']:.4f} | {record['latency_avg_ms']:.0f}ms")
PYEOF
COLLECT_EOF
chmod +x collect-metrics.sh
# 2-3. 크론 설정 (5분마다 자동 수집)
(crontab -l 2>/dev/null; echo "*/5 * * * * cd $(pwd) && bash collect-metrics.sh 'Scheduled benchmark' >> ./data/collect.log 2>&1") | crontab -
echo "✅ 크론 등록 완료 (5분 간격)"
# 2-4. 첫 번째 수집 테스트 (dry-run)
bash collect-metrics.sh "Analyze user behavior patterns"
cat ./data/metrics.jsonl | python3 -m json.tool | head -30
```
---
### Step 3: 라우팅 구조 및 대시보드 프레임 생성
**Option A — Next.js (권장)**
```bash
# 3-1. Next.js 프로젝트 초기화 (기존 프로젝트에 추가 시 이 단계 생략)
npx create-next-app@latest llm-dashboard \
--typescript \
--tailwind \
--app \
--no-src-dir
cd llm-dashboard
# 3-2. 의존성 설치
npm install recharts better-sqlite3 @types/better-sqlite3
# 3-3. 디자인 토큰 설정 (톤앤매너 일관성)
cat > app/globals.css << 'CSS_EOF'
:root {
/* 배경 계층 */
--bg-base: #0f1117;
--bg-surface: #1a1d27;
--bg-elevated: #21253a;
--border: rgba(255, 255, 255, 0.06);
/* 텍스트 계층 */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #475569;
/* 3단계 신호등 시스템 (모든 컴포넌트에서 일관되게 사용) */
--color-ok: #22c55e; /* 정상 — Green 500 */
--color-warn: #f59e0b; /* 경고 — Amber 500 */
--color-danger: #ef4444; /* 위험 — Red 500 */
--color-neutral: #60a5fa; /* 중립 — Blue 400 */
/* 데이터 시리즈 컬러 (색맹 고려 팔레트) */
--series-1: #818cf8; /* Indigo — System/GPT-4 */
--series-2: #38bdf8; /* Sky — User/Claude */
--series-3: #34d399; /* Emerald — Assistant/Gemini*/
--series-4: #fb923c; /* Orange — 4번째 시리즈 */
/* 비용 특화 */
--cost-input: #a78bfa;
--cost-output: #f472b6;
/* 랭킹 컬러 */
--rank-gold: #fbbf24;
--rank-silver: #94a3b8;
--rank-bronze: #b45309;
--rank-inactive: #374151;
/* 타이포그래피 */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-ui: 'Geist', 'Plus Jakarta Sans', system-ui, sans-serif;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-ui);
}
/* 숫자 표시: 정렬 안정성 */
.metric-value {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
/* KPI 카드 accent-bar */
.status-ok { border-left-color: var(--color-ok); }
.status-warn { border-left-color: var(--color-warn); }
.status-danger { border-left-color: var(--color-danger); }
CSS_EOF
# 3-4. 라우팅 구조 생성
mkdir -p app/admin/llm-monitoring
mkdir -p app/admin/llm-monitoring/users
mkdir -p "app/admin/llm-monitoring/users/[userId]"
mkdir -p "app/admin/llm-monitoring/runs/[runId]"
mkdir -p components/llm-monitoring
mkdir -p lib/llm-monitoring
# 3-5. SQLite DB 초기화
cat > lib/llm-monitoring/db.ts << 'TS_EOF'
import Database from 'better-sqlite3'
import path from 'path'
const DB_PATH = path.join(process.cwd(), 'data', 'monitoring.db')
const db = new Database(DB_PATH)
db.exec(`
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
timestamp DATETIME NOT NULL DEFAULT (datetime('now')),
model TEXT NOT NULL,
provider TEXT NOT NULL,
user_id TEXT DEFAULT 'anonymous',
user_alias TEXT DEFAULT 'anonymous',
session_id TEXT,
prompt_hash TEXT,
prompt_category TEXT DEFAULT '기타',
prompt_length INTEGER DEFAULT 0,
tags TEXT DEFAULT '[]',
is_dry_run INTEGER DEFAULT 1,
total_requests INTEGER DEFAULT 0,
successful INTEGER DEFAULT 0,
failed INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
latency_avg_ms REAL DEFAULT 0,
latency_p50_ms REAL DEFAULT 0,
latency_p95_ms REAL DEFAULT 0,
status_code INTEGER DEFAULT 200
);
CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY,
user_alias TEXT NOT NULL,
team TEXT DEFAULT '',
role TEXT DEFAULT 'user',
created_at DATETIME DEFAULT (datetime('now')),
last_seen DATETIME,
notes TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_runs_timestamp ON runs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_runs_user_id ON runs(user_id);
CREATE INDEX IF NOT EXISTS idx_runs_model ON runs(model);
CREATE VIEW IF NOT EXISTS user_stats AS
SELECT
user_id,
user_alias,
COUNT(*) AS total_runs,
SUM(input_tokens + output_tokens) AS total_tokens,
ROUND(SUM(cost_usd), 4) AS total_cost,
ROUND(AVG(latency_avg_ms), 1) AS avg_latency,
ROUND(AVG(CAST(successful AS REAL) / NULLIF(total_requests, 0) * 100), 1) AS success_rate,
COUNT(DISTINCT model) AS models_used,
MAX(timestamp) AS last_seen
FROM runs
GROUP BY user_id;
`)
export default db
TS_EOF
```
**Option B — 경량 HTML (의존성 최소)**
```bash
# 기존 프로젝트가 없거나 빠른 프로토타입 필요 시
mkdir -p llm-monitoring/data
cat > llm-monitoring/index.html << 'HTML_EOF'
🧮 LLM 사용 모니터링
🧮 LLM 사용 모니터링
Powered by Tokuin CLI
총 요청 수
-
데이터 로딩 중...
성공률
-
-
p95 레이턴시
-
-
총 비용
-
-
시간대별 비용 트렌드
카테고리 분포
🏆 사용자 랭킹
비용 기준 순위
순위
사용자
비용
요청수
선호 모델
성공률
마지막 활동
데이터 로딩 중...
💤 비사용자 현황
사용자
미사용 기간
마지막 활동
상태
추적 데이터 없음
📊 PM 자동 인사이트
💡 자동 분석 중...
HTML_EOF
echo "✅ 경량 HTML 대시보드 생성 완료: llm-monitoring/index.html"
# 로컬 서버 실행
cd llm-monitoring && python3 -m http.server "${DASHBOARD_PORT:-3000}" &
echo "✅ 대시보드 실행 중: http://localhost:${DASHBOARD_PORT:-3000}"
```
---
### Step 4: PM 인사이트 탭 및 랭킹 시스템
(Option A / Next.js의 경우)
```bash
# PM 대시보드 API 라우트 생성
cat > app/api/ranking/route.ts << 'TS_EOF'
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/llm-monitoring/db'
export async function GET(req: NextRequest) {
const period = req.nextUrl.searchParams.get('period') || '30d'
const days = period === '7d' ? 7 : period === '90d' ? 90 : 30
// 비용 기준 랭킹
const costRanking = db.prepare(`
SELECT
user_id, user_alias,
ROUND(SUM(cost_usd), 4) AS total_cost,
COUNT(*) AS total_runs,
GROUP_CONCAT(DISTINCT model) AS models_used,
ROUND(AVG(latency_avg_ms), 0) AS avg_latency,
ROUND(
AVG(CAST(successful AS REAL) / NULLIF(total_requests, 0)) * 100, 1
) AS success_rate,
MAX(timestamp) AS last_seen
FROM runs
WHERE timestamp >= datetime('now', '-' || ? || ' days')
GROUP BY user_id
ORDER BY total_cost DESC
LIMIT 20
`).all(days)
// 비사용자 추적 (선택 기간 내 활동 없는 등록 사용자)
const inactiveUsers = db.prepare(`
SELECT
p.user_id, p.user_alias, p.team,
MAX(r.timestamp) AS last_seen,
CAST((julianday('now') - julianday(MAX(r.timestamp))) AS INTEGER) AS days_inactive
FROM user_profiles p
LEFT JOIN runs r ON p.user_id = r.user_id
GROUP BY p.user_id
HAVING last_seen IS NULL
OR days_inactive >= 7
ORDER BY days_inactive DESC
`).all()
// PM 요약
const summary = db.prepare(`
SELECT
COUNT(DISTINCT user_id) AS total_users,
COUNT(DISTINCT CASE WHEN timestamp >= datetime('now', '-7 days') THEN user_id END) AS active_7d,
ROUND(SUM(cost_usd), 2) AS total_cost,
COUNT(*) AS total_runs
FROM runs
WHERE timestamp >= datetime('now', '-' || ? || ' days')
`).get(days) as Record
return NextResponse.json({ costRanking, inactiveUsers, summary })
}
TS_EOF
```
---
### Step 5: 주간 PM 리포트 자동 생성
```bash
cat > generate-pm-report.sh << 'REPORT_EOF'
#!/usr/bin/env bash
# generate-pm-report.sh — 주간 PM 리포트 자동 생성 (Markdown)
set -euo pipefail
REPORT_DATE=$(date +"%Y-%m-%d")
REPORT_WEEK=$(date +"%Y-W%V")
OUTPUT_DIR="./reports"
OUTPUT="${OUTPUT_DIR}/pm-weekly-${REPORT_DATE}.md"
mkdir -p "$OUTPUT_DIR"
python3 << PYEOF > "$OUTPUT"
import json, sys
from datetime import datetime, timedelta
from collections import defaultdict
# 최근 7일 데이터 로드
try:
records = [json.loads(l) for l in open('./data/metrics.jsonl') if l.strip()]
except FileNotFoundError:
records = []
week_ago = (datetime.now() - timedelta(days=7)).isoformat()
week_data = [r for r in records if r.get('timestamp', '') >= week_ago]
# 집계
total_cost = sum(r.get('cost_usd', 0) for r in week_data)
total_runs = len(week_data)
active_users = set(r['user_id'] for r in week_data)
all_users = set(r['user_id'] for r in records)
inactive_users = all_users - active_users
# 사용자별 비용 랭킹
user_costs = defaultdict(lambda: {'cost': 0, 'runs': 0, 'alias': '', 'categories': defaultdict(int)})
for r in week_data:
uid = r.get('user_id', 'unknown')
user_costs[uid]['cost'] += r.get('cost_usd', 0)
user_costs[uid]['runs'] += 1
user_costs[uid]['alias'] = r.get('user_alias', uid)
user_costs[uid]['categories'][r.get('prompt_category', '기타')] += 1
top_users = sorted(user_costs.items(), key=lambda x: x[1]['cost'], reverse=True)[:5]
# 모델별 사용량
model_usage = defaultdict(int)
for r in week_data:
model_usage[r.get('model', 'unknown')] += 1
top_model = max(model_usage, key=model_usage.get) if model_usage else '-'
# 성공률
success_count = sum(1 for r in week_data if r.get('status_code', 200) == 200)
success_rate = (success_count / total_runs * 100) if total_runs else 0
print(f"""# 📊 LLM 사용 주간 리포트 — {REPORT_DATE} ({REPORT_WEEK})
## Executive Summary
| 지표 | 값 |
|------|-----|
| 총 비용 | \${total_cost:.2f} |
| 총 실행 수 | {total_runs:,}회 |
| 활성 사용자 | {len(active_users)}명 |
| 채택률 | {len(active_users)}/{len(all_users)}명 ({len(active_users)/len(all_users)*100:.0f}% if all_users else 'N/A') |
| 성공률 | {success_rate:.1f}% |
| 최다 사용 모델 | {top_model} |
## 🏆 사용자 TOP 5 (비용 기준)
| 순위 | 사용자 | 비용 | 실행 수 | 주요 카테고리 |
|------|--------|------|---------|--------------|
{"".join(f"| {'🥇🥈🥉'[i] if i < 3 else i+1} | {u['alias']} | \${u['cost']:.4f} | {u['runs']} | {max(u['categories'], key=u['categories'].get) if u['categories'] else '-'} |" + chr(10) for i, (uid, u) in enumerate(top_users))}
## 💤 비활성 사용자 ({len(inactive_users)}명)
{"없음 — 모든 사용자 7일 내 활성" if not inactive_users else chr(10).join(f"- {uid}" for uid in inactive_users)}
## 💡 PM 권장 조치
{"- 비활성 사용자 " + str(len(inactive_users)) + "명 대상 온보딩/지원 검토" if inactive_users else ""}
{"- 성공률 " + f"{success_rate:.1f}%" + " — SLA 95% " + ("달성 ✅" if success_rate >= 95 else "미달 ⚠️ 에러 원인 분석 필요") }
{"- 총 비용 \$" + f"{total_cost:.2f}" + " — 전주 대비 모델 최적화 기회 검토"}
---
*자동 생성: generate-pm-report.sh | Tokuin CLI 기반*
""")
PYEOF
echo "✅ PM 리포트 생성: $OUTPUT"
cat "$OUTPUT"
# Slack 알림 (설정된 경우)
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
SUMMARY=$(grep -A5 "## Executive Summary" "$OUTPUT" | tail -5)
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"text\":\"📊 주간 LLM 리포트 ($REPORT_DATE)\n$SUMMARY\"}" > /dev/null
echo "✅ Slack 알림 전송 완료"
fi
REPORT_EOF
chmod +x generate-pm-report.sh
# 매주 월요일 오전 9시 자동 실행
(crontab -l 2>/dev/null; echo "0 9 * * 1 cd $(pwd) && bash generate-pm-report.sh >> ./data/report.log 2>&1") | crontab -
echo "✅ 주간 리포트 크론 등록 (매주 월요일 09:00)"
# 즉시 테스트 실행
bash generate-pm-report.sh
```
---
### Step 6: 비용 알림 설정
```bash
cat > check-alerts.sh << 'ALERT_EOF'
#!/usr/bin/env bash
# check-alerts.sh — 비용 임계값 초과 감지 및 Slack 알림
set -euo pipefail
THRESHOLD="${COST_THRESHOLD_USD:-10.00}"
CURRENT_COST=$(python3 << PYEOF
import json
from datetime import datetime, timedelta
today = datetime.now().date().isoformat()
try:
records = [json.loads(l) for l in open('./data/metrics.jsonl') if l.strip()]
today_cost = sum(r.get('cost_usd', 0) for r in records if r.get('timestamp', '')[:10] == today)
print(f"{today_cost:.4f}")
except:
print("0.0000")
PYEOF
)
python3 - << PYEOF
import sys
cost, threshold = float('$CURRENT_COST'), float('$THRESHOLD')
if cost > threshold:
print(f"ALERT: 오늘 비용 \${cost:.4f}가 임계값 \${threshold:.2f}를 초과했습니다!")
sys.exit(1)
else:
print(f"정상: 오늘 비용 \${cost:.4f} / 임계값 \${threshold:.2f}")
sys.exit(0)
PYEOF
# exit 1 시 Slack 알림
if [ $? -ne 0 ] && [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"text\":\"⚠️ LLM 비용 임계값 초과!\n오늘 비용: \$$CURRENT_COST / 임계값: \$$THRESHOLD\"}" > /dev/null
fi
ALERT_EOF
chmod +x check-alerts.sh
# 1시간마다 비용 체크
(crontab -l 2>/dev/null; echo "0 * * * * cd $(pwd) && bash check-alerts.sh >> ./data/alerts.log 2>&1") | crontab -
echo "✅ 비용 알림 크론 등록 (매시간)"
```
---
## Privacy Policy
```yaml
# 개인정보 보호 정책 (반드시 준수)
prompt_storage:
store_full_prompt: false # 기본값: 원문 저장 안 함
store_preview: false # 앞 100자 저장도 기본 비활성 (관리자 명시 설정 필요)
store_hash: true # SHA-256 해시만 저장 (패턴 분석용)
user_data:
anonymize_by_default: true # user_id는 해시로 저장 가능 (LLM_USER_ID 환경변수로 제어)
retention_days: 90 # 90일 후 오래된 데이터 정리 권장
compliance:
# API 키를 절대 코드/HTML/로그 파일에 기록하지 마세요.
# .env 파일은 반드시 .gitignore에 추가하세요.
# 관리자 외 프롬프트 미리보기 접근을 제한하세요.
```
> ⚠️ **`store_preview: true` 활성화 시 필수 절차**
>
> 프롬프트 미리보기 저장은 **관리자가 명시적으로** 아래 절차를 완료한 경우에만 활성화할 수 있습니다:
>
> 1. `.env` 파일에서 `STORE_PREVIEW=true` 설정 (코드 직접 수정 금지)
> 2. 팀 내 개인정보 처리 동의 확보 (사용자에게 미리보기 저장 사실 고지)
> 3. 접근 권한을 **관리자 역할**로만 제한 (일반 사용자 열람 불가)
> 4. `retention_days` 를 명시적으로 설정하여 보관 기간 지정
>
> 위 절차 없이 `store_preview: true`를 적용하는 것은 **MUST NOT** 위반입니다.
---
## Output Format
스킬 실행 완료 시 생성되는 파일:
```
./
├── safety-guard.sh # 안전 게이트 (Step 0)
├── categorize_prompt.py # 프롬프트 자동 분류
├── collect-metrics.sh # 메트릭 수집 (Step 2)
├── generate-pm-report.sh # PM 주간 리포트 (Step 5)
├── check-alerts.sh # 비용 알림 (Step 6)
│
├── data/
│ ├── metrics.jsonl # 시계열 메트릭 (JSONL 형식)
│ ├── collect.log # 수집 로그
│ ├── alerts.log # 알림 로그
│ └── reports/
│ └── pm-weekly-YYYY-MM-DD.md # 자동 생성 PM 리포트
│
├── [Next.js 선택 시]
│ ├── app/admin/llm-monitoring/page.tsx
│ ├── app/admin/llm-monitoring/users/[userId]/page.tsx
│ ├── app/api/runs/route.ts
│ ├── app/api/ranking/route.ts
│ ├── app/api/metrics/route.ts # Prometheus 엔드포인트
│ ├── components/llm-monitoring/
│ │ ├── KPICard.tsx
│ │ ├── TrendChart.tsx
│ │ ├── ModelCostBar.tsx
│ │ ├── LatencyGauge.tsx
│ │ ├── TokenDonut.tsx
│ │ ├── RankingTable.tsx
│ │ ├── InactiveUsers.tsx
│ │ ├── PMInsights.tsx
│ │ └── UserDetailPage.tsx
│ └── lib/llm-monitoring/db.ts
│
└── [경량 HTML 선택 시]
└── llm-monitoring/
├── index.html # 단일 파일 대시보드 (차트 + 랭킹 + 사용자 상세)
└── data/
└── metrics.jsonl
```
---
## Constraints
### MUST (반드시 지켜야 함)
- **Step 0(`safety-guard.sh`)을 항상 가장 먼저 실행할 것**
- `--dry-run`을 기본값으로 사용하고, 실제 API 호출은 `--allow-live` 플래그를 명시적으로 지정할 것
- API 키를 반드시 환경변수 또는 `.env` 파일로 관리할 것
- `.env`를 `.gitignore`에 추가할 것: `echo '.env' >> .gitignore`
- 상태 표시는 반드시 3단계 컬러 시스템(`--color-ok`, `--color-warn`, `--color-danger`)을 일관되게 사용할 것
- 사용자 링크 클릭 시 해당 사용자 개인 상세 페이지로 이동하는 드릴다운 네비게이션을 구현할 것
- PM 인사이트는 데이터 기반 자동 생성으로 구현할 것 (하드코딩 금지)
### MUST NOT (절대 하지 말 것)
- API 키를 코드, HTML, 스크립트, 로그 파일에 직접 입력하지 말 것
- 실제 API 호출(`--allow-live`)을 자동화 스크립트의 기본값으로 설정하지 말 것
- 임의의 색상 사용 금지 — 반드시 디자인 토큰 CSS 변수만 사용
- 상태 표시를 텍스트만으로 하지 말 것 (항상 색상 + 배지 병행)
- 사용자 프롬프트 원문을 데이터베이스에 저장하지 말 것 (해시만 허용)
---
## Examples
### 예시 1: 빠른 시작 (dry-run, API 키 불필요)
```bash
# 1. 안전 체크
bash safety-guard.sh
# 2. Tokuin 설치
curl -fsSL https://raw.githubusercontent.com/nooscraft/tokuin/main/install.sh | bash
# 3. 샘플 데이터 수집 (dry-run)
export LLM_USER_ID="dev-alice"
export LLM_USER_ALIAS="Alice"
bash collect-metrics.sh "Analyze user behavior patterns"
bash collect-metrics.sh "Write a Python function to parse JSON"
bash collect-metrics.sh "Translate this document to English"
# 4. 경량 대시보드 실행
cd llm-monitoring && python3 -m http.server 3000
open http://localhost:3000
```
### 예시 2: 다중 사용자 시뮬레이션 (팀 테스트)
```bash
# 여러 사용자 dry-run 시뮬레이션
for user in "alice" "backend" "analyst" "pm-charlie"; do
export LLM_USER_ID="$user"
export LLM_USER_ALIAS="$user"
for category in "코딩" "분석" "번역"; do
bash collect-metrics.sh "${category} 관련 프롬프트 예시"
done
done
# 결과 확인
wc -l data/metrics.jsonl
```
### 예시 3: PM 주간 리포트 즉시 생성
```bash
bash generate-pm-report.sh
cat reports/pm-weekly-$(date +%Y-%m-%d).md
```
### 예시 4: 비용 알림 테스트
```bash
export COST_THRESHOLD_USD=0.01 # 낮은 임계값으로 테스트
bash check-alerts.sh
# 기대: ALERT 메시지 출력 (임계값보다 낮으면 "정상")
```
---
## References
- **Tokuin GitHub**: https://github.com/nooscraft/tokuin
- **Tokuin 설치 스크립트**: https://raw.githubusercontent.com/nooscraft/tokuin/main/install.sh
- **모델 추가 가이드**: https://github.com/nooscraft/tokuin/blob/main/ADDING_MODELS_GUIDE.md
- **프로바이더 로드맵**: https://github.com/nooscraft/tokuin/blob/main/PROVIDERS_PLAN.md
- **Contributing 가이드**: https://github.com/nooscraft/tokuin/blob/main/CONTRIBUTING.md
- **OpenRouter 모델 카탈로그**: https://openrouter.ai/models
- **한국어 블로그 가이드**: https://digitalbourgeois.tistory.com/m/2658