diff --git a/CHANGELOG.md b/CHANGELOG.md index db42ec39..b12cba8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ - Add `failproofai audit` command (beta) — retrospectively scan past agent transcripts across all 7 CLIs and report wasteful/risky behavior via the 39 builtin policies + 8 new audit-only detectors (`redundant-cd-cwd`, `prefer-edit-over-read-cat`, `prefer-edit-over-sed-awk`, `prefer-write-over-heredoc`, `sleep-polling-loop`, `find-from-root`, `git-commit-no-verify`, `reread-after-edit`). Outputs ANSI table + markdown report; supports `--cli`, `--project`, `--since`, `--policy`, `--limit`, `--show-examples`, `--report`, `--no-report`, `--json`, `--no-cache`. Per-transcript cache at `~/.failproofai/cache/audit/` auto-invalidates on policy/detector code changes (#377). ### Fixes +- Deliver the `failproofai audit` CLI's telemetry reliably. `cli_audit_started` / `cli_audit_completed` / `cli_audit_failed` were emitted fire-and-forget (`void trackHookEvent(...)`), so the failed path (`die()` → `process.exit(1)`) and the empty-history path (`process.exit(0)`) killed the in-flight `fetch` before it landed — those events never reached PostHog. `src/audit/cli.ts` now `await`s the two exit-adjacent events before exiting (matching `bin/failproofai.mjs`'s `track()` helper); `cli_audit_started` stays fire-and-forget since the multi-second scan keeps the process alive. New `__tests__/audit/audit-cli-telemetry.test.ts` asserts each path emits its event and that the exit-adjacent events are awaited before `process.exit` (#461). +- Apply the same telemetry-delivery fix to the `failproofai auth` CLI (`src/auth/cli.ts`), which had the identical bug: `audit_cli_auth_login_completed` / `audit_otp_verified` / `audit_user_identity_linked` / `audit_cli_auth_logout_completed` / `audit_cli_auth_whoami` were emitted fire-and-forget and dropped when the process exited after the command. The terminal and error events are now awaited; the two mid-flow events (`audit_cli_auth_login_started`, the success `audit_otp_requested`) stay fire-and-forget since the interactive `email:` / `code:` prompts keep the process alive. New `__tests__/auth/auth-cli-telemetry.test.ts` (#461). +- Instrument the dashboard's server-side audit run. `POST /api/audit/run` ran `runAudit()` as a detached task and emitted **no** PostHog events — the dashboard's actual audit work and its failures were invisible, with only the client-side `audit_rerun_clicked` / `audit_rerun_failed` recorded. The route now emits `audit_run_started` / `audit_run_completed` (duration, events + sessions scanned, findings, hits, persisted) / `audit_run_failed` / `audit_run_rejected`, mirroring the CLI's `cli_audit_*` funnel; and the dashboard now emits the previously-missing `audit_rerun_succeeded` (it tracked clicks and failures but never successes) (#461). +- Close the remaining telemetry gaps the audit surfaced: track the postinstall build-missing failure (`package_install_failed`, awaited before `process.exit(1)` — previously invisible); add `keepalive: true` to `captureClientEvent` so events fired right before a navigation/unload aren't dropped; track `/api/auth/login-verify` validation-400s and add `email` + `source` to its failure events for parity with `/api/auth/login-request`; and fill property gaps (`node_version` on `package_installed`, drop the duplicate `version` on `first_install`, add `subcommand` + `exit_code` to `cli_auth_invoked`). The hook hot-path error events are intentionally left fire-and-forget to avoid adding telemetry latency to every tool call (#461). - Fix the policies → activity table collapsing on narrow / portrait windows. Columns no longer overlap — each data cell clips with an ellipsis at its own edge and headers stay on one line — and the table holds a readable `min-width` (1280px), scrolling horizontally below that via a themed scrollbar instead of squeezing columns into each other. The badge / long-header columns (decision, event, cli, mode, duration, session) were widened so their content fits — the **mode** column in particular now holds its widest pill (`bypassPermissions`) instead of clipping it mid-word, and the mode pill truncates with an ellipsis + hover tooltip if a longer / custom mode ever appears. - Fix three translated docs pages that failed the Mintlify deploy parse. `docs/tr/cli/audit.mdx` had a dropped closing backtick that pushed `` out of its inline-code span (parsed as an unclosed JSX tag); `docs/ja/built-in-policies.mdx` and `docs/zh/built-in-policies.mdx` carried translator-injected `{#id}` heading anchors that MDX reads as JS expressions. All three now match the other 12 locales (#455). - Stop the failproofai server log from repeating the benign Next.js "Failed to find Server Action" deployment-skew error. A browser tab left open across a dashboard rebuild/upgrade POSTs a stale Server Action ID; the client recovers via Next's graceful 404, but the standalone server still logged a 3-line error block to stderr per stale request. The `start` launcher now pipes the server's output through a filter (`scripts/skew-log-filter.ts`) that drops just that block — all other output, and color via `FORCE_COLOR`, passes through untouched; `dev` is unchanged (#456). @@ -98,6 +102,7 @@ - Add coverage for previously untested audit + auth modules: `__tests__/audit/archetypes.test.ts` (zero-signal → precision, broad-spread → goldfish, secondary ≥40% promotion vs authored fallback, deterministic variant picker), `__tests__/audit/findings.test.ts` (ranking, zero-hit drop, detector→policy remapping, `alsoCoveredBy`, `alreadyEnabled` enable-set + builtin-config heuristics, relative-time + missing `lastSeen` fallback), `__tests__/audit/strengths.test.ts` (clean-rate headline, credential / retry / push-to-main absence gates, 5-item cap, fallback row when too few qualify), and `__tests__/lib/auth-store.test.ts` (round-trip, mode 0600, atomic write leaves no `.tmp` siblings, shape-mismatch rejection, reminder scoping, atomic overwrite). +40 tests; full suite at 1741 passing. ### Docs +- Point every "Docs" landing link at `https://docs.befailproof.ai/introduction` (the Mintlify landing page) instead of a bare root that doesn't resolve to a page: the `failproofai --help` LINKS banner and the `dev` / `start` launch banner (were `https://befailproof.ai`), the dashboard "Reach Us" → Documentation entry (was `https://docs.befailproof.ai/`), and the README docs badge (English + 14 translations, was a bare `https://docs.befailproof.ai`). Deep page links (e.g. `https://docs.befailproof.ai/built-in-policies`) are unchanged (#461). - Replace the community Slack invite with Discord (`https://discord.gg/2zjBZP7yQJ`) everywhere it's user-facing: the `failproofai --help` LINKS banner, the dashboard "Reach Us" dropdown, and the README community badge (English + 14 translations). The Slack *webhook notification example* (`examples/policies-notification.js`) is intentionally left as-is — it's a feature integration, not a community link. - Reword the `/audit` invite card ("Share with friends" / "wanna know how your friends' agents score?") and grammar-pass the X/LinkedIn share templates (article/adverb/coordination/comma-splice fixes only — no behavioral or structural change). - Document the `failproofai audit` command and `npx -y failproofai audit` usage in `docs/cli/audit.mdx`, and refresh the `docs/dashboard.mdx` Audit section to the current poster flow (#453). diff --git a/README.md b/README.md index de0b3dd6..aff0319f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Translations:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/__tests__/api/audit-run-route.test.ts b/__tests__/api/audit-run-route.test.ts index 0373facf..3bf89ff8 100644 --- a/__tests__/api/audit-run-route.test.ts +++ b/__tests__/api/audit-run-route.test.ts @@ -3,13 +3,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { NextRequest } from "next/server"; // Mock the heavy audit modules so the route is exercised in isolation: runAudit -// is replaced with a controllable promise, and the cache write is a no-op. -const { runAuditMock, writeCacheMock } = vi.hoisted(() => ({ +// is replaced with a controllable promise, the cache write is a no-op, and the +// telemetry channel is a spy so we can assert the dashboard run funnel. +const { runAuditMock, writeCacheMock, trackEventMock, initTelemetryMock } = vi.hoisted(() => ({ runAuditMock: vi.fn(), writeCacheMock: vi.fn(), + trackEventMock: vi.fn(), + initTelemetryMock: vi.fn(async () => {}), })); vi.mock("@/src/audit", () => ({ runAudit: runAuditMock })); vi.mock("@/src/audit/dashboard-cache", () => ({ writeDashboardCache: writeCacheMock })); +vi.mock("@/lib/telemetry", () => ({ initTelemetry: initTelemetryMock, trackEvent: trackEventMock })); import { POST } from "@/app/api/audit/run/route"; import { getRunState, releaseRun } from "@/app/api/audit/_state"; @@ -18,15 +22,35 @@ function req(body: string): NextRequest { return { text: async () => body } as unknown as NextRequest; } +// A well-formed AuditResult so the route's audit_run_completed property reads +// (result.transcripts.scanned, result.totals.hits, …) don't throw. +function auditResult(over: Record = {}) { + return { + eventsScanned: 1240, + transcripts: { scanned: 18, skipped: 0, errors: 0, durationMs: 0 }, + projectsScanned: ["/a", "/b"], + results: [{}, {}, {}], + totals: { hits: 7, projectsWithHits: 2 }, + ...over, + }; +} + +const flush = async () => { + for (let i = 0; i < 3; i++) await Promise.resolve(); +}; +const trackedNames = () => trackEventMock.mock.calls.map((c) => c[0] as string); + describe("POST /api/audit/run (fire-and-forget)", () => { beforeEach(() => { releaseRun(); runAuditMock.mockReset(); writeCacheMock.mockReset(); + trackEventMock.mockReset(); + initTelemetryMock.mockClear(); }); afterEach(() => releaseRun()); - it("returns 202 immediately WITHOUT awaiting the run, and marks the lock running", async () => { + it("returns 202 immediately WITHOUT awaiting the run, marks the lock, and emits audit_run_started", async () => { // runAudit never resolves during the test — if POST awaited it, this would // hang. Reaching the assertions proves the run is detached. runAuditMock.mockImplementation(() => new Promise(() => {})); @@ -37,9 +61,10 @@ describe("POST /api/audit/run (fire-and-forget)", () => { await expect(res.json()).resolves.toEqual({ status: "started" }); expect(getRunState().running).toBe(true); expect(runAuditMock).toHaveBeenCalledTimes(1); + expect(trackedNames()).toContain("audit_run_started"); }); - it("409s a second concurrent run while one is in flight", async () => { + it("409s a second concurrent run and tracks audit_run_rejected(already_running)", async () => { runAuditMock.mockImplementation(() => new Promise(() => {})); const first = await POST(req("{}")); @@ -47,11 +72,14 @@ describe("POST /api/audit/run (fire-and-forget)", () => { const second = await POST(req("{}")); expect(second.status).toBe(409); - // The detached first run is still the only one that ran. expect(runAuditMock).toHaveBeenCalledTimes(1); + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_rejected", + expect.objectContaining({ reason: "already_running" }), + ); }); - it("records the error and releases the lock when the detached run throws", async () => { + it("tracks audit_run_failed, records the error, and releases the lock when the detached run throws", async () => { let reject!: (e: unknown) => void; runAuditMock.mockImplementation(() => new Promise((_res, rej) => { reject = rej; })); @@ -59,42 +87,46 @@ describe("POST /api/audit/run (fire-and-forget)", () => { expect(res.status).toBe(202); expect(getRunState().running).toBe(true); - // Fail the background run and let its .catch settle. reject(new Error("scan blew up")); - await Promise.resolve(); - await Promise.resolve(); + await flush(); const s = getRunState(); expect(s.running).toBe(false); expect(s.error).toBe("scan blew up"); expect(writeCacheMock).not.toHaveBeenCalled(); + expect(trackedNames()).toContain("audit_run_failed"); }); - it("writes the cache and clears the lock when the detached run succeeds", async () => { + it("writes the cache, tracks audit_run_completed with metrics, and clears the lock on success", async () => { let resolveRun!: (value: unknown) => void; - runAuditMock.mockImplementation( - () => new Promise((res) => { resolveRun = res; }), - ); + runAuditMock.mockImplementation(() => new Promise((res) => { resolveRun = res; })); writeCacheMock.mockReturnValue(true); const res = await POST(req("{}")); expect(res.status).toBe(202); expect(getRunState().running).toBe(true); - // Complete the detached run and let its .then settle. - resolveRun({ ok: true }); - await Promise.resolve(); - await Promise.resolve(); + resolveRun(auditResult()); + await flush(); expect(writeCacheMock).toHaveBeenCalledTimes(1); expect(getRunState()).toMatchObject({ running: false, error: null }); + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_completed", + expect.objectContaining({ + source: "dashboard", + events_scanned: 1240, + sessions_scanned: 18, + findings: 3, + total_hits: 7, + persisted: true, + }), + ); }); it("reports a run error when the result cannot be persisted (cache write fails)", async () => { let resolveRun!: (value: unknown) => void; - runAuditMock.mockImplementation( - () => new Promise((res) => { resolveRun = res; }), - ); + runAuditMock.mockImplementation(() => new Promise((res) => { resolveRun = res; })); // writeDashboardCache swallows its own IO errors and returns false; in // fire-and-forget the cache is the only delivery channel, so a failed // persist must surface as a run error rather than a silent success. @@ -103,19 +135,26 @@ describe("POST /api/audit/run (fire-and-forget)", () => { const res = await POST(req("{}")); expect(res.status).toBe(202); - resolveRun({ ok: true }); - await Promise.resolve(); - await Promise.resolve(); + resolveRun(auditResult()); + await flush(); const s = getRunState(); expect(s.running).toBe(false); expect(s.error).toBeTruthy(); + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_completed", + expect.objectContaining({ persisted: false }), + ); }); - it("400s a non-object JSON body", async () => { + it("400s a non-object JSON body and tracks audit_run_rejected(non_object_body)", async () => { const res = await POST(req("[]")); expect(res.status).toBe(400); expect(getRunState().running).toBe(false); expect(runAuditMock).not.toHaveBeenCalled(); + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_rejected", + expect.objectContaining({ reason: "non_object_body" }), + ); }); }); diff --git a/__tests__/audit/audit-cli-telemetry.test.ts b/__tests__/audit/audit-cli-telemetry.test.ts new file mode 100644 index 00000000..a865cfe6 --- /dev/null +++ b/__tests__/audit/audit-cli-telemetry.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +/** + * Reliability coverage for `failproofai audit` telemetry (cli_audit_*). + * + * The bug this guards: the started/completed/failed events were emitted + * fire-and-forget (`void trackHookEvent(...)`) and then the failed path calls + * die()->process.exit(1) and the empty-history path calls process.exit(0) + * immediately after — killing the in-flight fetch before it lands, so those + * events never reached PostHog. The fix awaits the two exit-adjacent events. + * + * These tests prove (a) each path emits its event, and (b) the exit-adjacent + * events are actually AWAITED — process.exit is observed to fire only after the + * event's promise has resolved. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { AuditResult } from "../../src/audit/types"; + +const resolvedEvents = new Set(); + +const h = vi.hoisted(() => ({ + trackHookEvent: vi.fn(), + runAudit: vi.fn(), + writeDashboardCache: vi.fn(() => true), + openWhenReady: vi.fn(), + launch: vi.fn(), +})); + +vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: h.trackHookEvent })); +vi.mock("../../src/audit/index", () => ({ runAudit: h.runAudit })); +vi.mock("../../src/audit/dashboard-cache", () => ({ writeDashboardCache: h.writeDashboardCache })); +vi.mock("../../src/audit/open-browser", () => ({ openWhenReady: h.openWhenReady })); +vi.mock("../../scripts/launch", () => ({ launch: h.launch })); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: () => "test-instance" })); + +import { runAuditCli } from "../../src/audit/cli"; + +function result(over: Partial): AuditResult { + return { + version: 2, + scannedAt: "2026-06-26T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 3, skipped: 0, errors: 0, durationMs: 0 }, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 100, + enabledBuiltinNames: [], + ...over, + }; +} + +let exitInfo: { code: number | undefined; resolvedAtExit: Set } | null; + +beforeEach(() => { + vi.clearAllMocks(); + resolvedEvents.clear(); + exitInfo = null; + // Exit-adjacent events resolve on a macrotask so we can prove the caller + // awaited them (the resolved-set is checked at process.exit time). Others + // resolve immediately. + h.trackHookEvent.mockImplementation((_id: string, name: string) => { + if (name === "cli_audit_completed" || name === "cli_audit_failed") { + return new Promise((res) => + setTimeout(() => { + resolvedEvents.add(name); + res(); + }, 5), + ); + } + return Promise.resolve(); + }); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + exitInfo = { code, resolvedAtExit: new Set(resolvedEvents) }; + throw new Error("__EXIT__"); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const names = () => h.trackHookEvent.mock.calls.map((c) => c[1] as string); + +describe("failproofai audit telemetry", () => { + it("emits cli_audit_started then cli_audit_completed and launches the dashboard (happy path)", async () => { + h.runAudit.mockResolvedValue(result({ eventsScanned: 100, totals: { hits: 2, projectsWithHits: 1 } })); + + await runAuditCli([]); + + expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]); + expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_completed", { + source: "cli", + events_scanned: 100, + sessions_scanned: 3, + total_hits: 2, + findings: 0, + }); + expect(h.launch).toHaveBeenCalledWith("start"); + expect(exitInfo).toBeNull(); // happy path never exits — launch() keeps the process alive + }); + + it("awaits cli_audit_completed before process.exit(0) on the empty-history path", async () => { + h.runAudit.mockResolvedValue(result({ eventsScanned: 0, transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 } })); + + await expect(runAuditCli([])).rejects.toThrow("__EXIT__"); + + expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]); + expect(exitInfo?.code).toBe(0); + // The fix: completed must have RESOLVED before the exit fired. + expect(exitInfo?.resolvedAtExit.has("cli_audit_completed")).toBe(true); + expect(h.launch).not.toHaveBeenCalled(); + }); + + it("awaits cli_audit_failed before die()/process.exit(1) when the scan throws", async () => { + h.runAudit.mockRejectedValue(new TypeError("disk exploded")); + + await expect(runAuditCli([])).rejects.toThrow("__EXIT__"); + + expect(names()).toEqual(["cli_audit_started", "cli_audit_failed"]); + expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_failed", { + source: "cli", + error_type: "TypeError", + }); + expect(exitInfo?.code).toBe(1); + // The fix: failed must have RESOLVED before the exit fired. + expect(exitInfo?.resolvedAtExit.has("cli_audit_failed")).toBe(true); + }); +}); diff --git a/__tests__/auth/auth-cli-telemetry.test.ts b/__tests__/auth/auth-cli-telemetry.test.ts new file mode 100644 index 00000000..bfa0a544 --- /dev/null +++ b/__tests__/auth/auth-cli-telemetry.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +/** + * Reliability coverage for `failproofai auth` telemetry. The auth CLI emitted + * its events fire-and-forget (`void trackHookEvent(...)`); since the process + * exits after the command returns, the terminal login/logout/whoami events + * raced the exit and were dropped. The fix awaits the exit-adjacent events. + * + * Proof technique: trackHookEvent resolves on a macrotask and records into + * `resolvedEvents`. After `await runAuthCli(...)`, an awaited event will already + * be in the set; a fire-and-forget (regressed) one will not. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const resolvedEvents = new Set(); +let promptResponses: string[] = []; + +const h = vi.hoisted(() => ({ + trackHookEvent: vi.fn(), + readAuth: vi.fn(), + deleteAuth: vi.fn(), + writeAuth: vi.fn(), + logoutSession: vi.fn(async () => {}), + requestLoginCode: vi.fn(), + verifyLoginCode: vi.fn(), +})); + +vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: h.trackHookEvent })); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: () => "test-instance" })); +vi.mock("../../lib/auth/auth-store", () => ({ + readAuth: h.readAuth, + deleteAuth: h.deleteAuth, + writeAuth: h.writeAuth, + authFromTokenResponse: (t: unknown) => t, +})); +vi.mock("../../lib/auth/api-server-client", () => ({ + logoutSession: h.logoutSession, + requestLoginCode: h.requestLoginCode, + verifyLoginCode: h.verifyLoginCode, + getApiBase: () => "https://api.test", + AuthApiError: class AuthApiError extends Error { + code: string; + status: number; + constructor(code: string, status: number, message: string) { + super(message); + this.code = code; + this.status = status; + } + }, +})); +vi.mock("node:readline", () => ({ + createInterface: () => ({ + question: (_q: string, cb: (a: string) => void) => cb(promptResponses.shift() ?? ""), + close: () => {}, + }), +})); + +import { runAuthCli } from "../../src/auth/cli"; + +const session = { user: { id: "u1", email: "a@b.com" }, refresh_expires_at: 9_999_999_999 }; +const names = () => h.trackHookEvent.mock.calls.map((c) => c[1] as string); + +beforeEach(() => { + vi.clearAllMocks(); + resolvedEvents.clear(); + promptResponses = []; + process.exitCode = 0; + h.trackHookEvent.mockImplementation( + (_id: string, name: string) => + new Promise((res) => + setTimeout(() => { + resolvedEvents.add(name); + res(); + }, 5), + ), + ); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = 0; +}); + +describe("failproofai auth telemetry (awaited before exit)", () => { + it("whoami (signed in) awaits audit_cli_auth_whoami", async () => { + h.readAuth.mockReturnValue(session); + await runAuthCli(["whoami"]); + expect(names()).toEqual(["audit_cli_auth_whoami"]); + expect(h.trackHookEvent).toHaveBeenCalledWith( + "test-instance", + "audit_cli_auth_whoami", + expect.objectContaining({ authenticated: true }), + ); + expect(resolvedEvents.has("audit_cli_auth_whoami")).toBe(true); + }); + + it("whoami (not signed in) awaits the event and sets exit code 1", async () => { + h.readAuth.mockReturnValue(null); + await runAuthCli(["whoami"]); + expect(h.trackHookEvent).toHaveBeenCalledWith( + "test-instance", + "audit_cli_auth_whoami", + expect.objectContaining({ authenticated: false }), + ); + expect(resolvedEvents.has("audit_cli_auth_whoami")).toBe(true); + expect(process.exitCode).toBe(1); + }); + + it("logout (with session) awaits audit_cli_auth_logout_completed and wipes auth", async () => { + h.readAuth.mockReturnValue(session); + await runAuthCli(["logout"]); + expect(names()).toContain("audit_cli_auth_logout_completed"); + expect(h.deleteAuth).toHaveBeenCalledTimes(1); + expect(resolvedEvents.has("audit_cli_auth_logout_completed")).toBe(true); + }); + + it("logout (no session) awaits the no-op event", async () => { + h.readAuth.mockReturnValue(null); + await runAuthCli(["logout"]); + expect(h.trackHookEvent).toHaveBeenCalledWith( + "test-instance", + "audit_cli_auth_logout_completed", + expect.objectContaining({ had_session: false }), + ); + expect(resolvedEvents.has("audit_cli_auth_logout_completed")).toBe(true); + }); + + it("login success awaits the terminal login_completed event", async () => { + h.readAuth.mockReturnValue(null); + promptResponses = ["a@b.com", "123456"]; + h.requestLoginCode.mockResolvedValue({ status: "sent", expires_in: 600, resend_available_in: 30 }); + h.verifyLoginCode.mockResolvedValue({ user: { id: "u1", email: "a@b.com" } }); + + await runAuthCli(["login"]); + + const emitted = names(); + expect(emitted).toContain("audit_cli_auth_login_started"); // fire-and-forget (mid-flow) + expect(emitted).toContain("audit_otp_verified"); + expect(emitted).toContain("audit_user_identity_linked"); + expect(emitted).toContain("audit_cli_auth_login_completed"); + expect(h.writeAuth).toHaveBeenCalledTimes(1); + // The terminal event (followed by return -> process exit) must be awaited. + expect(resolvedEvents.has("audit_cli_auth_login_completed")).toBe(true); + }); +}); diff --git a/__tests__/lib/client-telemetry.test.ts b/__tests__/lib/client-telemetry.test.ts index 53fa5725..1d9a76ae 100644 --- a/__tests__/lib/client-telemetry.test.ts +++ b/__tests__/lib/client-telemetry.test.ts @@ -54,6 +54,12 @@ describe("client-telemetry", () => { expect(body.properties.extra).toBe("prop"); }); + it("sets keepalive:true so events survive a page navigation/unload", () => { + setClientTelemetryConfig(enabledConfig); + captureClientEvent("$pageview"); + expect(fetchSpy.mock.calls[0][1].keepalive).toBe(true); + }); + it("includes $current_url and $pathname in properties", () => { setClientTelemetryConfig(enabledConfig); captureClientEvent("$pageview"); diff --git a/__tests__/scripts/postinstall.test.ts b/__tests__/scripts/postinstall.test.ts index 1dc77b21..ce0ae9eb 100644 --- a/__tests__/scripts/postinstall.test.ts +++ b/__tests__/scripts/postinstall.test.ts @@ -100,6 +100,23 @@ describe("postinstall script", () => { const allLogs = () => consoleLogSpy.mock.calls.map((c: unknown[]) => String(c[0] ?? "")).join("\n"); + describe("missing dashboard build (server.js absent)", () => { + it("tracks package_install_failed before exiting 1 (was silently invisible)", async () => { + const { existsSync } = await import("node:fs"); + vi.mocked(existsSync).mockReturnValue(false); // SERVER_JS missing + const { trackInstallEvent } = await import("../../scripts/install-telemetry.mjs"); + + await expect(import("../../scripts/postinstall.mjs")).rejects.toThrow("process.exit called"); + + expect(trackInstallEvent).toHaveBeenCalledWith( + "package_install_failed", + expect.objectContaining({ reason: "server_js_missing" }), + ); + expect(trackInstallEvent).not.toHaveBeenCalledWith("package_installed", expect.anything()); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + describe("brand-new user (no config, no settings)", () => { it("prints the Next steps block", async () => { await runPostinstall({ hooksConfigExists: false }); diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts index 2d5f47b9..28e69cbe 100644 --- a/app/api/audit/run/route.ts +++ b/app/api/audit/run/route.ts @@ -17,6 +17,7 @@ import { writeDashboardCache } from "@/src/audit/dashboard-cache"; import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; import type { RunAuditOptions } from "@/src/audit/types"; import { finishRun, tryAcquireRun } from "../_state"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; @@ -52,6 +53,11 @@ function sanitize(body: RunBody): RunAuditOptions { } export async function POST(request: NextRequest): Promise { + // initTelemetry never throws; init up front so every exit path (incl. the + // 400/409 rejections and the detached run task below) can report. The + // dashboard is a long-lived process, so trackEvent's background flush + // delivers even after this handler returns 202. + await initTelemetry(); let body: RunBody = {}; try { const raw = await request.text(); @@ -60,6 +66,7 @@ export async function POST(request: NextRequest): Promise { // JSON.parse("null") returns null and JSON.parse("[]") returns an // array — both pass the catch but break sanitize()'s field access. if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + trackEvent("audit_run_rejected", { source: "dashboard", reason: "non_object_body" }); return NextResponse.json( { error: "Request body must be a JSON object" }, { status: 400 }, @@ -68,18 +75,31 @@ export async function POST(request: NextRequest): Promise { body = parsed as RunBody; } } catch { + trackEvent("audit_run_rejected", { source: "dashboard", reason: "invalid_json" }); return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const opts = sanitize(body); if (!tryAcquireRun()) { + trackEvent("audit_run_rejected", { source: "dashboard", reason: "already_running" }); return NextResponse.json( { error: "Audit already running", status: "already-running" }, { status: 409 }, ); } + // Mirror the CLI's cli_audit_* funnel for the dashboard path (which shares the + // same runAudit() core but previously emitted no server telemetry at all). + trackEvent("audit_run_started", { + source: "dashboard", + since: opts.since ?? null, + no_cache: opts.noCache === true, + cli_count: opts.clis?.length ?? 0, + project_count: opts.projects?.length ?? 0, + }); + const startedAt = Date.now(); + // Fire-and-forget: a cold, all-history scan can run far longer than any HTTP // request should stay open — and longer than Node's `server.requestTimeout` // on the standalone production server. Start runAudit() as a detached task in @@ -93,8 +113,23 @@ export async function POST(request: NextRequest): Promise { // the client (the POST already returned 202), so a failed persist is a // failed run from the user's view — surface it instead of reporting OK. const persisted = writeDashboardCache(opts, result); + trackEvent("audit_run_completed", { + source: "dashboard", + duration_ms: Date.now() - startedAt, + events_scanned: result.eventsScanned, + sessions_scanned: result.transcripts.scanned, + projects_scanned: result.projectsScanned.length, + findings: result.results.length, + total_hits: result.totals.hits, + persisted, + }); finishRun(persisted ? null : "audit finished but its result could not be saved"); } catch (err) { + trackEvent("audit_run_failed", { + source: "dashboard", + duration_ms: Date.now() - startedAt, + error_type: err instanceof Error ? err.name : "unknown", + }); finishRun(err instanceof Error ? err.message : String(err)); } })(); diff --git a/app/api/auth/login-request/route.ts b/app/api/auth/login-request/route.ts index a4577b0b..5ea6f865 100644 --- a/app/api/auth/login-request/route.ts +++ b/app/api/auth/login-request/route.ts @@ -21,11 +21,11 @@ export async function POST(req: NextRequest): Promise { try { body = (await req.json()) as RequestBody; } catch { - trackEvent("audit_otp_requested", { status: "validation_error", reason: "invalid_json" }); + trackEvent("audit_otp_requested", { status: "validation_error", source: "dashboard", reason: "invalid_json" }); return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); } if (typeof body.email !== "string" || !body.email.trim()) { - trackEvent("audit_otp_requested", { status: "validation_error", reason: "missing_email" }); + trackEvent("audit_otp_requested", { status: "validation_error", source: "dashboard", reason: "missing_email" }); return NextResponse.json( { code: "validation_error", message: "email is required" }, { status: 400 }, diff --git a/app/api/auth/login-verify/route.ts b/app/api/auth/login-verify/route.ts index fd1736f7..61ce6f07 100644 --- a/app/api/auth/login-verify/route.ts +++ b/app/api/auth/login-verify/route.ts @@ -20,26 +20,32 @@ interface VerifyBody { } export async function POST(req: NextRequest): Promise { + // `initTelemetry` never throws — its internal try/catch is total. Init up + // front so the validation-400 paths below are tracked too, mirroring + // /api/auth/login-request. + await initTelemetry(); let body: VerifyBody = {}; try { body = (await req.json()) as VerifyBody; } catch { + trackEvent("audit_otp_verified", { status: "validation_error", source: "dashboard", reason: "invalid_json" }); return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); } if (typeof body.email !== "string" || !body.email.trim()) { + trackEvent("audit_otp_verified", { status: "validation_error", source: "dashboard", reason: "missing_email" }); return NextResponse.json( { code: "validation_error", message: "email is required" }, { status: 400 }, ); } if (typeof body.code !== "string" || !body.code.trim()) { + trackEvent("audit_otp_verified", { status: "validation_error", source: "dashboard", reason: "missing_code", email: body.email.trim().toLowerCase() }); return NextResponse.json( { code: "validation_error", message: "code is required" }, { status: 400 }, ); } - // `initTelemetry` never throws — its internal try/catch is total. - await initTelemetry(); + const email = body.email.trim().toLowerCase(); try { const tokens = await verifyLoginCode(body.email, body.code); writeAuth(authFromTokenResponse(tokens)); @@ -72,6 +78,7 @@ export async function POST(req: NextRequest): Promise { trackEvent("audit_otp_verified", { status: "failed", source: "dashboard", + email, error_code: err.code, http_status: err.status, }); @@ -87,6 +94,7 @@ export async function POST(req: NextRequest): Promise { trackEvent("audit_otp_verified", { status: "failed", source: "dashboard", + email, error_code: "upstream_unreachable", error_message: message.slice(0, 200), }); diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index 00cb096d..1b3bc5d0 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -104,7 +104,8 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr if (running) return; capture("audit_rerun_clicked", { source, since: "all" }); setRunning(true); - setRerunStatus({ kind: "running", startedAt: Date.now() }); + const startedAt = Date.now(); + setRerunStatus({ kind: "running", startedAt }); try { // noCache: an explicit re-audit bypasses the per-transcript cache and // re-scans from scratch — never a silent no-op that returns the identical @@ -114,6 +115,11 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr // fast cached path — it's a first scan, not a re-audit. await triggerRun({ cli: [], since: "all", noCache: true }); await refreshFromCache(); + capture("audit_rerun_succeeded", { + source, + since: "all", + duration_ms: Date.now() - startedAt, + }); setRerunStatus({ kind: "idle" }); } catch (err) { const kind = err instanceof RerunError ? err.kind : "network"; @@ -122,6 +128,7 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr source, since: "all", cli_filter: "all", + duration_ms: Date.now() - startedAt, }); setRerunStatus({ kind: "failed", reason: kind, failedAt: Date.now() }); } finally { diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 45237e72..8210c0c5 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -190,7 +190,7 @@ EXAMPLES LINKS ⭐ Star us: https://github.com/failproofai/failproofai - 📖 Docs: https://befailproof.ai + 📖 Docs: https://docs.befailproof.ai/introduction 💬 Discord: https://discord.gg/2zjBZP7yQJ `.trimStart()); process.exit(0); @@ -474,7 +474,11 @@ EXAMPLES lastSubcommand = "auth"; const { runAuthCli } = await import("../src/auth/cli"); await runAuthCli(args.slice(1)); - await track("cli_auth_invoked", { args_count: args.length - 1 }); + await track("cli_auth_invoked", { + args_count: args.length - 1, + subcommand: args[1] ?? "help", + exit_code: process.exitCode ?? 0, + }); process.exit(process.exitCode ?? 0); } diff --git a/components/reach-developers.tsx b/components/reach-developers.tsx index e151642a..a62d5a2a 100644 --- a/components/reach-developers.tsx +++ b/components/reach-developers.tsx @@ -18,7 +18,7 @@ const options = [ { label: "Documentation", icon: BookOpen, - href: "https://docs.befailproof.ai/", + href: "https://docs.befailproof.ai/introduction", color: "#60a5fa", bg: "rgba(96,165,250,0.08)", }, diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index da926d28..2e82ae4a 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -14,7 +14,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **الترجمات:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.de.md b/docs/i18n/README.de.md index 5042d475..1ca4697e 100644 --- a/docs/i18n/README.de.md +++ b/docs/i18n/README.de.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Übersetzungen:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index 8ba7eacf..a378a7de 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Traducciones:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index c3da4407..a6f7859d 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Traductions :** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.he.md b/docs/i18n/README.he.md index adaf73fb..4e6e1998 100644 --- a/docs/i18n/README.he.md +++ b/docs/i18n/README.he.md @@ -14,7 +14,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **תרגומים:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index 5e6dfbc2..b5312b9d 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **अनुवाद:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.it.md b/docs/i18n/README.it.md index b5d27fc7..dac68047 100644 --- a/docs/i18n/README.it.md +++ b/docs/i18n/README.it.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Traduzioni:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index 2bc537d2..948b4722 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **翻訳:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.ko.md b/docs/i18n/README.ko.md index eef6584e..80c9498f 100644 --- a/docs/i18n/README.ko.md +++ b/docs/i18n/README.ko.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **번역:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.pt-br.md b/docs/i18n/README.pt-br.md index 124aa8e2..6a6b6bfd 100644 --- a/docs/i18n/README.pt-br.md +++ b/docs/i18n/README.pt-br.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Traduções:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index d921b86e..44a12f89 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Переводы:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.tr.md b/docs/i18n/README.tr.md index 1a7c50c5..f61d093f 100644 --- a/docs/i18n/README.tr.md +++ b/docs/i18n/README.tr.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Çeviriler:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.vi.md b/docs/i18n/README.vi.md index aead6a3c..4ff04bc9 100644 --- a/docs/i18n/README.vi.md +++ b/docs/i18n/README.vi.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **Bản dịch:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 36d1e36c..304e5235 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -12,7 +12,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/failproofai/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/failproofai/failproofai/actions) [![Supply Chain](https://img.shields.io/badge/supply%20chain-secure-brightgreen?style=flat-square)](https://github.com/failproofai/failproofai/actions/workflows/osv-scanner.yml) [![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?style=flat-square&logo=discord)](https://discord.gg/2zjBZP7yQJ) -[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai) +[![Docs](https://img.shields.io/badge/docs-befailproof.ai-002CA7?style=flat-square)](https://docs.befailproof.ai/introduction) [![License](https://img.shields.io/badge/license-MIT%20%2B%20Commons%20Clause-blue?style=flat-square)](./LICENSE) **翻译版本:** [简体中文](./docs/i18n/README.zh.md) · [日本語](./docs/i18n/README.ja.md) · [한국어](./docs/i18n/README.ko.md) · [Español](./docs/i18n/README.es.md) · [Português](./docs/i18n/README.pt-br.md) · [Deutsch](./docs/i18n/README.de.md) · [Français](./docs/i18n/README.fr.md) · [Русский](./docs/i18n/README.ru.md) · [हिन्दी](./docs/i18n/README.hi.md) · [Türkçe](./docs/i18n/README.tr.md) · [Tiếng Việt](./docs/i18n/README.vi.md) · [Italiano](./docs/i18n/README.it.md) · [العربية](./docs/i18n/README.ar.md) · [עברית](./docs/i18n/README.he.md) diff --git a/lib/client-telemetry.ts b/lib/client-telemetry.ts index e396e057..d535a0a4 100644 --- a/lib/client-telemetry.ts +++ b/lib/client-telemetry.ts @@ -34,6 +34,10 @@ export function captureClientEvent( method: "POST", headers: { "Content-Type": "application/json" }, body: payload, + // keepalive lets the request outlive a page navigation/unload — without it, + // events fired right before the page goes away (client_error / unhandled_* + // from an error boundary, or a share click that opens a new tab) are dropped. + keepalive: true, signal: AbortSignal.timeout(5000), }).catch(() => {}); } diff --git a/scripts/launch.ts b/scripts/launch.ts index 363ee91a..df1a164d 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -20,7 +20,7 @@ export function launch(mode: "dev" | "start"): void { console.log(`\n failproof ai\n`); console.log(` 📦 Version: ${version}`); console.log(` ⭐ Star us: https://github.com/failproofai/failproofai`); - console.log(` 📖 Docs: https://befailproof.ai`); + console.log(` 📖 Docs: https://docs.befailproof.ai/introduction`); console.log(` 💬 Discord: https://discord.gg/2zjBZP7yQJ\n`); let cmd: string; diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 23bd43da..7cb0df8e 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -27,6 +27,14 @@ if (!existsSync(serverJsPath)) { ` The package may not have been built correctly.\n` + ` Try reinstalling: npm install -g failproofai@latest\n` ); + // Await so the event lands before process.exit(1) kills the in-flight fetch — + // a failed install is exactly the signal we most want and would otherwise lose. + await trackInstallEvent("package_install_failed", { + reason: "server_js_missing", + platform: platform(), + arch: arch(), + node_version: process.versions.node, + }).catch(() => {}); process.exit(1); } @@ -202,7 +210,7 @@ if (previousVersion === null) { arch: arch(), os_release: release(), node_version: process.versions.node, - version: currentVersion, + // `version` is carried automatically as `failproofai_version` — no explicit dup. }).catch(() => {}); } else { // Same version is a reinstall — still worth tracking; users hitting `npm install -g` @@ -227,6 +235,7 @@ trackInstallEvent("package_installed", { platform: platform(), arch: arch(), os_release: release(), + node_version: process.versions.node, hostname_hash: hashToId(hostname()), hooks_configured: hooksResult.configured, hooks_registered: hooksResult.registered, diff --git a/src/audit/cli.ts b/src/audit/cli.ts index 09d734d6..cc7f9dd2 100644 --- a/src/audit/cli.ts +++ b/src/audit/cli.ts @@ -254,6 +254,10 @@ export async function runAuditCli(args: string[]): Promise { } const instanceId = getInstanceId(); + // Fire-and-forget is safe for `started`: the multi-second audit below (and the + // awaited cli_audit_completed / cli_audit_failed) keep the process alive long + // enough for this fetch to land. Awaiting it would add up to a 5s pre-audit + // stall on a flaky network for no reliability gain. void trackHookEvent(instanceId, "cli_audit_started", { source: "cli" }); printHeader(); @@ -265,7 +269,10 @@ export async function runAuditCli(args: string[]): Promise { try { result = await runWithProgress(opts); } catch (err) { - void trackHookEvent(instanceId, "cli_audit_failed", { + // Await before die(): die() calls process.exit(1), which would kill an + // in-flight fire-and-forget fetch and drop this event. trackHookEvent is + // bounded (5s timeout) and never throws, so this can't hang or mask the error. + await trackHookEvent(instanceId, "cli_audit_failed", { source: "cli", error_type: err instanceof Error ? err.name : "unknown", }); @@ -274,7 +281,11 @@ export async function runAuditCli(args: string[]): Promise { printSummary(result); - void trackHookEvent(instanceId, "cli_audit_completed", { + // Await before the empty-history branch below, which calls process.exit(0) and + // would otherwise drop this event. On the dashboard path launch() keeps the + // process alive, but awaiting makes delivery reliable on every exit path. + // Bounded (5s) and never throws. + await trackHookEvent(instanceId, "cli_audit_completed", { source: "cli", events_scanned: result.eventsScanned, sessions_scanned: result.transcripts.scanned, diff --git a/src/auth/cli.ts b/src/auth/cli.ts index 5f097de9..46f81439 100644 --- a/src/auth/cli.ts +++ b/src/auth/cli.ts @@ -144,7 +144,7 @@ async function runLogin(): Promise { const nowSecs = Math.floor(Date.now() / 1000); const refreshUsable = existing.refresh_expires_at > nowSecs; if (refreshUsable) { - void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { source: "cli", status: "already_signed_in", user_id: existing.user.id, @@ -160,6 +160,8 @@ async function runLogin(): Promise { // Overwrite cleanly so a half-broken file doesn't survive next startup. deleteAuth(); } + // Fire-and-forget: the interactive `email:` / `code:` prompts that follow keep + // the process alive well past the 5s fetch, and awaiting would stall the prompt. void trackHookEvent(getInstanceId(), "audit_cli_auth_login_started", { source: "cli", api_base: getApiBase(), @@ -177,7 +179,7 @@ async function runLogin(): Promise { email = ""; } if (!email) { - void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { source: "cli", status: "aborted_invalid_email", }); @@ -186,6 +188,7 @@ async function runLogin(): Promise { try { const r = await requestLoginCode(email); + // Fire-and-forget: the `code:` prompt loop below keeps the process alive. void trackHookEvent(getInstanceId(), "audit_otp_requested", { source: "cli", status: "success", @@ -197,7 +200,7 @@ async function runLogin(): Promise { ); } catch (err) { const isApi = err instanceof AuthApiError; - void trackHookEvent(getInstanceId(), "audit_otp_requested", { + await trackHookEvent(getInstanceId(), "audit_otp_requested", { source: "cli", status: "failed", error_code: isApi ? err.code : "upstream_unreachable", @@ -228,7 +231,7 @@ async function runLogin(): Promise { break; } catch (err) { const isApi = err instanceof AuthApiError; - void trackHookEvent(getInstanceId(), "audit_otp_verified", { + await trackHookEvent(getInstanceId(), "audit_otp_verified", { source: "cli", status: "failed", attempt: verifyAttempts, @@ -248,7 +251,7 @@ async function runLogin(): Promise { } } if (!tokenResp) { - void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { source: "cli", status: "exhausted_attempts", attempts: verifyAttempts, @@ -257,7 +260,7 @@ async function runLogin(): Promise { } writeAuth(authFromTokenResponse(tokenResp)); - void trackHookEvent(getInstanceId(), "audit_otp_verified", { + await trackHookEvent(getInstanceId(), "audit_otp_verified", { source: "cli", status: "success", attempt: verifyAttempts, @@ -273,14 +276,14 @@ async function runLogin(): Promise { // `source: "audit_set_reminder_auth_dialog"`; this is the CLI sibling — // without it, anyone who signs in via `failproofai auth login` stays // unjoined to their pre-auth events. - void trackHookEvent(getInstanceId(), "audit_user_identity_linked", { + await trackHookEvent(getInstanceId(), "audit_user_identity_linked", { source: "cli", user_id: tokenResp.user.id, email: tokenResp.user.email, local_random_id: getInstanceId(), $set: { email: tokenResp.user.email, user_id: tokenResp.user.id }, }); - void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { source: "cli", status: "success", attempts: verifyAttempts, @@ -295,7 +298,7 @@ async function runLogin(): Promise { async function runLogout(): Promise { const existing = readAuth(); if (!existing) { - void trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { source: "cli", had_session: false, upstream: "noop", @@ -315,7 +318,7 @@ async function runLogout(): Promise { } } deleteAuth(); - void trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { source: "cli", had_session: true, upstream, @@ -326,10 +329,10 @@ async function runLogout(): Promise { ); } -function runWhoami(): void { +async function runWhoami(): Promise { const existing = readAuth(); if (!existing) { - void trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { source: "cli", authenticated: false, }); @@ -337,7 +340,7 @@ function runWhoami(): void { process.exitCode = 1; return; } - void trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { + await trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { source: "cli", authenticated: true, user_id: existing.user.id,