diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 49f3b603..e4503c4f 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -18,6 +18,7 @@ export class TelemetryClient { private readonly version: string; private readonly sessionId: string; private state: TelemetryState | null = null; + private flushInterval: ReturnType | null = null; constructor(config: TelemetryConfig, stateFactory: () => TelemetryState, version: string) { this.config = config; @@ -68,6 +69,24 @@ export class TelemetryClient { } } + startPeriodicFlush(intervalMs: number = 60_000): void { + if (this.flushInterval) return; + this.flushInterval = setInterval(() => { + void this.flush(); + }, intervalMs); + // Allow the process to exit even if the interval is still active + if (typeof this.flushInterval === "object" && "unref" in this.flushInterval) { + this.flushInterval.unref(); + } + } + + stop(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + this.flushInterval = null; + } + } + hashPrivateRef(value: string): string { const state = this.getState(); return createHash("sha256") diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts new file mode 100644 index 00000000..b057ef9d --- /dev/null +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { TelemetryClient } from "../../../packages/shared/src/telemetry/client.js"; +import type { TelemetryConfig, TelemetryState } from "../../../packages/shared/src/telemetry/types.js"; + +function makeClient(config?: Partial) { + const merged: TelemetryConfig = { enabled: true, endpoint: "http://localhost:9999/ingest", ...config }; + const state: TelemetryState = { + installId: "test-install", + salt: "test-salt", + createdAt: "2026-01-01T00:00:00Z", + firstSeenVersion: "0.0.0", + }; + return new TelemetryClient(merged, () => state, "0.0.0-test"); +} + +describe("TelemetryClient periodic flush", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("flushes queued events on interval", async () => { + const client = makeClient(); + client.startPeriodicFlush(1000); + + client.track("install.started"); + expect(fetch).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + expect(fetch).toHaveBeenCalledTimes(1); + + // Second tick with no new events — no additional call + await vi.advanceTimersByTimeAsync(1000); + expect(fetch).toHaveBeenCalledTimes(1); + + // New event gets flushed on next tick + client.track("install.started"); + await vi.advanceTimersByTimeAsync(1000); + expect(fetch).toHaveBeenCalledTimes(2); + + client.stop(); + }); + + it("stop() prevents further flushes", async () => { + const client = makeClient(); + client.startPeriodicFlush(1000); + + client.track("install.started"); + client.stop(); + + await vi.advanceTimersByTimeAsync(2000); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("startPeriodicFlush is idempotent", () => { + const client = makeClient(); + client.startPeriodicFlush(1000); + client.startPeriodicFlush(1000); // should not throw or double-fire + client.stop(); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 2fcbaf17..37318245 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -39,7 +39,7 @@ import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; -import { initTelemetry } from "./telemetry.js"; +import { initTelemetry, getTelemetryClient } from "./telemetry.js"; type BetterAuthSessionUser = { id: string; @@ -728,18 +728,26 @@ export async function startServer(): Promise { }); }); - if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { + { const shutdown = async (signal: "SIGINT" | "SIGTERM") => { - logger.info({ signal }, "Stopping embedded PostgreSQL"); - try { - await embeddedPostgres?.stop(); - } catch (err) { - logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); - } finally { - process.exit(0); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + telemetryClient.stop(); + await telemetryClient.flush(); } + + if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { + logger.info({ signal }, "Stopping embedded PostgreSQL"); + try { + await embeddedPostgres?.stop(); + } catch (err) { + logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); + } + } + + process.exit(0); }; - + process.once("SIGINT", () => { void shutdown("SIGINT"); }); diff --git a/server/src/telemetry.ts b/server/src/telemetry.ts index f5823a01..eb2b2874 100644 --- a/server/src/telemetry.ts +++ b/server/src/telemetry.ts @@ -21,6 +21,7 @@ export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClie () => loadOrCreateState(stateDir, serverVersion), serverVersion, ); + client.startPeriodicFlush(60_000); return client; }