diff --git a/src/services/chatAnalyticsService.ts b/src/services/chatAnalyticsService.ts new file mode 100644 index 0000000..957ef4d --- /dev/null +++ b/src/services/chatAnalyticsService.ts @@ -0,0 +1,110 @@ +import { logger } from "firebase-functions"; +import type { CrisisType } from "./chatFilterService"; +import type { KnowledgeLevel, TeamCode } from "../types/panit"; + +/** + * 채팅 관측성(#3) — BigQuery 집계용 이벤트 emit. + * + * 전송 경로: 구조화 로그(여기) → Cloud Logging 싱크 → BigQuery 데이터셋. 함수 요청 경로에 + * 네트워크 호출을 더하지 않도록 "로그만" 남기고(지연 0·새 의존성 0), BQ 적재는 로그 싱크가 + * 비동기로 처리한다. 싱크 설정·집계 쿼리는 docs/chat-analytics-bigquery.md 참고. + * + * 프라이버시: 메시지·응답 "본문"은 어떤 필드에도 담지 않는다 — 길이(msgLen)·메타만 남긴다. + */ + +/** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */ +export const CHAT_EVENT_MARKER = "chat_exchange"; +/** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */ +export const CHAT_EVENT_SCHEMA_VERSION = 1; + +export type ChatOutcome = + | "ok" // 모델 응답 정상 + | "filtered" // 출력 필터 교체 + | "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출) + | "crisis_output" // 출력 위기 마커/문구 → 고정 응답 + | "input_blocked" // 입력 필터/모더레이션 차단(422) + | "error" // provider 등 처리 실패 + | "replay" // 멱등 재반환 + | "rejected"; // 전역 한도 등 사전 거부 + +export interface ChatEventInput { + outcome: ChatOutcome; + uid: string; + teamCode?: TeamCode | null; + threadId?: string | null; + stylePack?: string; + knowledgeLevel?: KnowledgeLevel | null; + promptVersion?: string; + provider?: string; + model?: string; + finishReason?: string; + crisis?: boolean; + crisisType?: CrisisType; + filtered?: boolean; + /** checkOutput 사유(canary/prompt-leak/hate/...) — 본문 아님. */ + filterReason?: string; + /** 입력 차단 카테고리(hate/sexual/minor/insult) 또는 "moderation". */ + blockedCategory?: string; + /** 모델이 실제 호출한 도구 이름들(표시용 첨부 여부와 무관). */ + toolNames?: string[]; + tokensIn?: number; + tokensOut?: number; + tokensCached?: number; + latencyMs?: number; + /** 사용자 메시지 "길이"만(본문 X). */ + msgLen?: number; + /** HttpError code/에러 name(본문 X). */ + errorCode?: string; +} + +/** + * 평탄한 이벤트 객체 생성(순수 함수, 테스트 대상). undefined/null 필드는 빼서 BQ 스키마를 + * 깔끔히 유지하고, 메시지·응답 본문은 절대 포함하지 않는다. + */ +export function buildChatEvent(input: ChatEventInput): Record { + const ev: Record = { + event: CHAT_EVENT_MARKER, + schema: CHAT_EVENT_SCHEMA_VERSION, + outcome: input.outcome, + uid: input.uid, + }; + const put = (k: string, v: unknown): void => { + if (v !== undefined && v !== null) ev[k] = v; + }; + put("teamCode", input.teamCode); + put("threadId", input.threadId); + put("stylePack", input.stylePack); + put("knowledgeLevel", input.knowledgeLevel); + put("promptVersion", input.promptVersion); + put("provider", input.provider); + put("model", input.model); + put("finishReason", input.finishReason); + put("crisis", input.crisis); + put("crisisType", input.crisisType); + put("filtered", input.filtered); + put("filterReason", input.filterReason); + put("blockedCategory", input.blockedCategory); + if (input.toolNames) { + ev.toolNames = input.toolNames; + ev.toolCount = input.toolNames.length; + } + put("tokensIn", input.tokensIn); + put("tokensOut", input.tokensOut); + put("tokensCached", input.tokensCached); + if (typeof input.tokensIn === "number" && typeof input.tokensOut === "number") { + ev.tokensTotal = input.tokensIn + input.tokensOut; + } + put("latencyMs", input.latencyMs); + put("msgLen", input.msgLen); + put("errorCode", input.errorCode); + return ev; +} + +/** 이벤트를 구조화 로그로 emit. best-effort — 절대 throw하지 않는다(응답 경로 보호). */ +export function logChatEvent(input: ChatEventInput): void { + try { + logger.write({ severity: "INFO", message: CHAT_EVENT_MARKER, ...buildChatEvent(input) }); + } catch { + // 분석 로깅 실패가 사용자 응답을 막지 않는다. + } +} diff --git a/src/services/chatService.ts b/src/services/chatService.ts index c57cd9e..8d8912b 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -28,6 +28,7 @@ import { type ChatProviderMessage, } from "./chatProviderService"; import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; +import { logChatEvent } from "./chatAnalyticsService"; import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; import { buildChatTools } from "./chatToolService"; import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; @@ -163,6 +164,7 @@ const usageWarnedDates = new Set(); /** `POST /chat/messages`(§3.1). */ export async function sendMessage(uid: string, body: SendBody): Promise { + const startedAt = Date.now(); // 관측성(#3) 지연 측정 기준 // 2) 입력 검증 const config = await getChatConfig(); const { message, clientMessageId } = validateSendBody(body, config); @@ -174,6 +176,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise= config.globalDailyCallLimit) { + logChatEvent({ + outcome: "rejected", uid, errorCode: "GLOBAL_LIMIT", + msgLen: message.length, latencyMs: Date.now() - startedAt, + }); throw new HttpError(503, "daily global call limit reached", "AI_UNAVAILABLE"); } if (globalUsage >= config.globalDailyCallLimit * 0.8 && !usageWarnedDates.has(date)) { @@ -195,6 +201,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise t.name), + tokensIn: result.usage.inputTokens, + tokensOut: result.usage.outputTokens, + tokensCached: result.usage.cachedTokens, + latencyMs: Date.now() - startedAt, msgLen: message.length, + }); } catch (err) { // 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미 // 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지). diff --git a/tests/services/chatAnalyticsService.test.ts b/tests/services/chatAnalyticsService.test.ts new file mode 100644 index 0000000..53489c5 --- /dev/null +++ b/tests/services/chatAnalyticsService.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + buildChatEvent, + logChatEvent, + CHAT_EVENT_MARKER, + CHAT_EVENT_SCHEMA_VERSION, +} from "../../src/services/chatAnalyticsService"; +import { KnowledgeLevel, TeamCode } from "../../src/types/panit"; + +describe("chatAnalyticsService.buildChatEvent", () => { + it("필수 메타(마커·스키마·outcome·uid)를 항상 포함한다", () => { + const ev = buildChatEvent({ outcome: "ok", uid: "u1" }); + expect(ev.event).toBe(CHAT_EVENT_MARKER); + expect(ev.schema).toBe(CHAT_EVENT_SCHEMA_VERSION); + expect(ev.outcome).toBe("ok"); + expect(ev.uid).toBe("u1"); + }); + + it("메시지·응답 본문은 어떤 필드에도 담지 않는다(길이만)", () => { + const ev = buildChatEvent({ + outcome: "ok", + uid: "u1", + msgLen: 42, + }); + expect(ev.msgLen).toBe(42); + // 본문류 키가 절대 없어야 한다. + for (const k of ["message", "reply", "content", "text", "userContent", "assistantContent", "q"]) { + expect(ev[k]).toBeUndefined(); + } + }); + + it("undefined/null 필드는 제거해 BQ 스키마를 깔끔히 유지한다", () => { + const ev = buildChatEvent({ + outcome: "crisis_input", + uid: "u1", + teamCode: null, + model: undefined, + knowledgeLevel: null, + }); + expect("teamCode" in ev).toBe(false); + expect("model" in ev).toBe(false); + expect("knowledgeLevel" in ev).toBe(false); + }); + + it("toolNames에서 toolCount, 토큰에서 tokensTotal을 파생한다", () => { + const ev = buildChatEvent({ + outcome: "ok", + uid: "u1", + toolNames: ["get_lineup", "get_team_rank_snapshot"], + tokensIn: 1000, + tokensOut: 120, + tokensCached: 800, + }); + expect(ev.toolNames).toEqual(["get_lineup", "get_team_rank_snapshot"]); + expect(ev.toolCount).toBe(2); + expect(ev.tokensTotal).toBe(1120); + expect(ev.tokensCached).toBe(800); + }); + + it("빈 toolNames도 toolCount 0으로 명시한다", () => { + const ev = buildChatEvent({ outcome: "ok", uid: "u1", toolNames: [] }); + expect(ev.toolCount).toBe(0); + }); + + it("성공 케이스의 차원 필드를 그대로 싣는다", () => { + const ev = buildChatEvent({ + outcome: "ok", + uid: "u1", + teamCode: TeamCode.OB, + threadId: "OB", + stylePack: "commentator", + knowledgeLevel: KnowledgeLevel.Casual, + promptVersion: "2026-06-12.1", + provider: "vertex", + model: "gemini-3.1-flash-lite", + finishReason: "stop", + latencyMs: 3400, + }); + expect(ev.teamCode).toBe(TeamCode.OB); + expect(ev.stylePack).toBe("commentator"); + expect(ev.knowledgeLevel).toBe(KnowledgeLevel.Casual); + expect(ev.finishReason).toBe("stop"); + expect(ev.latencyMs).toBe(3400); + }); +}); + +describe("chatAnalyticsService.logChatEvent", () => { + it("best-effort — 어떤 입력에도 throw하지 않는다", () => { + expect(() => logChatEvent({ outcome: "error", uid: "u1", errorCode: "AI_UNAVAILABLE" })).not.toThrow(); + }); +});