Skip to content

Commit bfcd6f5

Browse files
committed
feat(cli): track agent usage and deprioritize Neon
1 parent 31ae444 commit bfcd6f5

5 files changed

Lines changed: 199 additions & 26 deletions

File tree

.changeset/friendly-eels-divide.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/cli': minor
3+
'@tanstack/create': patch
4+
---
5+
6+
Add anonymous CLI telemetry with command and step tracking, a hidden `--agent` flag for agent-originated invocations, first-run disclosure, and opt-out controls via config, env vars, and `tanstack telemetry` commands.
7+
8+
Deprioritize the Neon add-on in create flows without removing support for the add-on itself.

packages/cli/src/cli.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'node:fs'
22
import { resolve } from 'node:path'
3-
import { Command, InvalidArgumentError } from 'commander'
3+
import { Command, InvalidArgumentError, Option } from 'commander'
44
import { cancel, confirm, intro, isCancel, log } from '@clack/prompts'
55
import chalk from 'chalk'
66
import semver from 'semver'
@@ -85,6 +85,26 @@ function sanitizeIdList(values: Array<string>) {
8585
)
8686
}
8787

88+
const AGENT_FLAG = '--agent'
89+
90+
function addHiddenAgentFlag<T extends Command>(cmd: T) {
91+
if (cmd.options.some((option) => option.long === AGENT_FLAG)) {
92+
return cmd
93+
}
94+
95+
cmd.addOption(
96+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
97+
)
98+
99+
return cmd
100+
}
101+
102+
function getInvocationTelemetryProperties() {
103+
return {
104+
invoked_by_agent: process.argv.includes(AGENT_FLAG),
105+
}
106+
}
107+
88108
function getStarterTelemetryProperties(value?: string) {
89109
if (!value) {
90110
return {}
@@ -417,6 +437,7 @@ export function cli({
417437
const startedAt = Date.now()
418438
currentTelemetry = telemetry
419439
telemetry.captureCommandStarted(command, {
440+
...getInvocationTelemetryProperties(),
420441
...opts.properties,
421442
cli_version: VERSION,
422443
})
@@ -438,6 +459,8 @@ export function cli({
438459
.description(`${appName} CLI`)
439460
.version(VERSION, '-v, --version', 'output the current version')
440461

462+
addHiddenAgentFlag(program)
463+
441464
// Helper to create the create command action handler
442465
async function handleCreate(projectName: string, options: CliOptions) {
443466
try {
@@ -710,6 +733,7 @@ export function cli({
710733

711734
// Helper to configure create command options
712735
function configureCreateCommand(cmd: Command) {
736+
addHiddenAgentFlag(cmd)
713737
cmd.argument('[project-name]', 'name of the project')
714738

715739
if (!defaultFramework) {
@@ -917,6 +941,9 @@ export function cli({
917941
program
918942
.command('libraries')
919943
.description('List TanStack libraries')
944+
.addOption(
945+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
946+
)
920947
.option(
921948
'--group <group>',
922949
`filter by group (${LIBRARY_GROUPS.join(', ')})`,
@@ -991,6 +1018,9 @@ export function cli({
9911018
program
9921019
.command('doc')
9931020
.description('Fetch a TanStack documentation page')
1021+
.addOption(
1022+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1023+
)
9941024
.argument('<library>', 'library ID (eg. query, router, table)')
9951025
.argument('<path>', 'documentation path (eg. framework/react/overview)')
9961026
.option('--docs-version <version>', 'docs version (default: latest)', 'latest')
@@ -1098,6 +1128,9 @@ export function cli({
10981128
program
10991129
.command('search-docs')
11001130
.description('Search TanStack documentation')
1131+
.addOption(
1132+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1133+
)
11011134
.argument('<query>', 'search query')
11021135
.option('--library <id>', 'filter to specific library')
11031136
.option('--framework <name>', 'filter to specific framework')
@@ -1164,6 +1197,9 @@ export function cli({
11641197
program
11651198
.command('ecosystem')
11661199
.description('List TanStack ecosystem partners')
1200+
.addOption(
1201+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1202+
)
11671203
.option('--category <category>', 'filter by category')
11681204
.option('--library <id>', 'filter by TanStack library')
11691205
.option('--json', 'output JSON for automation', false)
@@ -1251,6 +1287,9 @@ export function cli({
12511287
program
12521288
.command('pin-versions')
12531289
.description('Pin versions of the TanStack libraries')
1290+
.addOption(
1291+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1292+
)
12541293
.action(async () => {
12551294
try {
12561295
await runWithTelemetry('pin-versions', {}, async (telemetry) => {
@@ -1333,6 +1372,9 @@ Remove your node_modules directory and package lock file and re-install.`,
13331372
telemetryCommand
13341373
.command('status')
13351374
.description('Show anonymous telemetry status')
1375+
.addOption(
1376+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1377+
)
13361378
.option('--json', 'output JSON for automation', false)
13371379
.action(async (options: { json: boolean }) => {
13381380
const status = await getTelemetryStatus({ createIfMissing: true })
@@ -1359,6 +1401,9 @@ Remove your node_modules directory and package lock file and re-install.`,
13591401
telemetryCommand
13601402
.command('enable')
13611403
.description('Enable anonymous telemetry')
1404+
.addOption(
1405+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1406+
)
13621407
.action(async () => {
13631408
await setTelemetryEnabled(true)
13641409
console.log('Anonymous telemetry enabled')
@@ -1367,6 +1412,9 @@ Remove your node_modules directory and package lock file and re-install.`,
13671412
telemetryCommand
13681413
.command('disable')
13691414
.description('Disable anonymous telemetry')
1415+
.addOption(
1416+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1417+
)
13701418
.action(async () => {
13711419
await setTelemetryEnabled(false)
13721420
console.log('Anonymous telemetry disabled')
@@ -1375,6 +1423,9 @@ Remove your node_modules directory and package lock file and re-install.`,
13751423
// === ADD SUBCOMMAND ===
13761424
program
13771425
.command('add')
1426+
.addOption(
1427+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1428+
)
13781429
.argument(
13791430
'[add-on...]',
13801431
'Name of the add-ons (or add-ons separated by spaces or commas)',
@@ -1437,6 +1488,9 @@ Remove your node_modules directory and package lock file and re-install.`,
14371488
addOnCommand
14381489
.command('init')
14391490
.description('Initialize an add-on from the current project')
1491+
.addOption(
1492+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1493+
)
14401494
.action(async () => {
14411495
try {
14421496
await runWithTelemetry('add-on:init', {}, async () => {
@@ -1450,6 +1504,9 @@ Remove your node_modules directory and package lock file and re-install.`,
14501504
addOnCommand
14511505
.command('compile')
14521506
.description('Update add-on from the current project')
1507+
.addOption(
1508+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1509+
)
14531510
.action(async () => {
14541511
try {
14551512
await runWithTelemetry('add-on:compile', {}, async () => {
@@ -1465,6 +1522,9 @@ Remove your node_modules directory and package lock file and re-install.`,
14651522
.description(
14661523
'Watch project files and continuously refresh .add-on and add-on.json',
14671524
)
1525+
.addOption(
1526+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1527+
)
14681528
.action(async () => {
14691529
try {
14701530
await runWithTelemetry('add-on:dev', {}, async () => {
@@ -1481,6 +1541,9 @@ Remove your node_modules directory and package lock file and re-install.`,
14811541
templateCommand
14821542
.command('init')
14831543
.description('Initialize a project template from the current project')
1544+
.addOption(
1545+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1546+
)
14841547
.action(async () => {
14851548
try {
14861549
await runWithTelemetry('template:init', {}, async () => {
@@ -1494,6 +1557,9 @@ Remove your node_modules directory and package lock file and re-install.`,
14941557
templateCommand
14951558
.command('compile')
14961559
.description('Compile the template JSON file for the current project')
1560+
.addOption(
1561+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1562+
)
14971563
.action(async () => {
14981564
try {
14991565
await runWithTelemetry('template:compile', {}, async () => {
@@ -1510,6 +1576,9 @@ Remove your node_modules directory and package lock file and re-install.`,
15101576
starterCommand
15111577
.command('init')
15121578
.description('Deprecated alias: initialize a project template')
1579+
.addOption(
1580+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1581+
)
15131582
.action(async () => {
15141583
try {
15151584
await runWithTelemetry('starter:init', {}, async () => {
@@ -1523,6 +1592,9 @@ Remove your node_modules directory and package lock file and re-install.`,
15231592
starterCommand
15241593
.command('compile')
15251594
.description('Deprecated alias: compile the template JSON file')
1595+
.addOption(
1596+
new Option(AGENT_FLAG, 'internal: invocation originated from an agent').hideHelp(),
1597+
)
15261598
.action(async () => {
15271599
try {
15281600
await runWithTelemetry('starter:compile', {}, async () => {

packages/cli/src/telemetry.ts

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ interface PendingStep {
2121
type: StatusStepType
2222
}
2323

24-
const POSTHOG_API_HOST = 'https://us.i.posthog.com'
25-
const POSTHOG_CAPTURE_ENDPOINT = `${POSTHOG_API_HOST}/capture/`
26-
const POSTHOG_PROJECT_TOKEN = 'phc_xJ2VBahJBzy3BShLhuGpw7EyoSuQtgwXXvhE9BYtHuKQ'
24+
const TELEMETRY_TRANSPORT_ENDPOINT = 'https://www.google-analytics.com/g/collect'
25+
const TELEMETRY_PROPERTY_ID = 'G-JMT1Z50SPS'
2726
const TELEMETRY_NOTICE =
2827
'TanStack CLI sends anonymous usage telemetry by default. It never sends project names, paths, raw search text, template URLs, add-on config values, or raw error messages. Disable it with `tanstack telemetry disable` or `TANSTACK_CLI_TELEMETRY_DISABLED=1`.'
2928
const TELEMETRY_TIMEOUT_MS = 1200
29+
const TELEMETRY_VALUE_MAX_LENGTH = 500
30+
const TELEMETRY_NUMERIC_PREFIX = 'epn.'
31+
const TELEMETRY_STRING_PREFIX = 'ep.'
3032

3133
let telemetryStatusPromise: Promise<Awaited<ReturnType<typeof getTelemetryStatus>>> | undefined
3234

@@ -56,6 +58,75 @@ function cleanProperties(value: unknown): unknown {
5658
return value
5759
}
5860

61+
function truncateValue(value: string) {
62+
return value.length > TELEMETRY_VALUE_MAX_LENGTH
63+
? `${value.slice(0, TELEMETRY_VALUE_MAX_LENGTH - 1)}…`
64+
: value
65+
}
66+
67+
function normalizeParamKey(key: string) {
68+
const normalized = key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+/, '')
69+
const prefixed = /^[a-zA-Z]/.test(normalized)
70+
? normalized
71+
: `p_${normalized || 'value'}`
72+
73+
return prefixed.slice(0, 40)
74+
}
75+
76+
function normalizeParamValue(value: unknown): number | string | undefined {
77+
if (value === undefined || value === null) {
78+
return undefined
79+
}
80+
81+
if (typeof value === 'boolean') {
82+
return value ? 1 : 0
83+
}
84+
85+
if (typeof value === 'number') {
86+
return Number.isFinite(value) ? value : undefined
87+
}
88+
89+
if (typeof value === 'string') {
90+
return truncateValue(value)
91+
}
92+
93+
const cleaned = cleanProperties(value)
94+
if (cleaned === undefined) {
95+
return undefined
96+
}
97+
98+
return truncateValue(JSON.stringify(cleaned))
99+
}
100+
101+
function createTelemetryRequestBody(
102+
event: string,
103+
distinctId: string,
104+
properties: TelemetryProperties,
105+
) {
106+
const params = new URLSearchParams({
107+
cid: distinctId,
108+
en: event,
109+
tid: TELEMETRY_PROPERTY_ID,
110+
v: '2',
111+
})
112+
113+
for (const [key, value] of Object.entries(properties)) {
114+
const normalizedValue = normalizeParamValue(value)
115+
if (normalizedValue === undefined) {
116+
continue
117+
}
118+
119+
const normalizedKey = normalizeParamKey(key)
120+
const paramName =
121+
typeof normalizedValue === 'number'
122+
? `${TELEMETRY_NUMERIC_PREFIX}${normalizedKey}`
123+
: `${TELEMETRY_STRING_PREFIX}${normalizedKey}`
124+
params.append(paramName, String(normalizedValue))
125+
}
126+
127+
return params.toString()
128+
}
129+
59130
function getErrorCode(error: unknown) {
60131
if (!error || typeof error !== 'object') {
61132
return 'unknown_error'
@@ -106,17 +177,12 @@ async function postEvent(event: string, distinctId: string, properties: Telemetr
106177
}, TELEMETRY_TIMEOUT_MS)
107178

108179
try {
109-
await fetch(POSTHOG_CAPTURE_ENDPOINT, {
180+
await fetch(TELEMETRY_TRANSPORT_ENDPOINT, {
110181
method: 'POST',
111182
headers: {
112-
'Content-Type': 'application/json',
183+
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
113184
},
114-
body: JSON.stringify({
115-
api_key: POSTHOG_PROJECT_TOKEN,
116-
distinct_id: distinctId,
117-
event,
118-
properties,
119-
}),
185+
body: createTelemetryRequestBody(event, distinctId, properties),
120186
signal: controller.signal,
121187
})
122188
} catch {
@@ -235,7 +301,7 @@ export class TelemetryClient {
235301

236302
private baseProperties() {
237303
return {
238-
$lib: 'tanstack-cli',
304+
client_lib: 'tanstack-cli',
239305
disabled_by: this.disabledBy,
240306
node_major: getNodeMajorVersion(),
241307
os_arch: process.arch,

0 commit comments

Comments
 (0)