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
5 changes: 5 additions & 0 deletions .changeset/telemetry-without-posthog-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Telemetry no longer depends on `posthog-node`: the single usage event is sent with a plain fetch to the same endpoint. Installing OpenSpec no longer pulls the fast-publishing `posthog-node`/`@posthog/core`/`@posthog/types` tree, which broke downstream installs under supply-chain age policies like pnpm's `minimumReleaseAge` (#1390).
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_9;
fetcherVersion = 3;
hash = "sha256-z9NIWAY1KODgALBML1bBFpM2K9N7Z4L9jFBJC/t+Mww=";
hash = "sha256-AHPKWjhrk4aTJvp9uqTJk15vASEZyRUoSw0W9oV2650=";
};

nativeBuildInputs = with pkgs; [
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@
"cross-spawn": "7.0.6",
"fast-glob": "^3.3.3",
"ora": "^9.4.1",
"posthog-node": "^5.46.0",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
Expand Down
28 changes: 0 additions & 28 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 59 additions & 34 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@
* - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
* - Auto-disabled in CI environments
* - Anonymous ID is a random UUID with no relation to the user
*
* Events are sent with a plain fetch to PostHog's stable public `/batch/`
* endpoint — the same one posthog-node used — instead of through the SDK.
* The SDK's only remaining job here was the wire format: every reliability
* knob was already forced to "send one event immediately, time-bounded,
* never retry, never throw". Carrying `posthog-node` for that shipped its
* fast-moving transitive tree (`@posthog/core`, `@posthog/types`, multiple
* releases per day) to every downstream consumer, where supply-chain age
* policies such as pnpm's `minimumReleaseAge` rejected the freshly published
* versions and broke installs (#1390).
*/
import { PostHog } from 'posthog-node';
import { randomUUID } from 'crypto';
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';

Expand All @@ -19,12 +28,25 @@ const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
const POSTHOG_HOST = 'https://edge.openspec.dev';
const TELEMETRY_REQUEST_TIMEOUT_MS = 1000;

let posthogClient: PostHog | null = null;
let anonymousId: string | null = null;

/**
* Requests started by trackCommand and not yet settled, so shutdown can
* flush them before the process exits. Each request is individually
* time-bounded, so awaiting them cannot stall exit for more than the
* request timeout.
*/
const pendingEvents = new Set<Promise<void>>();

async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Response> {
try {
const response = await fetch(url, options);
// Telemetry never reads the body, but undici keeps the connection
// occupied until the body is consumed or canceled — dispose of it on
// every path so no socket outlives shutdown().
if (response.body) {
await response.body.cancel();
}
if (response.ok) {
return response;
}
Expand Down Expand Up @@ -86,24 +108,32 @@ export async function getOrCreateAnonymousId(): Promise<string> {
}

/**
* Get the PostHog client instance.
* Creates it on first call with CLI-optimized settings.
* Send one capture event to PostHog's batch endpoint. Fire-and-forget:
* bounded by the request timeout, never throws, never retries.
*/
function getClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 1, // Send immediately, don't batch
flushInterval: 0, // No timer-based flushing
fetchRetryCount: 0,
requestTimeout: TELEMETRY_REQUEST_TIMEOUT_MS,
preloadFeatureFlags: false,
disableRemoteConfig: true,
disableSurveys: true,
fetch: safeTelemetryFetch,
});
}
return posthogClient;
function sendEvent(distinctId: string, event: string, properties: Record<string, unknown>): void {
const body = JSON.stringify({
api_key: POSTHOG_API_KEY,
batch: [
{
type: 'capture',
event,
distinct_id: distinctId,
properties,
timestamp: new Date().toISOString(),
},
],
});

const request = safeTelemetryFetch(`${POSTHOG_HOST}/batch/`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
signal: AbortSignal.timeout(TELEMETRY_REQUEST_TIMEOUT_MS),
}).then(() => undefined);

pendingEvents.add(request);
void request.finally(() => pendingEvents.delete(request));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand All @@ -119,17 +149,12 @@ export async function trackCommand(commandName: string, version: string): Promis

try {
const userId = await getOrCreateAnonymousId();
const client = getClient();

client.capture({
distinctId: userId,
event: 'command_executed',
properties: {
command: commandName,
version: version,
surface: 'cli',
$ip: null, // Explicitly disable IP tracking
},

sendEvent(userId, 'command_executed', {
command: commandName,
version: version,
surface: 'cli',
$ip: null, // Explicitly disable IP tracking
});
} catch {
// Silent failure - telemetry should never break CLI
Expand Down Expand Up @@ -163,19 +188,19 @@ export async function maybeShowTelemetryNotice(): Promise<void> {
}

/**
* Shutdown the PostHog client and flush pending events.
* Flush pending telemetry events.
* Call this before CLI exit.
*/
export async function shutdown(): Promise<void> {
if (!posthogClient) {
if (pendingEvents.size === 0) {
return;
}

try {
await posthogClient.shutdown();
await Promise.allSettled([...pendingEvents]);
} catch {
// Silent failure - telemetry should never break CLI exit
} finally {
posthogClient = null;
pendingEvents.clear();
}
}
Loading
Loading