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 = 2; export type ChatOutcome = | "ok" // 모델 응답 정상 | "filtered" // 출력 필터 교체(로컬 필터 매치·벤더 안전필터) | "empty" // 모델 빈 응답·비정상 출력 → 대체 문구(v2에서 "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[]; /** 응답에 첨부된 화면 이동 라우트들(예: prediction, schedule). */ navRoutes?: 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; } if (input.navRoutes) { ev.navRoutes = input.navRoutes; ev.navCount = input.navRoutes.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 { // 분석 로깅 실패가 사용자 응답을 막지 않는다. } }