mmday-firebase/tests/services/chatAnalyticsService.test.ts
윤정민 81aa1f2fb6 Implement chat analytics logging to track usage and performance metrics.
- 채팅 서비스의 관측성을 확보하기 위해 `chatAnalyticsService`를 도입하여 주요 이벤트(요청 결과, 토큰 사용량, 지연 시간 등)를 구조화된 로그로 기록합니다.
- 개인정보 보호를 위해 메시지 본문은 제외하고 길잇값과 메타데이터 위주로 수집하며, 비동기 로그 싱크를 통해 서비스 응답 지연을 방지했습니다.
- `sendMessage`의 각 단계(차단, 에러, 정상 응답 등)에 로깅을 통합하고, 테스트 코드를 통해 스키마 준수 및 필드 파생 로직의 정확성을 검증했습니다.
2026-06-24 10:02:51 +09:00

92 lines
3.0 KiB
TypeScript

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