Skip to content

Commit 3dafafd

Browse files
authored
feat(analytics): attribute requests by client surface and add CLI usage telemetry (#7763)
* feat(analytics): attribute requests by client surface and add CLI usage telemetry * fix(analytics): tolerate get-only header readers, gate CLI notice on stderr, re-read telemetry state before send
1 parent b4f214c commit 3dafafd

95 files changed

Lines changed: 3000 additions & 236 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish-sim-cli.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ jobs:
6363

6464
- name: Build package
6565
working-directory: packages/sim-cli
66+
env:
67+
# Public PostHog project token for anonymous CLI usage reporting; a
68+
# build without it reports nothing. See docs/cli/usage-data.
69+
SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }}
70+
SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }}
6671
run: bun run build
6772

6873
- name: Resolve release channel
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
// client-info pulls in @/main/navigation, which imports electron.
4+
vi.mock('electron', () => import('@/test/electron-mock'))
5+
6+
import { attachClientInfo, desktopClientInfo } from '@/main/client-info'
7+
8+
type BeforeSendHeadersHandler = (
9+
details: { url: string; requestHeaders: Record<string, string> },
10+
callback: (response: { requestHeaders?: Record<string, string> }) => void
11+
) => void
12+
13+
function fakeSession() {
14+
let handler: BeforeSendHeadersHandler | undefined
15+
const ses = {
16+
webRequest: {
17+
onBeforeSendHeaders: vi.fn((h: BeforeSendHeadersHandler) => {
18+
handler = h
19+
}),
20+
},
21+
}
22+
return { ses, run: () => handler }
23+
}
24+
25+
const APP_ORIGIN = 'https://sim.ai'
26+
const CLIENT_INFO = desktopClientInfo()
27+
28+
describe('desktopClientInfo', () => {
29+
it('names the shell, its runtime, and the platform', () => {
30+
expect(desktopClientInfo()).toMatch(
31+
new RegExp(
32+
`^desktop/1\\.0\\.0(; electron/[^;]+)?; os/${process.platform}; arch/${process.arch}$`
33+
)
34+
)
35+
})
36+
})
37+
38+
describe('attachClientInfo', () => {
39+
let session: ReturnType<typeof fakeSession>
40+
41+
beforeEach(() => {
42+
session = fakeSession()
43+
attachClientInfo(
44+
session.ses as unknown as Parameters<typeof attachClientInfo>[0],
45+
() => APP_ORIGIN
46+
)
47+
})
48+
49+
it('stamps the shell identity on an app-origin request', () => {
50+
const cb = vi.fn()
51+
session.run()?.(
52+
{ url: `${APP_ORIGIN}/api/workflows`, requestHeaders: { Accept: 'application/json' } },
53+
cb
54+
)
55+
expect(cb).toHaveBeenCalledWith({
56+
requestHeaders: { Accept: 'application/json', 'x-sim-client-info': CLIENT_INFO },
57+
})
58+
})
59+
60+
it('overwrites the web value the page sent, whatever its casing', () => {
61+
const cb = vi.fn()
62+
session.run()?.(
63+
{ url: `${APP_ORIGIN}/api/workflows`, requestHeaders: { 'X-Sim-Client-Info': 'web' } },
64+
cb
65+
)
66+
expect(cb).toHaveBeenCalledWith({
67+
requestHeaders: { 'x-sim-client-info': CLIENT_INFO },
68+
})
69+
})
70+
71+
it('leaves requests to other origins untouched', () => {
72+
const cb = vi.fn()
73+
session.run()?.(
74+
{ url: 'https://accounts.google.com/o/oauth2', requestHeaders: { Accept: '*/*' } },
75+
cb
76+
)
77+
expect(cb).toHaveBeenCalledWith({})
78+
})
79+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { CLIENT_INFO_HEADER, formatClientInfo } from '@sim/utils/client-info'
2+
import type { Session } from 'electron'
3+
import { app } from 'electron'
4+
import { isAppOrigin } from '@/main/navigation'
5+
6+
/**
7+
* The `X-Sim-Client-Info` value naming this shell: its version, the Electron
8+
* it runs on, and the platform. Computed once per process — none of it changes
9+
* while the app is running.
10+
*/
11+
export function desktopClientInfo(): string {
12+
const electron = process.versions.electron
13+
return formatClientInfo({
14+
surface: 'desktop',
15+
version: app.getVersion(),
16+
...(electron ? { runtime: { name: 'electron', version: electron } } : {}),
17+
os: process.platform,
18+
arch: process.arch,
19+
})
20+
}
21+
22+
/**
23+
* Stamps the shell's identity on every request to the app origin.
24+
*
25+
* The page derives the same value from the preload bridge, so this is the
26+
* backstop for what the page never issues itself — raw `fetch` exceptions,
27+
* service-worker traffic, sub-resources — and for a bundle older than the
28+
* bridge field. Only the network layer sees every request. The shell's value
29+
* overwrites whatever the page sent; the shell is authoritative about being
30+
* the shell. Requests to other origins are left untouched.
31+
*
32+
* This is the only `onBeforeSendHeaders` consumer — Electron allows a single
33+
* listener per session.
34+
*/
35+
export function attachClientInfo(ses: Session, appOrigin: () => string): void {
36+
const clientInfo = desktopClientInfo()
37+
ses.webRequest.onBeforeSendHeaders((details, callback) => {
38+
if (!isAppOrigin(details.url, appOrigin())) {
39+
callback({})
40+
return
41+
}
42+
const requestHeaders: Record<string, string> = {}
43+
for (const [name, value] of Object.entries(details.requestHeaders)) {
44+
if (name.toLowerCase() !== CLIENT_INFO_HEADER) requestHeaders[name] = value
45+
}
46+
requestHeaders[CLIENT_INFO_HEADER] = clientInfo
47+
callback({ requestHeaders })
48+
})
49+
}

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
setBrowserAppearanceTheme as setAgentBrowserTheme,
3636
setPanelFocused as setBrowserAgentPanelFocused,
3737
} from '@/main/browser-agent/session'
38+
import { attachClientInfo } from '@/main/client-info'
3839
import {
3940
APP_NAME_FOR_CHANNEL,
4041
channelForOrigin,
@@ -265,6 +266,7 @@ function main(): void {
265266
setupPermissionHandlers(ses, appOrigin)
266267
attachLocalPageProtocol(ses)
267268
attachCspFallback(ses, appOrigin)
269+
attachClientInfo(ses, appOrigin)
268270
attachDownloadHandling(ses, events)
269271
attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true)
270272
ses.setSpellCheckerLanguages(['en-US'])

apps/docs/content/docs/cli/commands.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ These apply to every command, and may be written before or after it.
3131
| Group | Description |
3232
| --- | --- |
3333
| [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login |
34+
| [`sim telemetry`](/cli/telemetry) | Control anonymous usage reporting |
3435
| [`sim audit-logs`](/cli/audit-logs) | Manage audit logs |
3536
| [`sim billing`](/cli/billing) | Manage billing |
3637
| [`sim blocks`](/cli/blocks) | Manage blocks |

apps/docs/content/docs/cli/configuration.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ endpoint or stored login.
121121
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely. Defaults to `3600`, above every timeout the server itself applies |
122122
| `SIM_DEBUG` | Trace each request's method, URL, status and duration to stderr |
123123
| `SIM_NO_UPDATE_CHECK` | Turn off update checks and notices |
124+
| `SIM_TELEMETRY_DISABLED` | Turn off anonymous usage reporting; see [usage data](/cli/usage-data) |
124125

125126
## Updates
126127

apps/docs/content/docs/cli/meta.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
"scripting",
1111
"workflow-sync",
1212
"troubleshooting",
13+
"usage-data",
1314
"---Commands---",
1415
"commands",
1516
"profiles",
17+
"telemetry",
1618
"audit-logs",
1719
"billing",
1820
"blocks",

apps/docs/content/docs/cli/reference.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,32 @@ sim profiles add <name> [options]
189189

190190
</CommandTable>
191191

192+
## sim telemetry
193+
194+
### sim telemetry status
195+
196+
Show whether usage reporting is on, and why not if it is off
197+
198+
```bash
199+
sim telemetry status
200+
```
201+
202+
### sim telemetry enable
203+
204+
Turn usage reporting on for this machine
205+
206+
```bash
207+
sim telemetry enable
208+
```
209+
210+
### sim telemetry disable
211+
212+
Turn usage reporting off for this machine
213+
214+
```bash
215+
sim telemetry disable
216+
```
217+
192218
## sim audit-logs
193219

194220
Also spelled `sim audit-log`.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: Telemetry
3+
description: Control anonymous usage reporting — every subcommand, argument, and flag
4+
---
5+
6+
import { CommandTable } from '@/components/ui/command-table'
7+
8+
Every command below also accepts the [global options](/cli/commands#global-options).
9+
10+
## Show whether usage reporting is on, and why not if it is off
11+
12+
```bash
13+
sim telemetry status
14+
```
15+
16+
## Turn usage reporting on for this machine
17+
18+
```bash
19+
sim telemetry enable
20+
```
21+
22+
## Turn usage reporting off for this machine
23+
24+
```bash
25+
sim telemetry disable
26+
```
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
title: Usage data
3+
description: What the CLI reports about how it is used, what it never sends, and how to turn reporting off
4+
---
5+
6+
The `sim` CLI reports anonymous usage data so the team can see which commands
7+
are used, which fail, and how long they take. Reporting is on by default and
8+
takes one command to turn off.
9+
10+
## What is sent
11+
12+
One event per command, after the command finishes:
13+
14+
| Field | Example | Notes |
15+
| --- | --- | --- |
16+
| Command | `workflows list` | The command's name, never its arguments |
17+
| Flags | `--output`, `--workspace` | Flag names only, never their values |
18+
| Argument count | `1` | How many positional arguments, never what they were |
19+
| Outcome | exit code `0`, `SimApiError`, HTTP `404`, API code `NOT_FOUND` | Never an error message |
20+
| Duration | `1432` ms | From process start to completion |
21+
| CLI, Node, OS, CPU | `2.1.2`, `22.14.0`, `darwin`, `arm64` | |
22+
| Terminal and CI | `is_tty`, `is_ci` | Whether stdout is a terminal, whether a CI variable is set |
23+
| Coding agent | `claude-code` | When the CLI runs inside an AI coding agent's shell |
24+
| Deployment kind | `hosted` or `self_hosted` | Never the address |
25+
| Device and session ids | random UUIDs | See below |
26+
27+
## What is never sent
28+
29+
Nothing you type. No argument values, flag values, file paths, workflow or
30+
workspace ids, error messages, environment variable values, credentials, or the
31+
address of the deployment you talk to. The report leaves your machine from a
32+
separate short-lived process that is not given your API key.
33+
34+
## Identity
35+
36+
The first run mints a random device id and stores it in `telemetry.json` under
37+
`~/.sim` (or `SIM_CONFIG_DIR`). It is not derived from your hardware, account,
38+
or network. Commands run within thirty minutes of each other share a session id
39+
and are numbered, so a sequence of commands can be read back. No profile is
40+
created for the device, and the data is not joined to your Sim account.
41+
42+
Deleting `telemetry.json` forgets the device id and shows the first-run notice
43+
again.
44+
45+
## Turning it off
46+
47+
Any of these turns reporting off. The first one that applies is the one
48+
`sim telemetry status` names.
49+
50+
```bash
51+
export DO_NOT_TRACK=1 # the cross-tool convention, honoured before anything else
52+
export SIM_TELEMETRY_DISABLED=1 # this CLI only, for one shell or CI job
53+
sim telemetry disable # this machine, saved in telemetry.json
54+
```
55+
56+
`sim telemetry enable` reverses the saved setting. `sim telemetry status` shows
57+
the current state.
58+
59+
Turning reporting off also stops the CLI from telling the API which coding
60+
agent, if any, is driving it. The CLI still identifies itself as the CLI on
61+
every request, the way every official client does; that is how a request is
62+
attributed, not usage data.
63+
64+
## The first-run notice
65+
66+
The first time the CLI would report from an interactive terminal it prints a
67+
short notice on stderr and does not report that run. The notice is not shown in
68+
CI or when stderr is redirected, and it is shown once per device.
69+
70+
## Self-hosted deployments
71+
72+
Reporting is tied to the CLI build, not to the deployment it talks to. The
73+
published `sim` package reports to Sim. A build made from the repository without
74+
`SIM_CLI_TELEMETRY_KEY` set has no destination and reports nothing; setting it
75+
to your own PostHog project token at build time reports to your own project.

0 commit comments

Comments
 (0)