Implement chat analytics logging to track usage and performance metrics.

- 채팅 서비스의 관측성을 확보하기 위해 `chatAnalyticsService`를 도입하여 주요 이벤트(요청 결과, 토큰 사용량, 지연 시간 등)를 구조화된 로그로 기록합니다.
- 개인정보 보호를 위해 메시지 본문은 제외하고 길잇값과 메타데이터 위주로 수집하며, 비동기 로그 싱크를 통해 서비스 응답 지연을 방지했습니다.
- `sendMessage`의 각 단계(차단, 에러, 정상 응답 등)에 로깅을 통합하고, 테스트 코드를 통해 스키마 준수 및 필드 파생 로직의 정확성을 검증했습니다.
This commit is contained in:
윤정민 2026-06-24 10:02:51 +09:00
parent 6595523afb
commit 81aa1f2fb6
3 changed files with 244 additions and 0 deletions

View File

@ -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<string, unknown> {
const ev: Record<string, unknown> = {
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 {
// 분석 로깅 실패가 사용자 응답을 막지 않는다.
}
}

View File

@ -28,6 +28,7 @@ import {
type ChatProviderMessage, type ChatProviderMessage,
} from "./chatProviderService"; } from "./chatProviderService";
import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService";
import { logChatEvent } from "./chatAnalyticsService";
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
import { buildChatTools } from "./chatToolService"; import { buildChatTools } from "./chatToolService";
import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts";
@ -163,6 +164,7 @@ const usageWarnedDates = new Set<string>();
/** `POST /chat/messages`(§3.1). */ /** `POST /chat/messages`(§3.1). */
export async function sendMessage(uid: string, body: SendBody): Promise<ChatSendResult> { export async function sendMessage(uid: string, body: SendBody): Promise<ChatSendResult> {
const startedAt = Date.now(); // 관측성(#3) 지연 측정 기준
// 2) 입력 검증 // 2) 입력 검증
const config = await getChatConfig(); const config = await getChatConfig();
const { message, clientMessageId } = validateSendBody(body, config); const { message, clientMessageId } = validateSendBody(body, config);
@ -174,6 +176,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
} }
const globalUsage = await getGlobalUsage(date); const globalUsage = await getGlobalUsage(date);
if (globalUsage >= config.globalDailyCallLimit) { if (globalUsage >= 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"); throw new HttpError(503, "daily global call limit reached", "AI_UNAVAILABLE");
} }
if (globalUsage >= config.globalDailyCallLimit * 0.8 && !usageWarnedDates.has(date)) { if (globalUsage >= config.globalDailyCallLimit * 0.8 && !usageWarnedDates.has(date)) {
@ -195,6 +201,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
const inputCheck = checkInput(message, config.filters); const inputCheck = checkInput(message, config.filters);
if (inputCheck.blocked) { if (inputCheck.blocked) {
await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay); await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay);
logChatEvent({
outcome: "input_blocked", uid, blockedCategory: inputCheck.category,
msgLen: message.length, latencyMs: Date.now() - startedAt,
});
throw new HttpError(422, `input blocked (${inputCheck.category})`, "INPUT_BLOCKED", { throw new HttpError(422, `input blocked (${inputCheck.category})`, "INPUT_BLOCKED", {
notice: INPUT_BLOCKED_NOTICE, notice: INPUT_BLOCKED_NOTICE,
}); });
@ -226,6 +236,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
// 멱등 재반환 — 항상 pin된 threadId 기준(처리 도중 응원팀 변경에도 원래 스레드) // 멱등 재반환 — 항상 pin된 threadId 기준(처리 도중 응원팀 변경에도 원래 스레드)
if (outcome.kind === "done") { if (outcome.kind === "done") {
logChatEvent({
outcome: "replay", uid, threadId: outcome.threadId,
msgLen: message.length, latencyMs: Date.now() - startedAt,
});
return replayDone(uid, date, config, outcome.threadId, outcome.assistantMessageId); return replayDone(uid, date, config, outcome.threadId, outcome.assistantMessageId);
} }
@ -256,6 +270,12 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
await refundWithRetry(uid, clientMessageId); await refundWithRetry(uid, clientMessageId);
throw err; throw err;
} }
logChatEvent({
outcome: "crisis_input", uid, teamCode: activeTeamCode, threadId: activeThreadId,
stylePack: config.stylePack, promptVersion: config.promptVersion,
crisis: true, crisisType: crisis.type,
msgLen: message.length, latencyMs: Date.now() - startedAt,
});
return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt); return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt);
} }
@ -278,6 +298,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
if (moderation.blocked) { if (moderation.blocked) {
await refundWithRetry(uid, clientMessageId); await refundWithRetry(uid, clientMessageId);
await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay); await recordBlockedAttemptTx(uid, date, config.dailyLimit, config.blockThresholdPerDay);
logChatEvent({
outcome: "input_blocked", uid, teamCode: activeTeamCode, threadId: activeThreadId,
blockedCategory: "moderation", msgLen: message.length, latencyMs: Date.now() - startedAt,
});
throw new HttpError(422, "input blocked (moderation)", "INPUT_BLOCKED", { throw new HttpError(422, "input blocked (moderation)", "INPUT_BLOCKED", {
notice: INPUT_BLOCKED_NOTICE, notice: INPUT_BLOCKED_NOTICE,
}); });
@ -296,6 +320,11 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
} catch (err) { } catch (err) {
if (err instanceof HttpError) throw err; if (err instanceof HttpError) throw err;
console.error("[chat] provider 호출 실패", err); console.error("[chat] provider 호출 실패", err);
logChatEvent({
outcome: "error", uid, teamCode: activeTeamCode, threadId: activeThreadId,
stylePack: config.stylePack, provider: config.provider.name, model: config.provider.model,
errorCode: "AI_UNAVAILABLE", msgLen: message.length, latencyMs: Date.now() - startedAt,
});
throw new HttpError(503, "AI provider unavailable", "AI_UNAVAILABLE"); throw new HttpError(503, "AI provider unavailable", "AI_UNAVAILABLE");
} }
// 일일 토큰 사용량 합산(§8.3) — best-effort, 실패가 응답을 막지 않는다 // 일일 토큰 사용량 합산(§8.3) — best-effort, 실패가 응답을 막지 않는다
@ -340,6 +369,20 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
finalReply = reply; finalReply = reply;
finalCrisis = crisisOut; finalCrisis = crisisOut;
finalToolCalls = toolCalls; finalToolCalls = toolCalls;
logChatEvent({
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : "ok",
uid, teamCode: activeTeamCode, threadId: activeThreadId,
stylePack: config.stylePack, knowledgeLevel: ctx.knowledgeLevel, promptVersion: config.promptVersion,
provider: config.provider.name, model: config.provider.model,
finishReason: result.finishReason,
crisis: crisisOut, crisisType: crisisOut ? (outputCheck.crisisType ?? "selfHarm") : undefined,
filtered, filterReason: outputCheck.action === "filter" ? outputCheck.reason : undefined,
toolNames: result.toolCalls.map((t) => t.name),
tokensIn: result.usage.inputTokens,
tokensOut: result.usage.outputTokens,
tokensCached: result.usage.cachedTokens,
latencyMs: Date.now() - startedAt, msgLen: message.length,
});
} catch (err) { } catch (err) {
// 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미 // 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미
// 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지). // 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지).

View File

@ -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();
});
});