- 채팅 서비스의 관측성을 확보하기 위해 `chatAnalyticsService`를 도입하여 주요 이벤트(요청 결과, 토큰 사용량, 지연 시간 등)를 구조화된 로그로 기록합니다. - 개인정보 보호를 위해 메시지 본문은 제외하고 길잇값과 메타데이터 위주로 수집하며, 비동기 로그 싱크를 통해 서비스 응답 지연을 방지했습니다. - `sendMessage`의 각 단계(차단, 에러, 정상 응답 등)에 로깅을 통합하고, 테스트 코드를 통해 스키마 준수 및 필드 파생 로직의 정확성을 검증했습니다.
92 lines
3.0 KiB
TypeScript
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();
|
|
});
|
|
});
|