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
5 changes: 5 additions & 0 deletions .github/workflows/publish-sim-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ jobs:

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

- name: Resolve release channel
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src/main/client-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

// client-info pulls in @/main/navigation, which imports electron.
vi.mock('electron', () => import('@/test/electron-mock'))

import { attachClientInfo, desktopClientInfo } from '@/main/client-info'

type BeforeSendHeadersHandler = (
details: { url: string; requestHeaders: Record<string, string> },
callback: (response: { requestHeaders?: Record<string, string> }) => void
) => void

function fakeSession() {
let handler: BeforeSendHeadersHandler | undefined
const ses = {
webRequest: {
onBeforeSendHeaders: vi.fn((h: BeforeSendHeadersHandler) => {
handler = h
}),
},
}
return { ses, run: () => handler }
}

const APP_ORIGIN = 'https://sim.ai'
const CLIENT_INFO = desktopClientInfo()

describe('desktopClientInfo', () => {
it('names the shell, its runtime, and the platform', () => {
expect(desktopClientInfo()).toMatch(
new RegExp(
`^desktop/1\\.0\\.0(; electron/[^;]+)?; os/${process.platform}; arch/${process.arch}$`
)
)
})
})

describe('attachClientInfo', () => {
let session: ReturnType<typeof fakeSession>

beforeEach(() => {
session = fakeSession()
attachClientInfo(
session.ses as unknown as Parameters<typeof attachClientInfo>[0],
() => APP_ORIGIN
)
})

it('stamps the shell identity on an app-origin request', () => {
const cb = vi.fn()
session.run()?.(
{ url: `${APP_ORIGIN}/api/workflows`, requestHeaders: { Accept: 'application/json' } },
cb
)
expect(cb).toHaveBeenCalledWith({
requestHeaders: { Accept: 'application/json', 'x-sim-client-info': CLIENT_INFO },
})
})

it('overwrites the web value the page sent, whatever its casing', () => {
const cb = vi.fn()
session.run()?.(
{ url: `${APP_ORIGIN}/api/workflows`, requestHeaders: { 'X-Sim-Client-Info': 'web' } },
cb
)
expect(cb).toHaveBeenCalledWith({
requestHeaders: { 'x-sim-client-info': CLIENT_INFO },
})
})

it('leaves requests to other origins untouched', () => {
const cb = vi.fn()
session.run()?.(
{ url: 'https://accounts.google.com/o/oauth2', requestHeaders: { Accept: '*/*' } },
cb
)
expect(cb).toHaveBeenCalledWith({})
})
})
49 changes: 49 additions & 0 deletions apps/desktop/src/main/client-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CLIENT_INFO_HEADER, formatClientInfo } from '@sim/utils/client-info'
import type { Session } from 'electron'
import { app } from 'electron'
import { isAppOrigin } from '@/main/navigation'

/**
* The `X-Sim-Client-Info` value naming this shell: its version, the Electron
* it runs on, and the platform. Computed once per process — none of it changes
* while the app is running.
*/
export function desktopClientInfo(): string {
const electron = process.versions.electron
return formatClientInfo({
surface: 'desktop',
version: app.getVersion(),
...(electron ? { runtime: { name: 'electron', version: electron } } : {}),
os: process.platform,
arch: process.arch,
})
}

