Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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';
163 changes: 163 additions & 0 deletions packages/paths/src/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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,
} 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 () => {
// Simulate a prior run by writing a UUID and enabling telemetry so the
// lazy init would read it. We still keep capture disabled network-wise
// via a bogus POSTHOG_API_KEY so the PostHog client never actually dials
// out — but we re-enable the telemetry path so getTelemetryId runs.
const { writeFileSync, mkdirSync } = await import('fs');
const existingId = '11111111-1111-4111-8111-111111111111';
mkdirSync(tmpHome, { recursive: true });
writeFileSync(join(tmpHome, 'telemetry-id'), existingId, 'utf8');

delete process.env.ARCHON_TELEMETRY_DISABLED;
resetTelemetryForTests();

// Force capture path to run: disable via empty POSTHOG_API_KEY? No — that
// early-returns. Instead we leave the embedded key; capture() fires
// fire-and-forget but only the telemetry-id read matters for this assertion.
// The assertion is simply that the file content is unchanged afterwards.
captureWorkflowInvoked({ workflowName: 'w' });

// Give the async capture a moment to run its file read.
await new Promise(resolve => setTimeout(resolve, 20));

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

// Clean up any in-flight client
await shutdownTelemetry();
});
});
Loading
Loading