diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d27a48..81139ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.11-beta.3 — 2026-05-25 + +### Features +- Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). + ## 0.0.11-beta.2 — 2026-05-21 ### Features diff --git a/__tests__/hooks/hook-telemetry.test.ts b/__tests__/hooks/hook-telemetry.test.ts new file mode 100644 index 0000000..81a05ec --- /dev/null +++ b/__tests__/hooks/hook-telemetry.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +/** + * Real-payload coverage for the hook-binary telemetry choke point. + * Every hook + audit event flows through trackHookEvent, so asserting the + * fetch body here guarantees `product: failproofai-oss` is stamped on all of them. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { trackHookEvent } from "../../src/hooks/hook-telemetry"; + +describe("hook-telemetry trackHookEvent", () => { + let fetchSpy: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + delete process.env.FAILPROOFAI_TELEMETRY_DISABLED; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + process.env = { ...originalEnv }; + }); + + it("stamps product: failproofai-oss on every event", async () => { + await trackHookEvent("inst-id", "hooks_installed", { count: 1 }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.event).toBe("hooks_installed"); + expect(body.distinct_id).toBe("inst-id"); + expect(body.properties.product).toBe("failproofai-oss"); + expect(body.properties.$lib).toBe("failproofai-hooks"); + expect(body.properties.failproofai_version).toEqual(expect.any(String)); + expect(body.properties.count).toBe(1); + }); + + it("stamps product even when no extra properties are passed", async () => { + await trackHookEvent("inst-id", "audit_started"); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.properties.product).toBe("failproofai-oss"); + }); + + it("is a no-op when telemetry is disabled", async () => { + process.env.FAILPROOFAI_TELEMETRY_DISABLED = "1"; + await trackHookEvent("inst-id", "hooks_installed"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/lib/client-telemetry.test.ts b/__tests__/lib/client-telemetry.test.ts index d4dc11e..53fa572 100644 --- a/__tests__/lib/client-telemetry.test.ts +++ b/__tests__/lib/client-telemetry.test.ts @@ -50,6 +50,7 @@ describe("client-telemetry", () => { expect(body.distinct_id).toBe("test-id"); expect(body.properties.$lib).toBe("failproofai-web"); expect(body.properties.failproofai_version).toBe("1.0.0-test"); + expect(body.properties.product).toBe("failproofai-oss"); expect(body.properties.extra).toBe("prop"); }); diff --git a/__tests__/lib/telemetry.test.ts b/__tests__/lib/telemetry.test.ts index 8247821..2576931 100644 --- a/__tests__/lib/telemetry.test.ts +++ b/__tests__/lib/telemetry.test.ts @@ -121,7 +121,7 @@ describe("lib/telemetry", () => { expect(mockCapture).toHaveBeenCalledWith({ distinctId: "test-instance-id", event: "feature_used", - properties: expect.objectContaining({ feature: "dashboard", $lib: "failproofai", failproofai_version: expect.any(String) }), + properties: expect.objectContaining({ feature: "dashboard", $lib: "failproofai", failproofai_version: expect.any(String), product: "failproofai-oss" }), }); }); @@ -132,7 +132,7 @@ describe("lib/telemetry", () => { expect(mockCapture).toHaveBeenCalledWith({ distinctId: "test-instance-id", event: "app_started", - properties: { $lib: "failproofai", failproofai_version: expect.any(String) }, + properties: { $lib: "failproofai", failproofai_version: expect.any(String), product: "failproofai-oss" }, }); }); }); diff --git a/__tests__/scripts/install-telemetry.test.ts b/__tests__/scripts/install-telemetry.test.ts new file mode 100644 index 0000000..51eda84 --- /dev/null +++ b/__tests__/scripts/install-telemetry.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +/** + * Real-payload coverage for the npm-lifecycle telemetry choke point. + * package_installed / package_uninstalled both flow through trackInstallEvent, + * so asserting the fetch body here guarantees `product: failproofai-oss` is + * stamped on install/uninstall events too. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { trackInstallEvent } from "../../scripts/install-telemetry.mjs"; + +describe("install-telemetry trackInstallEvent", () => { + let fetchSpy: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + delete process.env.FAILPROOFAI_TELEMETRY_DISABLED; + process.env.npm_package_version = "9.9.9-test"; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + process.env = { ...originalEnv }; + }); + + it("stamps product: failproofai-oss on every event", async () => { + await trackInstallEvent("package_installed", { hooks_configured: true }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.event).toBe("package_installed"); + expect(body.properties.product).toBe("failproofai-oss"); + expect(body.properties.$lib).toBe("failproofai-install"); + expect(body.properties.hooks_configured).toBe(true); + }); + + it("is a no-op when telemetry is disabled", async () => { + process.env.FAILPROOFAI_TELEMETRY_DISABLED = "1"; + await trackInstallEvent("package_installed"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/client-telemetry.ts b/lib/client-telemetry.ts index 19e52b9..e396e05 100644 --- a/lib/client-telemetry.ts +++ b/lib/client-telemetry.ts @@ -1,4 +1,5 @@ import type { TelemetryConfig } from "@/app/actions/get-telemetry-config"; +import { POSTHOG_PRODUCT } from "@/src/posthog-key"; let config: TelemetryConfig | null = null; @@ -21,6 +22,7 @@ export function captureClientEvent( ...properties, $lib: "failproofai-web", failproofai_version: config.version, + product: POSTHOG_PRODUCT, $current_url: window.location.href, $pathname: window.location.pathname, }, diff --git a/lib/telemetry.ts b/lib/telemetry.ts index ec84f04..3c4dbdb 100644 --- a/lib/telemetry.ts +++ b/lib/telemetry.ts @@ -10,7 +10,7 @@ import { getInstanceId } from "./telemetry-id"; import { version } from "../package.json"; -import { POSTHOG_API_KEY } from "../src/posthog-key"; +import { POSTHOG_API_KEY, POSTHOG_PRODUCT } from "../src/posthog-key"; const DEFAULT_API_KEY = POSTHOG_API_KEY; const DEFAULT_HOST = "https://us.i.posthog.com"; @@ -129,7 +129,7 @@ export function trackEvent( client.capture({ distinctId: getInstanceId(), event: name, - properties: { ...properties, $lib: "failproofai", failproofai_version: version }, + properties: { ...properties, $lib: "failproofai", failproofai_version: version, product: POSTHOG_PRODUCT }, }); } diff --git a/scripts/install-telemetry.mjs b/scripts/install-telemetry.mjs index cef99ef..4285f7c 100644 --- a/scripts/install-telemetry.mjs +++ b/scripts/install-telemetry.mjs @@ -13,6 +13,9 @@ import { join } from "node:path"; const NAMESPACE = "failproofai-telemetry-v1"; const API_KEY = "phc_Ac1Ww1GqKc0z1SyrRWbmatEeQdlOQIsDEEdP8l8JRgX"; const CAPTURE_URL = "https://us.i.posthog.com/capture/"; +// Mirrors POSTHOG_PRODUCT in src/posthog-key.ts — this standalone npm-lifecycle +// script can't import the TS module at install time, so the literal is inlined. +const PRODUCT = "failproofai-oss"; function hashToId(raw) { return createHmac("sha256", NAMESPACE).update(raw).digest("hex"); @@ -91,6 +94,7 @@ export async function trackInstallEvent(event, properties = {}) { ...properties, $lib: "failproofai-install", failproofai_version: version, + product: PRODUCT, }, }); diff --git a/src/hooks/hook-telemetry.ts b/src/hooks/hook-telemetry.ts index 34c49ce..d4240cb 100644 --- a/src/hooks/hook-telemetry.ts +++ b/src/hooks/hook-telemetry.ts @@ -6,7 +6,7 @@ */ import { version } from "../../package.json"; -import { POSTHOG_API_KEY } from "../posthog-key"; +import { POSTHOG_API_KEY, POSTHOG_PRODUCT } from "../posthog-key"; const API_KEY = POSTHOG_API_KEY; const CAPTURE_URL = "https://us.i.posthog.com/capture/"; @@ -22,7 +22,7 @@ export async function trackHookEvent( api_key: process.env.FAILPROOFAI_POSTHOG_KEY ?? API_KEY, event, distinct_id: distinctId, - properties: { ...properties, $lib: "failproofai-hooks", failproofai_version: version }, + properties: { ...properties, $lib: "failproofai-hooks", failproofai_version: version, product: POSTHOG_PRODUCT }, }); try { diff --git a/src/posthog-key.ts b/src/posthog-key.ts index cae5b9d..93525eb 100644 --- a/src/posthog-key.ts +++ b/src/posthog-key.ts @@ -3,3 +3,12 @@ * and the compiled hook binary telemetry. Write-only (safe to commit). */ export const POSTHOG_API_KEY = "phc_Ac1Ww1GqKc0z1SyrRWbmatEeQdlOQIsDEEdP8l8JRgX"; + +/** + * Product identifier attached to the `product` property of every PostHog + * event, across all telemetry channels (hooks, web, server, install). + * Single source of truth so the value never drifts between channels. + * The standalone npm-lifecycle script (scripts/install-telemetry.mjs) can't + * import this TS module at install time, so it inlines the same literal. + */ +export const POSTHOG_PRODUCT = "failproofai-oss";