/**
* Stamps the shell's identity on every request to the app origin.
*
* The page derives the same value from the preload bridge, so this is the
* backstop for what the page never issues itself — raw `fetch` exceptions,
* service-worker traffic, sub-resources — and for a bundle older than the
* bridge field. Only the network layer sees every request. The shell's value
* overwrites whatever the page sent; the shell is authoritative about being
* the shell. Requests to other origins are left untouched.
*
* This is the only `onBeforeSendHeaders` consumer — Electron allows a single
* listener per session.
*/
export function attachClientInfo(ses: Session, appOrigin: () => string): void {
const clientInfo = desktopClientInfo()
ses.webRequest.onBeforeSendHeaders((details, callback) => {
if (!isAppOrigin(details.url, appOrigin())) {
callback({})
return
}
const requestHeaders: Record<string, string> = {}
for (const [name, value] of Object.entries(details.requestHeaders)) {
if (name.toLowerCase() !== CLIENT_INFO_HEADER) requestHeaders[name] = value
}
requestHeaders[CLIENT_INFO_HEADER] = clientInfo
callback({ requestHeaders })
})
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
setBrowserAppearanceTheme as setAgentBrowserTheme,
setPanelFocused as setBrowserAgentPanelFocused,
} from '@/main/browser-agent/session'
import { attachClientInfo } from '@/main/client-info'
import {
APP_NAME_FOR_CHANNEL,
channelForOrigin,
Expand Down Expand Up @@ -265,6 +266,7 @@ function main(): void {
setupPermissionHandlers(ses, appOrigin)
attachLocalPageProtocol(ses)
attachCspFallback(ses, appOrigin)
attachClientInfo(ses, appOrigin)
attachDownloadHandling(ses, events)
attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true)
ses.setSpellCheckerLanguages(['en-US'])
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ These apply to every command, and may be written before or after it.
| Group | Description |
| --- | --- |
| [`sim profiles`](/cli/profiles) | List profiles or add a workspace profile that shares a stored login |
| [`sim telemetry`](/cli/telemetry) | Control anonymous usage reporting |
| [`sim audit-logs`](/cli/audit-logs) | Manage audit logs |
| [`sim billing`](/cli/billing) | Manage billing |
| [`sim blocks`](/cli/blocks) | Manage blocks |
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/cli/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ endpoint or stored login.
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely. Defaults to `3600`, above every timeout the server itself applies |
| `SIM_DEBUG` | Trace each request's method, URL, status and duration to stderr |
| `SIM_NO_UPDATE_CHECK` | Turn off update checks and notices |
| `SIM_TELEMETRY_DISABLED` | Turn off anonymous usage reporting; see [usage data](/cli/usage-data) |

## Updates

Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/cli/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
"scripting",
"workflow-sync",
"troubleshooting",
"usage-data",
"---Commands---",
"commands",
"profiles",
"telemetry",
"audit-logs",
"billing",
"blocks",
Expand Down
26 changes: 26 additions & 0 deletions apps/docs/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,32 @@ sim profiles add <name> [options]

</CommandTable>

## sim telemetry

### sim telemetry status

Show whether usage reporting is on, and why not if it is off

```bash
sim telemetry status
```

### sim telemetry enable

Turn usage reporting on for this machine

```bash
sim telemetry enable
```

### sim telemetry disable

Turn usage reporting off for this machine

```bash
sim telemetry disable
```

## sim audit-logs

Also spelled `sim audit-log`.
Expand Down
26 changes: 26 additions & 0 deletions apps/docs/content/docs/cli/telemetry.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
title: Telemetry
description: Control anonymous usage reporting — every subcommand, argument, and flag
---

import { CommandTable } from '@/components/ui/command-table'

Every command below also accepts the [global options](/cli/commands#global-options).

## Show whether usage reporting is on, and why not if it is off

```bash
sim telemetry status
```

## Turn usage reporting on for this machine

```bash
sim telemetry enable
```

## Turn usage reporting off for this machine

```bash
sim telemetry disable
```
75 changes: 75 additions & 0 deletions apps/docs/content/docs/cli/usage-data.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: Usage data
description: What the CLI reports about how it is used, what it never sends, and how to turn reporting off
---

The `sim` CLI reports anonymous usage data so the team can see which commands
are used, which fail, and how long they take. Reporting is on by default and
takes one command to turn off.

## What is sent

One event per command, after the command finishes:

| Field | Example | Notes |
| --- | --- | --- |
| Command | `workflows list` | The command's name, never its arguments |
| Flags | `--output`, `--workspace` | Flag names only, never their values |
| Argument count | `1` | How many positional arguments, never what they were |
| Outcome | exit code `0`, `SimApiError`, HTTP `404`, API code `NOT_FOUND` | Never an error message |
| Duration | `1432` ms | From process start to completion |
| CLI, Node, OS, CPU | `2.1.2`, `22.14.0`, `darwin`, `arm64` | |
| Terminal and CI | `is_tty`, `is_ci` | Whether stdout is a terminal, whether a CI variable is set |
| Coding agent | `claude-code` | When the CLI runs inside an AI coding agent's shell |
| Deployment kind | `hosted` or `self_hosted` | Never the address |
| Device and session ids | random UUIDs | See below |

## What is never sent

Nothing you type. No argument values, flag values, file paths, workflow or
workspace ids, error messages, environment variable values, credentials, or the
address of the deployment you talk to. The report leaves your machine from a
separate short-lived process that is not given your API key.

## Identity

The first run mints a random device id and stores it in `telemetry.json` under
`~/.sim` (or `SIM_CONFIG_DIR`). It is not derived from your hardware, account,
or network. Commands run within thirty minutes of each other share a session id
and are numbered, so a sequence of commands can be read back. No profile is
created for the device, and the data is not joined to your Sim account.

Deleting `telemetry.json` forgets the device id and shows the first-run notice
again.

## Turning it off

Any of these turns reporting off. The first one that applies is the one
`sim telemetry status` names.

```bash
export DO_NOT_TRACK=1 # the cross-tool convention, honoured before anything else
export SIM_TELEMETRY_DISABLED=1 # this CLI only, for one shell or CI job
sim telemetry disable # this machine, saved in telemetry.json
```

`sim telemetry enable` reverses the saved setting. `sim telemetry status` shows
the current state.

Turning reporting off also stops the CLI from telling the API which coding
agent, if any, is driving it. The CLI still identifies itself as the CLI on
every request, the way every official client does; that is how a request is
attributed, not usage data.

## The first-run notice

The first time the CLI would report from an interactive terminal it prints a
short notice on stderr and does not report that run. The notice is not shown in
CI or when stderr is redirected, and it is shown once per device.

## Self-hosted deployments

Reporting is tied to the CLI build, not to the deployment it talks to. The
published `sim` package reports to Sim. A build made from the repository without
`SIM_CLI_TELEMETRY_KEY` set has no destination and reports nothing; setting it
to your own PostHog project token at build time reports to your own project.
1 change: 1 addition & 0 deletions apps/sim/app/_shell/providers/posthog-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { consent, mockCapture, mockInit, mockOptIn, mockOptOut, mockPostHog, mock
capture: vi.fn(),
init: vi.fn(),
opt_in_capturing: vi.fn(),
register: vi.fn(),
opt_out_capturing: vi.fn(),
}
posthog.init.mockImplementation(() => {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/_shell/providers/posthog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
import { setPostHogClient } from '@/lib/posthog/client'
import { preparePostHogEvent } from '@/lib/posthog/exception-filter'
import { surfaceSuperProperties } from '@/lib/posthog/surface'

const logger = createLogger('PostHogProvider')

Expand Down Expand Up @@ -154,6 +155,7 @@ export function PostHogProvider({ children, consentRequired = false }: PostHogPr
* without emitting PostHog's synthetic opt-in event.
*/
posthog.opt_in_capturing({ captureEventName: false })
posthog.register(surfaceSuperProperties())
setPostHogClient(posthog)

if (publicEnvMissingAtModuleInit) {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/auth/forget-password/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
setRequestAuth: vi.fn(),
}))

import { POST } from '@/app/api/auth/forget-password/route'
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/auth/reset-password/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
setRequestAuth: vi.fn(),
}))

import { POST } from '@/app/api/auth/reset-password/route'
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/files/serve/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('@sim/logger', () => ({
logger: serveLogger,
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
setRequestAuth: vi.fn(),
}))

const {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/folders/[id]/duplicate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
setRequestAuth: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/folders/orchestration', () => ({ nextFolderSortOrder: mockNextFolderSortOrder }))
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/folders/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
setRequestAuth: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/folders/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
setRequestAuth: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

Expand Down
Loading
Loading