diff --git a/.gitignore b/.gitignore index 87013b82..73de0673 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ kubo/ dist/ .vite/ *.car +*.tgz test-output/ test-car-output/ test-e2e-cars/ diff --git a/README.md b/README.md index c6e39b58..4f9d2448 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ PRIVATE_KEY=0x... # Ethereum private key with USDFC tokens # Optional RPC_URL=wss://... # Filecoin RPC endpoint (default: Calibration testnet) +FILECOIN_PIN_TELEMETRY_ENDPOINT=https://... # Override telemetry endpoint for testing # Optional for Pinning Server Daemon PORT=3456 # Daemon server port @@ -201,6 +202,66 @@ npm run test:integration # Integration tests npm run lint:fix # Fix formatting ``` +## Privacy and Telemetry + +Filecoin Pin collects minimal anonymous usage data to help us understand adoption and improve the tool. + +### What We Collect + +On the first run of the CLI, we collect: +- **Anonymous identifier**: A randomly generated UUID (not linked to any personal information) +- **Package version**: The version of filecoin-pin you're using +- **Platform**: Your operating system (e.g., darwin, linux, win32) +- **Timestamp**: When the CLI was first run + +### What We Don't Collect + +- No personally identifiable information +- No file names, contents, or CIDs +- No wallet addresses or private keys +- No command arguments or flags +- No network or provider information + +### How to Opt Out + +**Recommended:** Use the `--private` flag to permanently disable telemetry: + +```bash +filecoin-pin --private +``` + +This creates a config file at `~/.filecoin-pin/config.json` that persists your privacy preference. All future runs will respect this setting. + +**Alternative:** Set an environment variable: + +```bash +export FILECOIN_PIN_TELEMETRY_DISABLED=1 +filecoin-pin add myfile.txt +``` + +Or add it to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.): + +```bash +echo 'export FILECOIN_PIN_TELEMETRY_DISABLED=1' >> ~/.bashrc +``` + +### Data Usage + +Telemetry data is used solely to: +- Measure unique installations and active usage +- Understand which versions are in use +- Inform development priorities and platform support + +The data is collected once on first run and helps us demonstrate real-world adoption to stakeholders and contributors. + +### Industry Standard Practice + +This telemetry approach follows common npm ecosystem practices: +- **npm itself** collects anonymous usage data during package installation ([npm Privacy Policy](https://docs.npmjs.com/policies/privacy/)) +- Similar to [@nuxt/telemetry](https://www.npmjs.com/package/@nuxt/telemetry), [@ibm/telemetry-js](https://www.npmjs.com/package/@ibm/telemetry-js), and other community packages +- All data is anonymized and used solely for measuring adoption and improving the tool +- Easy opt-out mechanism follows ecosystem conventions + ## Community and Support ### Contributing diff --git a/src/cli.ts b/src/cli.ts index 33f0dcc9..85170366 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { dataSetCommand } from './commands/data-set.js' import { importCommand } from './commands/import.js' import { paymentsCommand } from './commands/payments.js' import { serverCommand } from './commands/server.js' +import { trackFirstRun } from './core/telemetry.js' // Get package.json for version const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -20,6 +21,9 @@ const program = new Command() .description('IPFS Pinning Service with Filecoin storage via Synapse SDK') .version(packageJson.version) .option('-v, --verbose', 'verbose output') + .option('--private', 'disable telemetry collection (persists to config)') + .option('--test', 'mark telemetry as test data (internal use)') + .exitOverride() // Prevent auto-exit so telemetry can complete // Add subcommands program.addCommand(serverCommand) @@ -33,8 +37,22 @@ program.action(() => { program.help() }) -// Parse arguments and run -program.parseAsync(process.argv).catch((error) => { - console.error('Error:', error.message) - process.exit(1) +// Parse arguments first to get options +let parsedOptions: { private?: boolean; test?: boolean } = {} +try { + await program.parseAsync(process.argv) + parsedOptions = program.opts() +} catch (error) { + // Commander throws on help/version with exitOverride, ignore those + if (error instanceof Error && error.message !== '(outputHelp)' && error.message !== '(version)') { + console.error('Error:', error.message) + process.exit(1) + } + parsedOptions = program.opts() +} + +// Track first run for telemetry (non-blocking) +trackFirstRun(packageJson.version, { + isPrivate: parsedOptions.private || false, + isTest: parsedOptions.test || false, }) diff --git a/src/core/telemetry-config.ts b/src/core/telemetry-config.ts new file mode 100644 index 00000000..d6a81127 --- /dev/null +++ b/src/core/telemetry-config.ts @@ -0,0 +1,61 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const CONFIG_DIR = join(homedir(), '.filecoin-pin') +const CONFIG_FILE = join(CONFIG_DIR, 'config.json') + +interface TelemetryConfig { + telemetry?: { + disabled?: boolean + } +} + +/** + * Read telemetry config from file + */ +export function readTelemetryConfig(): TelemetryConfig { + try { + if (!existsSync(CONFIG_FILE)) { + return {} + } + const content = readFileSync(CONFIG_FILE, 'utf-8') + return JSON.parse(content) + } catch (error) { + // If config is corrupted or unreadable, return empty config + return {} + } +} + +/** + * Write telemetry config to file + */ +export function writeTelemetryConfig(config: TelemetryConfig): void { + try { + // Create config directory if it doesn't exist + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }) + } + + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8') + } catch (error) { + // Fail silently - telemetry config is not critical + } +} + +/** + * Check if telemetry is disabled in config + */ +export function isTelemetryDisabledInConfig(): boolean { + const config = readTelemetryConfig() + return config.telemetry?.disabled === true +} + +/** + * Disable telemetry in config (persists to disk) + */ +export function disableTelemetryInConfig(): void { + const config = readTelemetryConfig() + config.telemetry = { disabled: true } + writeTelemetryConfig(config) +} diff --git a/src/core/telemetry.ts b/src/core/telemetry.ts new file mode 100644 index 00000000..ed2111fa --- /dev/null +++ b/src/core/telemetry.ts @@ -0,0 +1,129 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { isTelemetryDisabledInConfig, disableTelemetryInConfig } from './telemetry-config.js' + +const TELEMETRY_ENDPOINT = process.env.FILECOIN_PIN_TELEMETRY_ENDPOINT || 'https://eomwm816g3v5sar.m.pipedream.net' +const CONFIG_DIR = join(homedir(), '.filecoin-pin') +const TELEMETRY_ID_FILE = join(CONFIG_DIR, '.telemetry-id') +const REQUEST_TIMEOUT = 5000 // 5 seconds + +interface TelemetryPayload { + event: string + anonymousId: string + version: string + platform: string + timestamp: string + testMode?: string +} + +interface TrackingOptions { + isPrivate: boolean + isTest: boolean +} + +/** + * Check if telemetry is disabled via environment variable + */ +function isTelemetryDisabled(): boolean { + return process.env.FILECOIN_PIN_TELEMETRY_DISABLED === '1' +} + +/** + * Get or create anonymous telemetry ID + */ +function getOrCreateTelemetryId(): { id: string; isFirstRun: boolean } { + try { + // Create config directory if it doesn't exist + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }) + } + + // Check if telemetry ID file exists + if (existsSync(TELEMETRY_ID_FILE)) { + const id = readFileSync(TELEMETRY_ID_FILE, 'utf-8').trim() + return { id, isFirstRun: false } + } + + // First run - generate new UUID + const id = randomUUID() + writeFileSync(TELEMETRY_ID_FILE, id, 'utf-8') + return { id, isFirstRun: true } + } catch (error) { + // Fail silently if we can't access filesystem + return { id: 'unknown', isFirstRun: false } + } +} + +/** + * Send telemetry event to endpoint + */ +async function sendTelemetryEvent(payload: TelemetryPayload): Promise { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) + + await fetch(TELEMETRY_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + + clearTimeout(timeoutId) + } catch (error) { + // Fail silently - telemetry should never block CLI functionality + } +} + +/** + * Track CLI first run event + * This function is non-blocking and will not throw errors + */ +export function trackFirstRun(version: string, options?: TrackingOptions): void { + // Don't await - fire and forget + void (async () => { + try { + // If --private flag is used, save to config and exit + if (options?.isPrivate) { + disableTelemetryInConfig() + return + } + + // Check opt-out via config file, then environment variable + if (isTelemetryDisabledInConfig() || isTelemetryDisabled()) { + return + } + + // Get or create telemetry ID + const { id, isFirstRun } = getOrCreateTelemetryId() + + // Only send event on first run + if (!isFirstRun) { + return + } + + // Prepare payload + const payload: TelemetryPayload = { + event: 'cli_first_run', + anonymousId: id, + version, + platform: process.platform, + timestamp: new Date().toISOString(), + } + + // Add testMode flag if --test is used + if (options?.isTest) { + payload.testMode = 'test' + } + + // Send telemetry + await sendTelemetryEvent(payload) + } catch (error) { + // Fail silently - telemetry should never block CLI functionality + } + })() +} diff --git a/src/test/unit/telemetry.test.ts b/src/test/unit/telemetry.test.ts new file mode 100644 index 00000000..b91ed2e2 --- /dev/null +++ b/src/test/unit/telemetry.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import * as os from 'node:os' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +// Mock os.homedir +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: vi.fn(), + } +}) + +describe('Telemetry', () => { + let testDir: string + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + // Create temporary test directory + testDir = join(tmpdir(), `filecoin-pin-test-${randomUUID()}`) + mkdirSync(testDir, { recursive: true }) + + // Save original environment + originalEnv = { ...process.env } + + // Mock homedir to use test directory + vi.mocked(os.homedir).mockReturnValue(testDir) + + // Reset fetch mock + mockFetch.mockReset() + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + } as Response) + + // Reset module cache to ensure fresh imports + vi.resetModules() + }) + + afterEach(() => { + // Restore environment + process.env = originalEnv + + // Clean up test directory + try { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }) + } + } catch (error) { + // Ignore cleanup errors + } + }) + + it('should create telemetry ID file on first run', async () => { + // Import module fresh for each test + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3') + + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 100)) + + const telemetryFile = join(testDir, '.filecoin-pin', '.telemetry-id') + expect(existsSync(telemetryFile)).toBe(true) + + const telemetryId = readFileSync(telemetryFile, 'utf-8').trim() + expect(telemetryId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + }) + + it('should send telemetry event on first run', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3') + + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'https://eomwm816g3v5sar.m.pipedream.net', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + ) + + const callArgs = mockFetch.mock.calls[0] + if (!callArgs) throw new Error('Expected fetch to be called') + const body = JSON.parse(callArgs[1].body) + + expect(body).toMatchObject({ + event: 'cli_first_run', + version: '0.7.3', + platform: process.platform, + }) + expect(body.anonymousId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + + it('should not send telemetry when disabled via environment variable', async () => { + process.env.FILECOIN_PIN_TELEMETRY_DISABLED = '1' + + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3') + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should handle function call without throwing', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + // Should not throw even if network fails + expect(() => trackFirstRun('0.7.3')).not.toThrow() + }) + + it('should not send telemetry on subsequent runs', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + // First run + trackFirstRun('0.7.3') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockFetch).toHaveBeenCalledTimes(1) + + // Reset mock + mockFetch.mockClear() + + // Second run - should not send + trackFirstRun('0.7.3') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should save config when --private flag is used', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3', { isPrivate: true, isTest: false }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should not send telemetry + expect(mockFetch).not.toHaveBeenCalled() + + // Should create config file + const configFile = join(testDir, '.filecoin-pin', 'config.json') + expect(existsSync(configFile)).toBe(true) + + // Config should have telemetry disabled + const config = JSON.parse(readFileSync(configFile, 'utf-8')) + expect(config).toEqual({ + telemetry: { + disabled: true, + }, + }) + }) + + it('should include testMode when --test flag is used', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3', { isPrivate: false, isTest: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockFetch).toHaveBeenCalledTimes(1) + + const callArgs = mockFetch.mock.calls[0] + if (!callArgs) throw new Error('Expected fetch to be called') + const body = JSON.parse(callArgs[1].body) + + expect(body.testMode).toBe('test') + }) + + it('should not include testMode when --test flag is not used', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3', { isPrivate: false, isTest: false }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockFetch).toHaveBeenCalledTimes(1) + + const callArgs = mockFetch.mock.calls[0] + if (!callArgs) throw new Error('Expected fetch to be called') + const body = JSON.parse(callArgs[1].body) + + expect(body.testMode).toBeUndefined() + }) + + it('should respect config file opt-out', async () => { + // Create config file with telemetry disabled + const configDir = join(testDir, '.filecoin-pin') + const configFile = join(configDir, 'config.json') + mkdirSync(configDir, { recursive: true }) + const config = { telemetry: { disabled: true } } + require('node:fs').writeFileSync(configFile, JSON.stringify(config), 'utf-8') + + const { trackFirstRun } = await import('../../core/telemetry.js') + + trackFirstRun('0.7.3') + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should not send telemetry + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should persist --private opt-out across runs', async () => { + const { trackFirstRun } = await import('../../core/telemetry.js') + + // First run with --private + trackFirstRun('0.7.3', { isPrivate: true, isTest: false }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockFetch).not.toHaveBeenCalled() + + // Second run without flag - should still be opted out + trackFirstRun('0.7.3') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockFetch).not.toHaveBeenCalled() + }) +})