Implement chat analytics logging to track usage and performance metrics.
- 채팅 서비스의 관측성을 확보하기 위해 `chatAnalyticsService`를 도입하여 주요 이벤트(요청 결과, 토큰 사용량, 지연 시간 등)를 구조화된 로그로 기록합니다. - 개인정보 보호를 위해 메시지 본문은 제외하고 길잇값과 메타데이터 위주로 수집하며, 비동기 로그 싱크를 통해 서비스 응답 지연을 방지했습니다. - `sendMessage`의 각 단계(차단, 에러, 정상 응답 등)에 로깅을 통합하고, 테스트 코드를 통해 스키마 준수 및 필드 파생 로직의 정확성을 검증했습니다.
This commit is contained in:
parent
6595523afb
commit
81aa1f2fb6
110
src/services/chatAnalyticsService.ts
Normal file
110
src/services/chatAnalyticsService.ts
Normal 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 {
|
||||
// 분석 로깅 실패가 사용자 응답을 막지 않는다.
|
||||
}
|
||||
}
|
||||
@ -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<string>();
|
||||
|
||||
/** `POST /chat/messages`(§3.1). */
|
||||
export async function sendMessage(uid: string, body: SendBody): Promise<ChatSendResult> {
|
||||
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<ChatSend
|
||||
}
|
||||
const globalUsage = await getGlobalUsage(date);
|
||||
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");
|
||||
}
|
||||
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);
|
||||
if (inputCheck.blocked) {
|
||||
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", {
|
||||
notice: INPUT_BLOCKED_NOTICE,
|
||||
});
|
||||
@ -226,6 +236,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
|
||||
// 멱등 재반환 — 항상 pin된 threadId 기준(처리 도중 응원팀 변경에도 원래 스레드)
|
||||
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);
|
||||
}
|
||||
|
||||
@ -256,6 +270,12 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
await refundWithRetry(uid, clientMessageId);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -278,6 +298,10 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
if (moderation.blocked) {
|
||||
await refundWithRetry(uid, clientMessageId);
|
||||
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", {
|
||||
notice: INPUT_BLOCKED_NOTICE,
|
||||
});
|
||||
@ -296,6 +320,11 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) throw 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");
|
||||
}
|
||||
// 일일 토큰 사용량 합산(§8.3) — best-effort, 실패가 응답을 막지 않는다
|
||||
@ -340,6 +369,20 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
finalReply = reply;
|
||||
finalCrisis = crisisOut;
|
||||
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) {
|
||||
// 저장(처리 10) 이전 실패 — 차감분 복원(§3.1 복원 규칙). 422 모더레이션 경로는 이미
|
||||
// 복원됐고, 저장 완료(done) 후에는 refundTx가 복원을 거부한다(§6.2 차감 유지).
|
||||
|
||||
91
tests/services/chatAnalyticsService.test.ts
Normal file
91
tests/services/chatAnalyticsService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user