Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ kubo/
dist/
.vite/
*.car
*.tgz
test-output/
test-car-output/
test-e2e-cars/
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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,
})
61 changes: 61 additions & 0 deletions src/core/telemetry-config.ts
Original file line number Diff line number Diff line change
@@ -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)
}
129 changes: 129 additions & 0 deletions src/core/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
})()
}
Loading
Loading