Skip to content

Commit 324bfa8

Browse files
committed
fix: enforce closed telemetry schema
1 parent 7c69da6 commit 324bfa8

2 files changed

Lines changed: 86 additions & 17 deletions

File tree

‎src/lib/observability.ts‎

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Sentry from "@sentry/react";
22
import posthog from "posthog-js";
3+
import { DEFAULT_PROVIDERS } from "@/lib/ai/providers/types";
34

45
let initialized = false;
56

@@ -22,35 +23,75 @@ export function filterObservabilityBreadcrumb<T extends ConsoleBreadcrumb>(bread
2223
return isReactCaughtRenderError ? null : breadcrumb;
2324
}
2425

25-
const PRODUCT_PROPERTY_KEYS = new Set([
26-
"durationMs",
27-
"errorName",
28-
"feature",
29-
"format",
30-
"model",
31-
"provider",
32-
"roundIndex",
33-
"speechCount",
34-
"statusCode",
35-
"streaming",
36-
"tool",
37-
"winner",
26+
const PRODUCT_EVENT_NAMES = new Set([
27+
"ai_request_failed",
28+
"ai_request_started",
29+
"ai_request_succeeded",
30+
"content_exported",
31+
"sparring_round_completed",
32+
"sparring_session_judged",
33+
"tool_opened",
3834
]);
3935

36+
const STRING_PROPERTY_VALUES: Record<string, ReadonlySet<string>> = {
37+
errorName: new Set([
38+
"AIAuthError",
39+
"AIModelRestrictionError",
40+
"AIProviderError",
41+
"AIRateLimitError",
42+
"AIResponseError",
43+
"AISubscriptionError",
44+
"Error",
45+
"TimeoutError",
46+
"TypeError",
47+
"UnknownError",
48+
]),
49+
feature: new Set(["ask", "attack", "brainstorm", "crossfire", "factoids", "generate", "sparring"]),
50+
format: new Set(["docx", "google_docs", "ld", "pdf", "pf", "standard"]),
51+
model: new Set(DEFAULT_PROVIDERS.flatMap((provider) => provider.models.map((model) => model.id))),
52+
provider: new Set(DEFAULT_PROVIDERS.map((provider) => provider.id)),
53+
tool: new Set([
54+
"admin",
55+
"ask",
56+
"attack",
57+
"brainstorm",
58+
"crossfire",
59+
"factoids",
60+
"flowchart",
61+
"generate",
62+
"history",
63+
"library",
64+
"settings",
65+
"sparring",
66+
"timer",
67+
]),
68+
winner: new Set(["ai", "tie", "user"]),
69+
};
70+
4071
type TelemetryAttribute = string | number | boolean;
4172

73+
/** Retains only bounded values from the product telemetry schema. */
4274
function safeProductProperties(properties?: Record<string, unknown>): Record<string, TelemetryAttribute> {
4375
if (!properties) return {};
4476

4577
const safeProperties: Record<string, TelemetryAttribute> = {};
4678
for (const [key, value] of Object.entries(properties)) {
47-
if (!PRODUCT_PROPERTY_KEYS.has(key)) continue;
48-
if (typeof value === "string" || typeof value === "boolean") safeProperties[key] = value;
49-
if (typeof value === "number" && Number.isFinite(value)) safeProperties[key] = value;
79+
if (typeof value === "string" && STRING_PROPERTY_VALUES[key]?.has(value)) safeProperties[key] = value;
80+
if (key === "streaming" && typeof value === "boolean") safeProperties[key] = value;
81+
if (key === "durationMs" && typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 3_600_000) {
82+
safeProperties[key] = value;
83+
}
84+
if ((key === "roundIndex" || key === "speechCount") && Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 100) {
85+
safeProperties[key] = Number(value);
86+
}
87+
if (key === "statusCode" && Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599) {
88+
safeProperties[key] = Number(value);
89+
}
5090
}
5191
return safeProperties;
5292
}
5393

94+
/** Initializes configured observability SDKs once with explicit privacy controls. */
5495
export function initializeObservability(): void {
5596
if (initialized) return;
5697
initialized = true;
@@ -100,7 +141,9 @@ export function initializeObservability(): void {
100141
}
101142
}
102143

144+
/** Reports a named product event only when its name and properties match the closed schema. */
103145
export function captureProductEvent(name: string, properties?: Record<string, unknown>): void {
146+
if (!PRODUCT_EVENT_NAMES.has(name)) return;
104147
const safeProperties = safeProductProperties(properties);
105148

106149
if (import.meta.env.VITE_POSTHOG_KEY) {
@@ -128,6 +171,7 @@ export function captureProductEvent(name: string, properties?: Record<string, un
128171
}
129172
}
130173

174+
/** Reports a safely constructed exception without allowing telemetry failures into product flows. */
131175
export function captureException(error: unknown, context?: Record<string, unknown>): void {
132176
if (!import.meta.env.VITE_SENTRY_DSN) return;
133177

‎src/test/observability.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe("observability primitives", () => {
7272
vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
7373
const properties = {
7474
tool: "generate",
75-
format: "bp",
75+
format: "pdf",
7676
durationMs: 123,
7777
streaming: false,
7878
};
@@ -125,6 +125,31 @@ describe("observability primitives", () => {
125125
])).not.toContain("private debate content");
126126
});
127127

128+
it("rejects private strings in allowlisted fields and rejects unknown event names", () => {
129+
vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
130+
vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
131+
132+
captureProductEvent("ai_request_succeeded", {
133+
feature: "private debate content",
134+
model: "student@example.com",
135+
provider: "openai",
136+
});
137+
captureProductEvent("student@example.com", { feature: "ask" });
138+
139+
expect(captureMock).toHaveBeenCalledOnce();
140+
expect(captureMock).toHaveBeenCalledWith("ai_request_succeeded", { provider: "openai" });
141+
expect(sentryLoggerInfoMock).toHaveBeenCalledOnce();
142+
expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", {
143+
event_name: "ai_request_succeeded",
144+
provider: "openai",
145+
});
146+
expect(JSON.stringify([
147+
...captureMock.mock.calls,
148+
...sentryLoggerInfoMock.mock.calls,
149+
...sentryMetricsCountMock.mock.calls,
150+
])).not.toMatch(/private debate content|student@example\.com/);
151+
});
152+
128153
it("does not send exceptions when Sentry is not configured", () => {
129154
captureException(new Error("synthetic test error"), { category: "test" });
130155

0 commit comments

Comments
 (0)