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,
|
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 차감 유지).
|
||||||
|
|||||||
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