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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ build never reaches a production installer.
| `INSTA_PROJECT_ID` · `INSTA_ORG_ID` · `INSTA_BRANCH` | Target a project, org or branch without linking |
| `INSTA_PASSWORD` | Password for non-interactive login |
| `INSTA_NO_AUTOUPDATE` | Disable self-update |
| `INSTA_NO_TELEMETRY` · `DO_NOT_TRACK` | Disable usage analytics. Each command sends one event (command, flags, outcome, version, OS) to the same PostHog project as the console. Only ids, enums and numbers among the arguments are kept — names, branches, keys, paths, secret values, free text and error messages never leave the machine; custom API hosts report nothing |
| `INSTA_NO_TELEMETRY` · `DO_NOT_TRACK` | Disable usage analytics. Each command sends one event (command, flags, outcome, version, OS) to the same PostHog project as the console. Only ids, enums and numbers among the arguments are kept — names, branches, keys, paths, secret values, free text and error messages never leave the machine; custom API hosts report nothing. Before you sign in, events carry a random id stored in `~/.insta/telemetry.json`; the first command after you sign in sends one extra event merging that id into your account, and the first command after the session ends (logout, `env use`) retires it |

## Agent skills

Expand Down
32 changes: 24 additions & 8 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const PROJECT_KEYS: Record<EnvName, string> = {
}
// The send is awaited before the process exits, so it gets one bounded attempt and no retry.
const SEND_TIMEOUT_MS = 1500
const ID_FILE = join(os.homedir(), '.insta', 'telemetry.json')
const REDACTED = '[REDACTED]'

// Only ids, enums and numbers leave the machine, and only when the value has that shape; every other
Expand Down Expand Up @@ -170,6 +171,7 @@ export function buildCommandEvent(
auth_kind: token ? (token.startsWith('insta_') ? 'api_key' : 'session') : null,
project_id: ctx.project?.projectId ?? null,
org_id: ctx.project?.orgId ?? null,
...(ctx.project ? { $groups: { project: ctx.project.projectId, ...(ID(ctx.project.orgId) ? { org: ctx.project.orgId } : {}) } } : {}),
tty: ctx.tty,
ci: !!ctx.env.CI,
agent: detectAgent(ctx.env),
Expand All @@ -181,17 +183,23 @@ export function buildCommandEvent(
}
}

export async function anonymousId(file = join(os.homedir(), '.insta', 'telemetry.json')): Promise<string> {
/** The pre-login id events are sent under, and the account it has been merged into, if any. */
export type TelemetryState = { anonymousId: string; identifiedAs?: string }

export async function readState(file = ID_FILE): Promise<TelemetryState> {
try {
const parsed = JSON.parse(await readFile(file, 'utf8')) as { anonymousId?: unknown }
if (typeof parsed.anonymousId === 'string' && parsed.anonymousId) return parsed.anonymousId
const parsed = JSON.parse(await readFile(file, 'utf8')) as TelemetryState
if (typeof parsed.anonymousId === 'string' && parsed.anonymousId) return parsed
} catch {}
const id = randomUUID()
return writeState({ anonymousId: randomUUID() }, file)
}

async function writeState(state: TelemetryState, file = ID_FILE): Promise<TelemetryState> {
try {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, JSON.stringify({ anonymousId: id }, null, 2))
await writeFile(file, JSON.stringify(state, null, 2))
} catch {}
return id
return state
}

