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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,17 @@ MAX_CONCURRENT_CONVERSATIONS=10 # Maximum concurrent AI conversations (default:

# Session Retention
# SESSION_RETENTION_DAYS=30 # Delete inactive sessions older than N days (default: 30)

# Anonymous Telemetry (optional)
# Archon sends anonymous workflow-invocation events to PostHog so maintainers
# can see which workflows get real usage. No PII — workflow name/description +
# platform + Archon version + a random install UUID. No identities, no prompts,
# no paths, no code. See README "Telemetry" for the full list.
#
# Opt out (any one disables telemetry):
# ARCHON_TELEMETRY_DISABLED=1
# DO_NOT_TRACK=1 (de facto standard)
#
# Point at a self-hosted PostHog or a different project:
# POSTHOG_API_KEY=phc_yourKeyHere
# POSTHOG_HOST=https://eu.i.posthog.com (default: https://us.i.posthog.com)
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,23 @@ Full documentation is available at **[archon.diy](https://archon.diy)**.
| [Architecture](https://archon.diy/reference/architecture/) | System design and internals |
| [Troubleshooting](https://archon.diy/reference/troubleshooting/) | Common issues and fixes |

## Telemetry

Archon sends a single anonymous event — `workflow_invoked` — each time a workflow starts, so maintainers can see which workflows get real usage and prioritize accordingly. **No PII, ever.**

**What's collected:** the workflow name, the workflow description (both authored by you in YAML), the platform that triggered it (`cli`, `web`, `slack`, etc.), the Archon version, and a random install UUID stored at `~/.archon/telemetry-id`. Nothing else.

**What's *not* collected:** your code, prompts, messages, git remotes, file paths, usernames, tokens, AI output, workflow node details — none of it.

**Opt out:** set any of these in your environment:

```bash
ARCHON_TELEMETRY_DISABLED=1
DO_NOT_TRACK=1 # de facto standard honored by Astro, Bun, Prisma, Nuxt, etc.
```

Self-host PostHog or use a different project by setting `POSTHOG_API_KEY` and `POSTHOG_HOST`.

## Contributing

Contributions welcome! See the open [issues](https://github.com/coleam00/Archon/issues) for things to work on.
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

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

4 changes: 4 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
checkForUpdate,
BUNDLED_IS_BINARY,
BUNDLED_VERSION,
shutdownTelemetry,
} from '@archon/paths';
import * as git from '@archon/git';

Expand Down Expand Up @@ -573,6 +574,9 @@ async function main(): Promise<number> {
}
return 1;
} finally {
// Flush queued telemetry events before the CLI process exits.
// Short-lived CLI commands lose buffered events if shutdown() is skipped.
await shutdownTelemetry();
// Always close database connection
await closeDb();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/paths/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"dependencies": {
"dotenv": "^17",
"pino": "^9",
"pino-pretty": "^13"
"pino-pretty": "^13",
"posthog-node": "^5.29.2"
},
"peerDependencies": {
"typescript": "^5.0.0"
Expand Down
4 changes: 4 additions & 0 deletions packages/paths/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ export {
parseLatestRelease,
} from './update-check';
export type { UpdateCheckResult } from './update-check';

// Anonymous telemetry
export { captureWorkflowInvoked, shutdownTelemetry, isTelemetryDisabled } from './telemetry';
export type { WorkflowInvokedProperties } from './telemetry';
151 changes: 151 additions & 0 deletions packages/paths/src/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';

import {
isTelemetryDisabled,
captureWorkflowInvoked,
shutdownTelemetry,
resetTelemetryForTests,
getOrCreateTelemetryId,
} from './telemetry';

const ENV_VARS = [
'ARCHON_HOME',
'ARCHON_TELEMETRY_DISABLED',
'DO_NOT_TRACK',
'POSTHOG_API_KEY',
'POSTHOG_HOST',
];

function saveEnv(): Record<string, string | undefined> {
const saved: Record<string, string | undefined> = {};
for (const key of ENV_VARS) saved[key] = process.env[key];
return saved;
}

function restoreEnv(saved: Record<string, string | undefined>): void {
for (const key of ENV_VARS) {
if (saved[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = saved[key];
}
}
}

describe('telemetry opt-out detection', () => {
let saved: Record<string, string | undefined>;

beforeEach(() => {
saved = saveEnv();
resetTelemetryForTests();
});

afterEach(() => {
restoreEnv(saved);
resetTelemetryForTests();
});

test('enabled by default when no opt-out env vars set', () => {
delete process.env.ARCHON_TELEMETRY_DISABLED;
delete process.env.DO_NOT_TRACK;
delete process.env.POSTHOG_API_KEY;
expect(isTelemetryDisabled()).toBe(false);
});

test('ARCHON_TELEMETRY_DISABLED=1 disables telemetry', () => {
process.env.ARCHON_TELEMETRY_DISABLED = '1';
expect(isTelemetryDisabled()).toBe(true);
});

test('DO_NOT_TRACK=1 disables telemetry', () => {
process.env.DO_NOT_TRACK = '1';
expect(isTelemetryDisabled()).toBe(true);
});

test('ARCHON_TELEMETRY_DISABLED=0 does not disable (strict "1" match)', () => {
process.env.ARCHON_TELEMETRY_DISABLED = '0';
delete process.env.DO_NOT_TRACK;
expect(isTelemetryDisabled()).toBe(false);
});

test('empty POSTHOG_API_KEY override disables telemetry', () => {
process.env.POSTHOG_API_KEY = '';
delete process.env.ARCHON_TELEMETRY_DISABLED;
delete process.env.DO_NOT_TRACK;
expect(isTelemetryDisabled()).toBe(true);
});
});

describe('captureWorkflowInvoked when disabled', () => {
let saved: Record<string, string | undefined>;

beforeEach(() => {
saved = saveEnv();
resetTelemetryForTests();
process.env.ARCHON_TELEMETRY_DISABLED = '1';
});

afterEach(() => {
restoreEnv(saved);
resetTelemetryForTests();
});

test('does not throw when telemetry is disabled', () => {
expect(() => {
captureWorkflowInvoked({
workflowName: 'test-workflow',
workflowDescription: 'A test',
platform: 'cli',
archonVersion: 'dev',
});
}).not.toThrow();
});

test('shutdownTelemetry is a no-op when never initialized', async () => {
await expect(shutdownTelemetry()).resolves.toBeUndefined();
});
});

describe('telemetry ID persistence', () => {
let saved: Record<string, string | undefined>;
let tmpHome: string;

beforeEach(() => {
saved = saveEnv();
tmpHome = mkdtempSync(join(tmpdir(), 'archon-telemetry-test-'));
process.env.ARCHON_HOME = tmpHome;
// Force-disable actual network capture — we only exercise the ID path.
process.env.ARCHON_TELEMETRY_DISABLED = '1';
resetTelemetryForTests();
});

afterEach(() => {
restoreEnv(saved);
resetTelemetryForTests();
rmSync(tmpHome, { recursive: true, force: true });
});

test('calling capture while disabled does not create a telemetry-id file', () => {
captureWorkflowInvoked({ workflowName: 'w' });
expect(existsSync(join(tmpHome, 'telemetry-id'))).toBe(false);
});

test('an existing telemetry-id file is preserved (not overwritten)', async () => {
const { writeFileSync, mkdirSync } = await import('fs');
const existingId = '11111111-1111-4111-8111-111111111111';
mkdirSync(tmpHome, { recursive: true });
writeFileSync(join(tmpHome, 'telemetry-id'), existingId, 'utf8');

resetTelemetryForTests();

// Direct, synchronous call — no network, no fire-and-forget, no timer.
const resolved = getOrCreateTelemetryId();

expect(resolved).toBe(existingId);
const stored = readFileSync(join(tmpHome, 'telemetry-id'), 'utf8').trim();
expect(stored).toBe(existingId);
});
});
Loading
Loading