From 9f5fac27531baa47264ec7183abae5f580a1b16f Mon Sep 17 00:00:00 2001 From: yohlo Date: Tue, 25 Aug 2026 23:04:14 -0700 Subject: [PATCH] feat(telemetry): exclude configured phones from usage telemetry - TELEMETRY_EXCLUDE_PHONES resolves to player ids at runtime, cached 10m - excluded players write no activities, client events, or vitals - their client errors are still captured --- src/app/routes/api/telemetry/beacon.ts | 7 +++ src/lib/telemetry/auth-events.server.ts | 5 +++ src/lib/telemetry/exclusions.server.test.ts | 37 ++++++++++++++++ src/lib/telemetry/exclusions.server.ts | 49 +++++++++++++++++++++ src/utils/activities.test.ts | 19 ++++++++ src/utils/activities.ts | 13 +++--- 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 src/lib/telemetry/exclusions.server.test.ts create mode 100644 src/lib/telemetry/exclusions.server.ts diff --git a/src/app/routes/api/telemetry/beacon.ts b/src/app/routes/api/telemetry/beacon.ts index 8ca7ec4..4c73e43 100644 --- a/src/app/routes/api/telemetry/beacon.ts +++ b/src/app/routes/api/telemetry/beacon.ts @@ -41,6 +41,13 @@ const writeEvents = async ( events: BeaconEvent[], base: { sessionId: string; playerId?: string; userAgent?: string } ) => { + const { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server"); + if (base.playerId && (await isExcludedPlayerId(base.playerId))) { + // Excluded players still report errors; their usage stays out. + events = events.filter((event) => event.kind === "error"); + if (events.length === 0) return; + } + const { pbAdmin } = await import("@/lib/pocketbase/client"); await pbAdmin.authPromise; diff --git a/src/lib/telemetry/auth-events.server.ts b/src/lib/telemetry/auth-events.server.ts index 9623b72..a6610df 100644 --- a/src/lib/telemetry/auth-events.server.ts +++ b/src/lib/telemetry/auth-events.server.ts @@ -1,4 +1,5 @@ import { Logger } from "@/lib/logger"; +import { isExcludedPhone, isExcludedPlayerId } from "./exclusions.server"; const logger = new Logger("Telemetry"); @@ -12,6 +13,8 @@ interface AuthEventInput { export const recordAuthEvent = (name: string, input: AuthEventInput) => { void (async () => { try { + if (isExcludedPhone(input.phone)) return; + const { pbAdmin } = await import("@/lib/pocketbase/client"); await pbAdmin.authPromise; @@ -35,6 +38,8 @@ export const recordAuthEvent = (name: string, input: AuthEventInput) => { } catch {} } + if (await isExcludedPlayerId(playerId)) return; + await pbAdmin.createActivity({ name, player: playerId, diff --git a/src/lib/telemetry/exclusions.server.test.ts b/src/lib/telemetry/exclusions.server.test.ts new file mode 100644 index 0000000..d169b99 --- /dev/null +++ b/src/lib/telemetry/exclusions.server.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + Logger: class { + error() {} + info() {} + }, +})); + +import { isExcludedPhone } from "./exclusions.server"; + +const ENV_KEY = "TELEMETRY_EXCLUDE_PHONES"; + +afterEach(() => { + delete process.env[ENV_KEY]; +}); + +describe("isExcludedPhone", () => { + it("matches exact and formatted variants of a configured phone", () => { + process.env[ENV_KEY] = "+17135550142"; + expect(isExcludedPhone("+17135550142")).toBe(true); + expect(isExcludedPhone("+1 (713) 555-0142")).toBe(true); + expect(isExcludedPhone("+17135550143")).toBe(false); + }); + + it("supports a comma-separated list with whitespace", () => { + process.env[ENV_KEY] = "+17135550142, +14155550100"; + expect(isExcludedPhone("+14155550100")).toBe(true); + }); + + it("excludes nothing when unset or empty", () => { + expect(isExcludedPhone("+17135550142")).toBe(false); + process.env[ENV_KEY] = ""; + expect(isExcludedPhone("+17135550142")).toBe(false); + expect(isExcludedPhone(undefined)).toBe(false); + }); +}); diff --git a/src/lib/telemetry/exclusions.server.ts b/src/lib/telemetry/exclusions.server.ts new file mode 100644 index 0000000..c25dc7c --- /dev/null +++ b/src/lib/telemetry/exclusions.server.ts @@ -0,0 +1,49 @@ +import { Logger } from "@/lib/logger"; + +const logger = new Logger("Telemetry"); + +const CACHE_TTL_MS = 10 * 60 * 1000; + +const normalizePhone = (phone: string): string => phone.replace(/[^\d+]/g, ""); + +const excludedPhones = (): string[] => + (process.env.TELEMETRY_EXCLUDE_PHONES ?? "") + .split(",") + .map((phone) => normalizePhone(phone.trim())) + .filter(Boolean); + +export const isExcludedPhone = (phone?: string): boolean => + !!phone && excludedPhones().includes(normalizePhone(phone)); + +let cache: { playerIds: Set; expiresAt: number } | null = null; + +export const getExcludedPlayerIds = async (): Promise> => { + const now = Date.now(); + if (cache && cache.expiresAt > now) return cache.playerIds; + + const playerIds = new Set(); + const phones = excludedPhones(); + + if (phones.length > 0) { + try { + const SuperTokens = (await import("supertokens-node")).default; + const { pbAdmin } = await import("@/lib/pocketbase/client"); + for (const phoneNumber of phones) { + const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber }); + const authId = users[0]?.id; + if (!authId) continue; + const player = await pbAdmin.getPlayerByAuthId(authId); + if (player) playerIds.add(player.id); + } + } catch (error) { + logger.error("Failed to resolve telemetry exclusions", error); + return playerIds; + } + } + + cache = { playerIds, expiresAt: now + CACHE_TTL_MS }; + return playerIds; +}; + +export const isExcludedPlayerId = async (playerId?: string): Promise => + !!playerId && (await getExcludedPlayerIds()).has(playerId); diff --git a/src/utils/activities.test.ts b/src/utils/activities.test.ts index f412025..ae18c22 100644 --- a/src/utils/activities.test.ts +++ b/src/utils/activities.test.ts @@ -20,6 +20,11 @@ vi.mock("@/lib/logger", () => ({ }, })); +vi.mock("@/lib/telemetry/exclusions.server", () => ({ + isExcludedPlayerId: async (playerId?: string) => playerId === "excluded-player", + isExcludedPhone: () => false, +})); + import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities"; import { setRequestActor } from "@/lib/telemetry/request-context.server"; import { redirect } from "@tanstack/react-router"; @@ -303,6 +308,20 @@ describe("serverFnLoggingMiddleware control flow and dedup", () => { expect(h.createActivity.mock.calls[0][0].error).toContain("FORBIDDEN"); }); + it("does not record activity for excluded players", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + setRequestActor(h.request as unknown as Request, { playerId: "excluded-player" }); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await flushWrites(); + expect(h.createActivity).not.toHaveBeenCalled(); + }); + it("prefers the stashed request actor over middleware context", async () => { setRequest("http://localhost:3000/_serverFn/doThing"); setRequestActor(h.request as unknown as Request, { playerId: "p9" }); diff --git a/src/utils/activities.ts b/src/utils/activities.ts index 5bc92c5..5490a8d 100644 --- a/src/utils/activities.ts +++ b/src/utils/activities.ts @@ -13,14 +13,17 @@ import type { ActivityInput } from "@/lib/pocketbase/services/activities"; const logger = new Logger("Activities"); export const recordActivity = (activity: ActivityInput) => { - import("@/lib/pocketbase/client") - .then(async ({ pbAdmin }) => { + void (async () => { + try { + const { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server"); + if (await isExcludedPlayerId(activity.player)) return; + const { pbAdmin } = await import("@/lib/pocketbase/client"); await pbAdmin.authPromise; await pbAdmin.createActivity(activity); - }) - .catch((activityError) => { + } catch (activityError) { logger.error("Failed to record activity", activityError); - }); + } + })(); }; const auditErrorMessage = (