Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.0.11-beta.2 — 2026-05-21

### Features
- Expand PostHog telemetry coverage to close the 16 server-side and 12 web-UI gaps surfaced by the May audit (#376). Server-side adds `cli_install_success` / `cli_install_failure` / `cli_uninstall_success` / `cli_uninstall_failure` / `cli_list_invoked` / `cli_parse_error` / `cli_unexpected_error` / `hook_dispatch_error` (CLI lifecycle outcomes in `bin/failproofai.mjs`), `hook_stdin_error` / `hook_payload_parse_error` (hook handler input errors in `src/hooks/handler.ts`), `policy_evaluation_error` (builtin policy crashes in `src/hooks/policy-evaluator.ts`, distinct from the existing `custom_hook_error`), `custom_policy_validation_failed` / `custom_hooks_load_error` / `policy_params_validation_warning` / `scope_validation_failed` / `hook_write_failed` / `multi_scope_warning_shown` / `cli_detection_summary` / `beta_policies_installed` (manager / loader / install-prompt internals), and `first_install` / `version_changed` (lifecycle detection in `scripts/postinstall.mjs` via a new `~/.failproofai/last-version` file). Web-UI adds `policies_tab_switched` / `activity_filter_changed` (debounced) / `activity_row_toggled` / `activity_copy_clicked` / `activity_pagination_changed` / `cli_selection_toggled` / `cli_install_remove_submitted` / `cli_reinstall_submitted` / `policy_config_modal_opened` / `policy_config_modal_closed` / `action_error_displayed` / `hooks_install_from_error_clicked` via `usePostHog()` in `app/policies/hooks-client.tsx`. The deny-/instruct-only condition at `handler.ts:344` (allow-path tracking) is intentionally left unchanged. All events go through the existing helpers (`trackHookEvent`, `trackInstallEvent`, `captureClientEvent`) and honor `FAILPROOFAI_TELEMETRY_DISABLED=1`.

### Breaking
- Remove the undocumented cloud auth + event relay subsystem ahead of a from-scratch redesign. Deletes `src/auth/` (OAuth 2.0 device-flow login against `api.befailproof.ai`, `~/.failproofai/auth.json` token store) and `src/relay/` (WebSocket event relay daemon, sanitized JSONL queue at `~/.failproofai/cache/server-queue/`, PID tracking). Strips the `failproofai login` / `logout` / `whoami` / `relay start|stop|status` / `sync` subcommands and the internal `--relay-daemon` mode from `bin/failproofai.mjs`, along with their `--help` entries and "did you mean" suggestions. Removes the fire-and-forget `appendToServerQueue` + `ensureRelayRunning` calls from `src/hooks/handler.ts` so hook evaluation no longer enqueues events or lazy-spawns a daemon. The whole subsystem had zero references in `README.md`, `docs/`, `examples/`, or `__tests__/`, and only had internal cross-imports — `tsc`, `eslint`, `vitest` (1623 tests), and the `bun run build` bundles all stay green. Users who ran `failproofai login` should also wipe `~/.failproofai/{auth.json,cache/server-queue,relay.pid}` and stop any running relay daemon by hand; new auth/cloud surface will land in a follow-up.

Expand Down
211 changes: 211 additions & 0 deletions __tests__/hooks/new-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// @vitest-environment node
/**
* Coverage for telemetry events added as part of the gap-closing audit.
* Each test stubs trackHookEvent and asserts the right event name + props
* fire at the trigger site. Keep one focused case per event.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { resolve } from "node:path";
import { homedir } from "node:os";

vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
}));

vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));

vi.mock("../../src/hooks/install-prompt", async () => {
const actual = await vi.importActual<typeof import("../../src/hooks/install-prompt")>(
"../../src/hooks/install-prompt",
);
return {
...actual,
promptPolicySelection: vi.fn(() => Promise.resolve(["block-sudo"])),
};
});

vi.mock("../../src/hooks/integrations", () => ({
detectInstalledClis: vi.fn(() => ["claude"]),
getIntegration: vi.fn((id: string) => ({
displayName: id,
scopes: id === "codex" ? ["user", "project"] : ["user", "project", "local"],
eventTypes: [],
getSettingsPath: vi.fn(),
hooksInstalledInSettings: vi.fn(),
readSettings: vi.fn(() => ({})),
writeSettings: vi.fn(),
writeHookEntries: vi.fn(),
removeHooksFromFile: vi.fn(() => 0),
})),
claudeCode: {
getSettingsPath: vi.fn(() => "/tmp/.claude/settings.json"),
hooksInstalledInSettings: vi.fn(() => false),
},
listIntegrations: vi.fn(() => []),
}));

vi.mock("../../src/hooks/hooks-config", () => ({
readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })),
readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })),
writeHooksConfig: vi.fn(),
readScopedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })),
writeScopedHooksConfig: vi.fn(),
findProjectConfigDir: vi.fn((cwd: string) => cwd),
getConfigPathForScope: vi.fn(() => "/tmp/policies-config.json"),
}));

vi.mock("../../src/hooks/hook-telemetry", () => ({
trackHookEvent: vi.fn(() => Promise.resolve()),
}));

vi.mock("../../lib/telemetry-id", () => ({
getInstanceId: vi.fn(() => "test-instance-id"),
hashToId: vi.fn((raw: string) => `hashed:${raw}`),
}));

vi.mock("../../src/hooks/custom-hooks-loader", async () => {
const actual = await vi.importActual<typeof import("../../src/hooks/custom-hooks-loader")>(
"../../src/hooks/custom-hooks-loader",
);
return {
...actual,
loadCustomHooks: vi.fn(() => Promise.resolve([])),
discoverPolicyFiles: vi.fn(() => []),
};
});

describe("new telemetry events — manager", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(execSync).mockReturnValue("/usr/local/bin/failproofai\n");
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("fires scope_validation_failed when scope is unsupported for the CLI", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue("{}");

const { installHooks } = await import("../../src/hooks/manager");
const { trackHookEvent } = await import("../../src/hooks/hook-telemetry");

// codex does not support the "local" scope (see src/hooks/integrations.ts)
await expect(installHooks(["block-sudo"], "local" as never, undefined, false, undefined, undefined, false, [
"codex",
])).rejects.toThrow(/Scope "local" is not supported/);

expect(trackHookEvent).toHaveBeenCalledWith(
"test-instance-id",
"scope_validation_failed",
expect.objectContaining({
cli: "codex",
scope: "local",
supported_scopes: expect.any(Array),
}),
);
});

it("fires custom_policy_validation_failed when the custom file throws", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue("{}");
const { loadCustomHooks } = await import("../../src/hooks/custom-hooks-loader");
vi.mocked(loadCustomHooks).mockRejectedValueOnce(new Error("Custom hooks file not found: /tmp/missing.js"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null | undefined) => undefined as never) as never);

const { installHooks } = await import("../../src/hooks/manager");
const { trackHookEvent } = await import("../../src/hooks/hook-telemetry");

await installHooks(["block-sudo"], "user", undefined, false, undefined, "/tmp/missing.js");

expect(trackHookEvent).toHaveBeenCalledWith(
"test-instance-id",
"custom_policy_validation_failed",
expect.objectContaining({
scope: "user",
error_type: "file_not_found",
}),
);
exitSpy.mockRestore();
});

it("fires policy_params_validation_warning when an unknown key is in policyParams", async () => {
vi.mocked(existsSync).mockReturnValue(false);
const { readMergedHooksConfig } = await import("../../src/hooks/hooks-config");
vi.mocked(readMergedHooksConfig).mockReturnValue({
enabledPolicies: [],
policyParams: { "nonexistent-policy": { hint: "test" } },
});

const { listHooks } = await import("../../src/hooks/manager");
const { trackHookEvent } = await import("../../src/hooks/hook-telemetry");

await listHooks();

expect(trackHookEvent).toHaveBeenCalledWith(
"test-instance-id",
"policy_params_validation_warning",
expect.objectContaining({
unknown_keys_count: 1,
unknown_keys: ["nonexistent-policy"],
}),
);
});

it("respects FAILPROOFAI_TELEMETRY_DISABLED — the underlying helper is mocked, but the call still happens (test verifies the trigger fires unconditionally)", async () => {
// The helper itself short-circuits when FAILPROOFAI_TELEMETRY_DISABLED=1
// (verified in __tests__/lib/telemetry.test.ts). Here we just confirm the
// trigger call site still runs — the env-var check lives in the helper.
process.env.FAILPROOFAI_TELEMETRY_DISABLED = "1";
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue("{}");

try {
const { installHooks } = await import("../../src/hooks/manager");
const { trackHookEvent } = await import("../../src/hooks/hook-telemetry");

await installHooks(["block-sudo"], "user");

// The call site fires; the helper internally no-ops. This is the same
// contract as the existing hooks_installed test.
expect(trackHookEvent).toHaveBeenCalled();
} finally {
delete process.env.FAILPROOFAI_TELEMETRY_DISABLED;
}
});
});

describe("new telemetry events — install-prompt", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("fires cli_detection_summary with resolution_mode=explicit when --cli is passed", async () => {
const { resolveTargetClis } = await import("../../src/hooks/install-prompt");
const { trackHookEvent } = await import("../../src/hooks/hook-telemetry");

const result = await resolveTargetClis(["codex"], "install");
expect(result).toEqual(["codex"]);
expect(trackHookEvent).toHaveBeenCalledWith(
"test-instance-id",
"cli_detection_summary",
expect.objectContaining({
action: "install",
explicit_clis: ["codex"],
selected_clis: ["codex"],
resolution_mode: "explicit",
}),
);
});
});
Loading