export async function sendBatch(key: string, batch: object[], fetchImpl: typeof fetch = fetch): Promise<boolean> {
Expand Down Expand Up @@ -232,15 +240,23 @@ export async function trackCommand(cmd: Command, args: unknown[], outcome: Outco
const key = telemetryKey(config.apiUrl)
if (!key) return
const project = await (deps.loadProject ?? readProject)()
const userId = config.user?.id
let state = await readState(deps.idFile)
// Session gone or switched: the id belongs to the account that left, so later commands get a fresh one.
if (state.identifiedAs && state.identifiedAs !== userId) state = await writeState({ anonymousId: randomUUID() }, deps.idFile)
const event = buildCommandEvent(command, args.flat(), cmd.opts(), outcome, {
cliVersion,
channel: deps.channel ?? detectChannel(),
config,
project,
anonymousId: await anonymousId(deps.idFile),
anonymousId: state.anonymousId,
env,
tty: deps.tty ?? !!process.stdout.isTTY,
})
await sendBatch(key, [event], deps.fetchImpl)
const batch: object[] = [event]
const merge = userId !== undefined && state.identifiedAs !== userId
if (merge) batch.push({ event: '$identify', distinct_id: userId, timestamp: event.timestamp, properties: { $anon_distinct_id: state.anonymousId } })
const sent = await sendBatch(key, batch, deps.fetchImpl)
if (merge && sent) await writeState({ ...state, identifiedAs: userId }, deps.idFile)
} catch {}
}
44 changes: 38 additions & 6 deletions test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'
import { ApiError } from '../src/api.js'
import type { GlobalConfig } from '../src/config.js'
import {
anonymousId, buildCommandEvent, commandPath, detectAgent, redactArgs, redactOptions,
buildCommandEvent, commandPath, detectAgent, readState, redactArgs, redactOptions,
sendBatch, telemetryDisabled, telemetryKey, trackCommand, POSTHOG_HOST,
} from '../src/telemetry.js'
import { CliCancel, die } from '../src/util.js'
Expand Down Expand Up @@ -131,9 +131,12 @@ describe('buildCommandEvent', () => {
expect(e.properties).toMatchObject({
command: 'deploy', args: ['[REDACTED]'], options: { json: true }, success: false, cancelled: false, exit_code: 2,
duration_ms: 900, env: 'prod', api_host: 'api.instacloud.com', logged_in: true, auth_kind: 'api_key',
project_id: 'p1', org_id: 'o1', ci: true, agent: 'claude-code', cli_version: '1.2.3', channel: 'npm',
project_id: 'p1', org_id: 'o1', $groups: { org: 'o1', project: 'p1' }, ci: true, agent: 'claude-code', cli_version: '1.2.3', channel: 'npm',
})
expect(e.properties).not.toHaveProperty('branch')
expect(buildCommandEvent('org list', [], {}, { durationMs: 1, exitCode: 0 }, ctx(loggedIn)).properties).not.toHaveProperty('$groups')
const envOnly = buildCommandEvent('deploy', [], {}, { durationMs: 1, exitCode: 0 }, ctx(loggedIn, { project: { projectId: 'p1', orgId: '', branch: 'main' } }))
expect(envOnly.properties.$groups).toEqual({ project: 'p1' })
const s = buildCommandEvent('status', [], {}, { durationMs: 1, exitCode: 0 }, ctx({ apiUrl: CUSTOM, accessToken: 'eyJsession' }))
expect(s.properties).toMatchObject({ env: 'custom', api_host: 'localhost:4800', auth_kind: 'session', success: true })
expect(buildCommandEvent('status', [], {}, { durationMs: 1, exitCode: 0 }, ctx(anon)).properties.auth_kind).toBeNull()
Expand Down Expand Up @@ -176,12 +179,12 @@ describe('buildCommandEvent', () => {
})
})

describe('anonymousId', () => {
it('mints once and reuses', async () => {
describe('readState', () => {
it('mints an anonymous id once and reuses it', async () => {
const file = join(await mkdtemp(join(tmpdir(), 'insta-telemetry-')), 'nested', 'telemetry.json')
const first = await anonymousId(file)
const { anonymousId: first } = await readState(file)
expect(first).toMatch(/^[0-9a-f-]{36}$/)
expect(await anonymousId(file)).toBe(first)
expect(await readState(file)).toEqual({ anonymousId: first })
expect(JSON.parse(await readFile(file, 'utf8'))).toEqual({ anonymousId: first })
})
})
Expand Down Expand Up @@ -256,6 +259,35 @@ describe('trackCommand', () => {
expect(same.calls[0]!.body.batch[0].distinct_id).toBe('user_1')
})

it('merges the anonymous id into whichever session appears, once, and retires it when the session goes', async () => {
const idFile = join(await mkdtemp(join(tmpdir(), 'insta-telemetry-')), 'telemetry.json')
const run = async (config: GlobalConfig, name: string, status = 200) => {
const d = { ...(await deps(config)), ...fakeFetch(status), idFile }
await trackCommand(new Command('insta').command(name), [], { durationMs: 1, exitCode: 0 }, '1.0.0', d)
return d.calls[0]!.body.batch as Array<{ event: string; distinct_id: string; properties: Record<string, unknown> }>
}
const { anonymousId: first } = await readState(idFile)
expect((await run(anon, 'org list')).map((e) => e.event)).toEqual(['cli_command'])

// PostHog never got the merge: keep offering it until it lands.
expect((await run(loggedIn, 'setup agent', 500)).map((e) => e.event)).toEqual(['cli_command', '$identify'])
expect(await readState(idFile)).toEqual({ anonymousId: first })
const merged = await run(loggedIn, 'status')
expect(merged[1]).toMatchObject({ event: '$identify', distinct_id: 'user_1', properties: { $anon_distinct_id: first } })
expect(await readState(idFile)).toEqual({ anonymousId: first, identifiedAs: 'user_1' })
expect((await run(loggedIn, 'org list')).map((e) => e.event)).toEqual(['cli_command'])

// another account signs in over this one: a fresh id is merged, never the one user_1 already owns
const switched = await run({ ...loggedIn, user: { id: 'user_2', email: null, name: null } }, 'login')
expect(switched[1]!.properties.$anon_distinct_id).not.toBe(first)
expect(await readState(idFile)).toEqual({ anonymousId: switched[1]!.properties.$anon_distinct_id, identifiedAs: 'user_2' })

// logout or `env use`: the next command runs under a fresh id
const [after] = await run({ apiUrl: PROD }, 'env use')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(after!.distinct_id).not.toBe(switched[1]!.properties.$anon_distinct_id)
expect(await readState(idFile)).toEqual({ anonymousId: after!.distinct_id })
})

it('never throws, even when the config cannot be read', async () => {
const d = { ...(await deps(loggedIn)), loadConfig: async () => { throw new Error('unknown INSTA_ENV') } }
const { leaf } = tree()
Expand Down
Loading