diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 1739e37b19f..53418f763d4 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -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 diff --git a/apps/desktop/src/main/client-info.test.ts b/apps/desktop/src/main/client-info.test.ts new file mode 100644 index 00000000000..67ecd169108 --- /dev/null +++ b/apps/desktop/src/main/client-info.test.ts @@ -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 }, + callback: (response: { requestHeaders?: Record }) => 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 + + beforeEach(() => { + session = fakeSession() + attachClientInfo( + session.ses as unknown as Parameters[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({}) + }) +}) diff --git a/apps/desktop/src/main/client-info.ts b/apps/desktop/src/main/client-info.ts new file mode 100644 index 00000000000..258019d261e --- /dev/null +++ b/apps/desktop/src/main/client-info.ts @@ -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 = {} + 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 }) + }) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4bb615b229b..49127cf305d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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, @@ -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']) diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index 1c565753a0f..b1c1e6f400a 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -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 | diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx index 50171148a70..3a1f6ac6814 100644 --- a/apps/docs/content/docs/cli/configuration.mdx +++ b/apps/docs/content/docs/cli/configuration.mdx @@ -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 diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 9a0b7bfd3b4..de9eac6e055 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -10,9 +10,11 @@ "scripting", "workflow-sync", "troubleshooting", + "usage-data", "---Commands---", "commands", "profiles", + "telemetry", "audit-logs", "billing", "blocks", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index b0e4f065045..d793c3cae88 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -189,6 +189,32 @@ sim profiles add [options] +## 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`. diff --git a/apps/docs/content/docs/cli/telemetry.mdx b/apps/docs/content/docs/cli/telemetry.mdx new file mode 100644 index 00000000000..9bea4c847d4 --- /dev/null +++ b/apps/docs/content/docs/cli/telemetry.mdx @@ -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 +``` diff --git a/apps/docs/content/docs/cli/usage-data.mdx b/apps/docs/content/docs/cli/usage-data.mdx new file mode 100644 index 00000000000..61528c8554c --- /dev/null +++ b/apps/docs/content/docs/cli/usage-data.mdx @@ -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. diff --git a/apps/sim/app/_shell/providers/posthog-provider.test.tsx b/apps/sim/app/_shell/providers/posthog-provider.test.tsx index 229fb5b1c7b..1f4486d783c 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.test.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.test.tsx @@ -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(() => { diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index f6a42361367..3c046409a2f 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -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') @@ -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) { diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index ed7d96fa86d..d246c5c1543 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -61,6 +61,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) import { POST } from '@/app/api/auth/forget-password/route' diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index 5535380a52e..b7038f796fb 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -43,6 +43,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) import { POST } from '@/app/api/auth/reset-password/route' diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 0adf30b1f5f..755861df05f 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -13,6 +13,7 @@ vi.mock('@sim/logger', () => ({ logger: serveLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) const { diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.test.ts b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts index 210ca44f054..30f42adba3e 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.test.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.test.ts @@ -51,6 +51,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_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 })) diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 035223d827a..e5451244412 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -47,6 +47,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 4c203c43fba..a78c26fe7d1 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -35,6 +35,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts index 251acc8d605..1512b98c131 100644 --- a/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts +++ b/apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.test.ts @@ -23,6 +23,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock })) diff --git a/apps/sim/app/api/pinned-items/route.test.ts b/apps/sim/app/api/pinned-items/route.test.ts index a5e73f55a40..8be4a0ba54d 100644 --- a/apps/sim/app/api/pinned-items/route.test.ts +++ b/apps/sim/app/api/pinned-items/route.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) // The route and `lib/pinned-items/resources` import both the db client AND the table diff --git a/apps/sim/app/api/providers/ollama/models/route.test.ts b/apps/sim/app/api/providers/ollama/models/route.test.ts index b1b37f155b7..86b26d9ad42 100644 --- a/apps/sim/app/api/providers/ollama/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama/models/route.test.ts @@ -23,6 +23,7 @@ vi.mock('@sim/logger', () => ({ logger: ollamaLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) vi.mock('@/providers/utils', () => ({ diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts index 9a3edeb206e..7d6fc563f78 100644 --- a/apps/sim/app/api/public-api-route-handler.test.ts +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -27,6 +27,7 @@ vi.mock('@sim/logger', () => ({ mockLoggerError(requestContextState.current?.requestId, ...arguments_), }), getRequestContext: () => requestContextState.current, + setRequestAuth: vi.fn(), runWithRequestContext: async ( context: { requestId: string; method?: string; path?: string }, callback: () => T | Promise diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.test.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.test.ts index 9b6b562fc6e..45c04cba63d 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.test.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.test.ts @@ -43,6 +43,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/app/api/v1/admin/middleware', () => ({ withAdminAuthParams: (handler: unknown) => handler, diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts index 78c68e1f9dd..770dbfee8e9 100644 --- a/apps/sim/app/api/v1/auth.ts +++ b/apps/sim/app/api/v1/auth.ts @@ -1,5 +1,9 @@ -import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' +import { + describePrincipalAuth, + type PersonalApiKeyPrincipal, + type WorkspaceApiKeyPrincipal, +} from '@sim/auth/principal' +import { createLogger, setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' @@ -72,6 +76,7 @@ export async function authenticateV1Request(request: NextRequest): Promise ({ logger: log, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 727879a89ad..6dcf14f6543 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -468,7 +468,6 @@ describe('workflow execute async route', () => { requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678') workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false) - hybridAuthMockFns.mockHasExternalApiCredentials.mockReturnValue(true) mockGetWorkspaceBillingSettings.mockResolvedValue({ billedAccountUserId: 'owner-1', allowPersonalApiKeys: true, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index bbd7632ffc1..8a8938ad345 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -15,8 +15,9 @@ import { WORKFLOW_EXECUTION_ID_HEADER, WORKFLOW_EXECUTION_TIMEOUT_SECONDS_HEADER, } from '@/lib/api/contracts/workflows' +import { hasExternalApiCredentials } from '@/lib/api/server/credential-headers' import { PERSONAL_KEY_DENIED, WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages' -import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' +import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { assertBillingAttributionSnapshot, diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts index d78b02188b9..8f51155db10 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts @@ -33,6 +33,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/promote/promote', () => ({ promoteFork: mockPromoteFork })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts index cff037e493a..972f05e9103 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts @@ -33,6 +33,7 @@ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/create-fork', () => ({ createFork: mockCreateFork })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts index 898b4f5ea9e..0c78eca1391 100644 --- a/apps/sim/lib/api-key/service.test.ts +++ b/apps/sim/lib/api-key/service.test.ts @@ -31,6 +31,7 @@ vi.mock('@sim/logger', () => ({ logger: serviceLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({ diff --git a/apps/sim/lib/api/client-info.ts b/apps/sim/lib/api/client-info.ts new file mode 100644 index 00000000000..419b9e989bc --- /dev/null +++ b/apps/sim/lib/api/client-info.ts @@ -0,0 +1,36 @@ +import { CLIENT_INFO_HEADER, formatClientInfo } from '@sim/utils/client-info' +import { getDesktopShellVersion } from '@/lib/desktop' + +export { CLIENT_INFO_HEADER } + +export interface BrowserSurface { + surface: 'web' | 'desktop' + /** The desktop shell's version; absent on the web, which has no version of its own. */ + version?: string +} + +/** + * Which browser-hosted surface this page is: the desktop shell when its + * preload bridge is present, the web app otherwise. The one decision behind + * both the `X-Sim-Client-Info` header and the PostHog super property, so the + * two can never disagree. + */ +export function resolveBrowserSurface(): BrowserSurface { + const shellVersion = getDesktopShellVersion() + return shellVersion === undefined + ? { surface: 'web' } + : { surface: 'desktop', version: shellVersion } +} + +/** + * The `X-Sim-Client-Info` value the web app sends on its own API calls. + * + * Returns `undefined` on the server, where the same client code runs during + * prefetching and a request from the app to itself is not a web-surface call. + * Inside the desktop shell the main process stamps the same identity on every + * app-origin request as a backstop for traffic the page never issues itself. + */ +export function getClientInfoHeader(): string | undefined { + if (typeof window === 'undefined') return undefined + return formatClientInfo(resolveBrowserSurface()) +} diff --git a/apps/sim/lib/api/client/request.test.ts b/apps/sim/lib/api/client/request.test.ts index 4a9453deb51..3a059ed61bb 100644 --- a/apps/sim/lib/api/client/request.test.ts +++ b/apps/sim/lib/api/client/request.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { requestJson } from '@/lib/api/client/request' import { CLIENT_ID_HEADER } from '@/lib/api/client-id' +import { CLIENT_INFO_HEADER } from '@/lib/api/client-info' import { listKnowledgeDocumentsContract } from '@/lib/api/contracts/knowledge' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -123,3 +124,33 @@ describe('requestJson client id header', () => { expect(sentHeaders(fetchMock)[CLIENT_ID_HEADER]).toBeUndefined() }) }) + +describe('requestJson client info header', () => { + const contract = defineRouteContract({ + method: 'GET', + path: '/api/test', + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, + }) + + function sentHeaders(fetchMock: ReturnType): Record { + return (fetchMock.mock.calls[0][1] as RequestInit).headers as Record + } + + it('declares the web surface in the browser', async () => { + vi.stubGlobal('window', {}) + const fetchMock = mockFetchReturning({ ok: true }) + + await requestJson(contract, {}) + + expect(sentHeaders(fetchMock)[CLIENT_INFO_HEADER]).toBe('web') + }) + + it('omits it on the server, where a request to itself is not a web-surface call', async () => { + vi.stubGlobal('window', undefined) + const fetchMock = mockFetchReturning({ ok: true }) + + await requestJson(contract, {}) + + expect(sentHeaders(fetchMock)[CLIENT_INFO_HEADER]).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/api/client/request.ts b/apps/sim/lib/api/client/request.ts index cef9207cb16..039eba16d94 100644 --- a/apps/sim/lib/api/client/request.ts +++ b/apps/sim/lib/api/client/request.ts @@ -1,5 +1,6 @@ import { ApiClientError } from '@/lib/api/client/errors' import { CLIENT_ID_HEADER, getClientId } from '@/lib/api/client-id' +import { CLIENT_INFO_HEADER, getClientInfoHeader } from '@/lib/api/client-info' import type { AnyApiRouteContract, ApiSchema, @@ -109,6 +110,9 @@ function buildHeaders(headers: unknown, hasBody: boolean): Record)) { if (typeof value === 'string') output[key] = value diff --git a/apps/sim/lib/api/server/credential-headers.ts b/apps/sim/lib/api/server/credential-headers.ts new file mode 100644 index 00000000000..f0488ceecdb --- /dev/null +++ b/apps/sim/lib/api/server/credential-headers.ts @@ -0,0 +1,20 @@ +/** + * The headers that carry external API credentials, and the header-only check + * for them. Deliberately dependency-free: the route wrapper classifies every + * request with this before any authentication happens, and it must not pull + * the authentication graph in to do so. + */ + +export const API_KEY_HEADER = 'x-api-key' +export const BEARER_PREFIX = 'Bearer ' + +/** + * Whether a request carries external API credentials — an API key or a bearer + * token. Inspects headers only and validates nothing: it classifies the + * request as programmatic API traffic rather than interactive session traffic. + */ +export function hasExternalApiCredentials(headers: { get(name: string): string | null }): boolean { + if (headers.get(API_KEY_HEADER) !== null) return true + const auth = headers.get('authorization') + return auth?.startsWith(BEARER_PREFIX) ?? false +} diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 385b21ebc45..44ed6713897 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -1,4 +1,5 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { describePrincipalAuth, type Principal, type SessionPrincipal } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -90,6 +91,7 @@ export function defineInternalBinaryRoute< } throw error } + setRequestAuth(describePrincipalAuth(principal)) await options.rateLimit.enforce(request, principal) const parsed = await parseRequest(options.contract, request, context ?? {}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index ace040a22a2..c931133d687 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { getRequestContext } from '@sim/logger' +import { getRequestContext, setRequestAuth } from '@sim/logger' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' @@ -78,6 +78,27 @@ describe('defineInternalJsonRoute', () => { expect(response.headers.get('x-request-id')).toBeTruthy() }) + it('records how the request authenticated once the principal is known', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + }) + + await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith({ kind: 'session' }) + }) + it('applies a user-scoped admission limit after authentication and before execution', async () => { const execute = vi.fn(async () => ({ value: 'unreachable' })) mockEnforceUserRateLimit.mockResolvedValueOnce( diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index ddacfca77da..a3890a53fde 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,13 +1,16 @@ import { type DelegatedPrincipal, + describePrincipalAuth, type Principal, resolvePrincipalSubjectUserId, type SessionPrincipal, type WorkflowExecutionDelegatedPrincipal, } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { ContractJsonResponse } from '@/lib/api/contracts' +import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers' import { methodMatchesContract, requireJsonRouteDefinition, @@ -73,19 +76,19 @@ export function createInternalSessionOrExecutorAuth( return { async authenticate(request, params) { - if (request.headers.has('x-api-key')) { + if (request.headers.has(API_KEY_HEADER)) { throw new InternalUnauthenticatedError('Authentication required') } const authorization = request.headers.get('authorization') if (!authorization) return internalSessionAuth.authenticate() - if (!authorization.startsWith('Bearer ')) { + if (!authorization.startsWith(BEARER_PREFIX)) { throw new InternalUnauthenticatedError('Authentication required') } let delegation try { - delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + delegation = await verifyInternalDelegationToken(authorization.slice(BEARER_PREFIX.length)) } catch (error) { if (!(error instanceof InvalidInternalDelegationTokenError)) throw error throw new InternalUnauthenticatedError('Authentication required') @@ -370,6 +373,7 @@ export function defineInternalJsonRoute< } throw error } + setRequestAuth(describePrincipalAuth(principal)) const rateLimitResponse = await options.rateLimit.enforce(request, principal) if (rateLimitResponse) return responseWithRequestId(rateLimitResponse) diff --git a/apps/sim/lib/api/server/routes/scim-route.ts b/apps/sim/lib/api/server/routes/scim-route.ts index 1b965875110..f658a09009a 100644 --- a/apps/sim/lib/api/server/routes/scim-route.ts +++ b/apps/sim/lib/api/server/routes/scim-route.ts @@ -1,5 +1,5 @@ -import type { ScimConnectionPrincipal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' +import { describePrincipalAuth, type ScimConnectionPrincipal } from '@sim/auth/principal' +import { createLogger, setRequestAuth } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import type { AnyApiRouteContract, ContractJsonResponse } from '@/lib/api/contracts/types' import { type ParsedRequest, parseRequest } from '@/lib/api/server/validation' @@ -177,6 +177,7 @@ export function createScimRouteBuilder(dependencies: ScimRouteDependencies) { * authenticates and admits. */ principal = await dependencies.authenticate(request) + setRequestAuth(describePrincipalAuth(principal)) await enforceConnectionRateLimit(principal) const parsed = await parseRequest(options.contract, request, context ?? {}, { diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 374226b84dd..74c8d2ee22e 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -1,3 +1,5 @@ +import { describePrincipalAuth } from '@sim/auth/principal' +import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' @@ -337,6 +339,7 @@ async function admitAuthenticatedV2Request( } throw new V2RouteInfrastructureError('authentication', error) } + setRequestAuth(describePrincipalAuth(auth.principal)) try { requireOAuthOperationScope(auth.principal, operation) diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 819301f9e55..eac476540ca 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -1,6 +1,7 @@ -import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' +import { describePrincipalAuth, type WorkflowExecutionPrincipal } from '@sim/auth/principal' +import { createLogger, setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' +import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { type InternalSandboxProfile, verifyInternalToken } from '@/lib/auth/internal' @@ -15,20 +16,6 @@ export const AuthType = { export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType] -const API_KEY_HEADER = 'x-api-key' -const BEARER_PREFIX = 'Bearer ' - -/** - * Lightweight header-only check for whether a request carries external API credentials. - * Does NOT validate the credentials — only inspects headers to classify the request - * as programmatic API traffic vs interactive session traffic. - */ -export function hasExternalApiCredentials(headers: Headers): boolean { - if (headers.has(API_KEY_HEADER)) return true - const auth = headers.get('authorization') - return auth?.startsWith(BEARER_PREFIX) ?? false -} - export interface AuthResult { success: boolean userId?: string @@ -96,14 +83,14 @@ function resolveUserFromJwt( * @param options - Optional configuration * @param options.requireWorkflowId - Whether workflowId/userId is required (default: true) */ -export async function checkInternalAuth( +async function resolveInternalAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { const authHeader = request.headers.get('authorization') - const apiKeyHeader = request.headers.get('x-api-key') + const apiKeyHeader = request.headers.get(API_KEY_HEADER) if (apiKeyHeader) { return { success: false, @@ -111,7 +98,7 @@ export async function checkInternalAuth( } } - if (!authHeader?.startsWith('Bearer ')) { + if (!authHeader?.startsWith(BEARER_PREFIX)) { return { success: false, error: 'Internal authentication required', @@ -144,13 +131,13 @@ export async function checkInternalAuth( * @param options - Optional configuration * @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true) */ -export async function checkSessionOrInternalAuth( +async function resolveSessionOrInternalAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { // 1. Reject API keys first - const apiKeyHeader = request.headers.get('x-api-key') + const apiKeyHeader = request.headers.get(API_KEY_HEADER) if (apiKeyHeader) { return { success: false, @@ -160,7 +147,7 @@ export async function checkSessionOrInternalAuth( // 2. Check for internal JWT token const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ')) { + if (authHeader?.startsWith(BEARER_PREFIX)) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) @@ -208,13 +195,13 @@ export async function checkSessionOrInternalAuth( * * For internal JWT calls, requires workflowId to determine user context */ -export async function checkHybridAuth( +async function resolveHybridAuth( request: NextRequest, options: { requireWorkflowId?: boolean } = {} ): Promise { try { const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ')) { + if (authHeader?.startsWith(BEARER_PREFIX)) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) @@ -290,3 +277,36 @@ export async function checkHybridAuth( } } } + +type AuthCheck = ( + request: NextRequest, + options?: { requireWorkflowId?: boolean } +) => Promise + +/** + * Records how a request authenticated on the request context, so the logs and + * analytics of a route that authenticates through these helpers rather than a + * route builder carry the same `auth` attribution. A principal describes + * itself; an internal JWT that produced none is recorded by its auth type. + */ +function recordingAuth(resolve: AuthCheck): AuthCheck { + return async (request, options) => { + const result = await resolve(request, options) + if (!result.success) return result + if (result.principal) { + setRequestAuth(describePrincipalAuth(result.principal)) + } else if (result.authType) { + setRequestAuth({ kind: result.authType }) + } + return result + } +} + +/** Internal JWT authentication only. See {@link resolveInternalAuth}. */ +export const checkInternalAuth = recordingAuth(resolveInternalAuth) + +/** Session or internal JWT authentication, never an API key. See {@link resolveSessionOrInternalAuth}. */ +export const checkSessionOrInternalAuth = recordingAuth(resolveSessionOrInternalAuth) + +/** Any of the three supported credentials. See {@link resolveHybridAuth}. */ +export const checkHybridAuth = recordingAuth(resolveHybridAuth) diff --git a/apps/sim/lib/catalog/projection/projection-invariants.test.ts b/apps/sim/lib/catalog/projection/projection-invariants.test.ts index 044235e0781..e2c8243d923 100644 --- a/apps/sim/lib/catalog/projection/projection-invariants.test.ts +++ b/apps/sim/lib/catalog/projection/projection-invariants.test.ts @@ -20,6 +20,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: () => undefined, })) diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index ba1946bc3d8..69d6daa5aab 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -47,8 +47,10 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/selectors.mdx', 'cli/skills.mdx', 'cli/tables.mdx', + 'cli/telemetry.mdx', 'cli/tools.mdx', 'cli/troubleshooting.mdx', + 'cli/usage-data.mdx', 'cli/workflow-mcp-servers.mdx', 'cli/workflow-sync.mdx', 'cli/workflows.mdx', diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index 5314c2095fb..bf02a57e48d 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -38,6 +38,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: () => {}, })) vi.mock('ioredis', () => ({ diff --git a/apps/sim/lib/core/utils/with-route-handler.test.ts b/apps/sim/lib/core/utils/with-route-handler.test.ts index 0ac7b9cd6d6..c87415fd7f8 100644 --- a/apps/sim/lib/core/utils/with-route-handler.test.ts +++ b/apps/sim/lib/core/utils/with-route-handler.test.ts @@ -18,6 +18,49 @@ class TestHttpError extends HttpError { } describe('withRouteHandler', () => { + it('carries the workflow call chain into the request context', async () => { + const seen: unknown[] = [] + vi.mocked(loggerMock.runWithRequestContext).mockImplementationOnce((context, fn) => { + seen.push(context) + return fn() + }) + const handler = withRouteHandler(async () => NextResponse.json({ ok: true })) + + await handler( + new NextRequest('http://localhost/api/test', { headers: { 'x-sim-via': 'wf-1, wf-2' } }), + undefined + ) + + expect(seen[0]).toEqual(expect.objectContaining({ callChain: ['wf-1', 'wf-2'] })) + }) + + it('resolves the sending client into the request context for logs and analytics', async () => { + const seen: unknown[] = [] + vi.mocked(loggerMock.runWithRequestContext).mockImplementationOnce((context, fn) => { + seen.push(context) + return fn() + }) + const handler = withRouteHandler(async () => NextResponse.json({ ok: true })) + + await handler( + new NextRequest('http://localhost/api/test', { + headers: { 'x-sim-client-info': 'cli/2.1.2; node/22.14.0; agent/claude-code' }, + }), + undefined + ) + + expect(seen[0]).toEqual( + expect.objectContaining({ + client: expect.objectContaining({ + surface: 'cli', + version: '2.1.2', + agent: 'claude-code', + source: 'header', + }), + }) + ) + }) + it('classifies errors after a client disconnect without using the unhandled fallback', async () => { const routeHandlerLogger = vi.mocked(loggerMock.createLogger).mock.results[ vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'RouteHandler') diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index eb6c91d1c96..e25de9a486a 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -1,10 +1,13 @@ import { createLogger, runWithRequestContext } from '@sim/logger' +import { resolveClientInfo } from '@sim/utils/client-info' import { describeError, findCause, getErrorMessage, redactBoundParameters } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { hasExternalApiCredentials } from '@/lib/api/server/credential-headers' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' const logger = createLogger('RouteHandler') @@ -87,6 +90,29 @@ function traceIdFromTraceparent(header: string | null | undefined): string | und return match[1] } +/** + * Which official client sent the request, resolved once here so every log line + * and analytics event in the request carries it. Attribution only: the value + * is caller-controlled and never feeds authorization. + */ +function clientInfoFor(request: NextRequest) { + const headers = request?.headers + if (!headers?.get) return undefined + return resolveClientInfo(headers, { hasExternalCredentials: hasExternalApiCredentials(headers) }) +} + +/** + * The workflow call chain the request arrived with, so a request one workflow + * makes to run another is attributed to the workflow that made it. Read here, + * not only in the execute routes that enforce its depth, because the events a + * nested run emits should know they were nested. Bounded by the same cap the + * execute routes apply; a chain past it is refused there and truncated here. + */ +function callChainFor(request: NextRequest): readonly string[] | undefined { + const chain = parseCallChain(request?.headers?.get?.(SIM_VIA_HEADER)) + return chain.length > 0 ? chain.slice(0, MAX_CALL_CHAIN_DEPTH) : undefined +} + /** * What a wrapped error hides: a query failure from the database client carries * the driver's reason and the Postgres code on its cause, and only the outer @@ -134,8 +160,16 @@ export function withRouteHandler( const path = request?.nextUrl?.pathname ?? new URL(request?.url ?? '/', 'http://localhost').pathname const traceId = traceIdFromTraceparent(request?.headers?.get?.('traceparent')) - - return runWithRequestContext({ requestId, method, path, traceId }, async () => { + const requestContext = { + requestId, + method, + path, + traceId, + client: clientInfoFor(request), + callChain: callChainFor(request), + } + + return runWithRequestContext(requestContext, async () => { let response: NextResponse | Response try { response = await withPermissionGroupScope(() => handler(request, context)) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index bfaa0b47f5c..861497837ee 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -33,6 +33,7 @@ vi.mock('@sim/logger', () => { logger: createLogger(), runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), } }) diff --git a/apps/sim/lib/data-drains/destinations/bigquery.test.ts b/apps/sim/lib/data-drains/destinations/bigquery.test.ts index 31f4d98092a..c558ca09789 100644 --- a/apps/sim/lib/data-drains/destinations/bigquery.test.ts +++ b/apps/sim/lib/data-drains/destinations/bigquery.test.ts @@ -32,6 +32,7 @@ vi.mock('@sim/logger', () => ({ logger: loggerInstance, runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), })) vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn(async () => {}), diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 0521b0639a8..2e7a9ed6744 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -36,6 +36,7 @@ vi.mock('@sim/logger', () => ({ logger: mockLogger, runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), + setRequestAuth: vi.fn(), })) // Mock billing modules diff --git a/apps/sim/lib/posthog/server.test.ts b/apps/sim/lib/posthog/server.test.ts index ec472a5afcf..169f2e0c713 100644 --- a/apps/sim/lib/posthog/server.test.ts +++ b/apps/sim/lib/posthog/server.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import type { MockInstance } from 'vitest' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' @@ -31,6 +32,89 @@ describe('captureServerEvent', () => { beforeEach(() => { captureSpy.mockClear() captureSpy.mockImplementation(() => {}) + vi.mocked(loggerMock.getRequestContext).mockReturnValue(undefined) + }) + + it('stamps the resolved client from the request context onto every event', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + client: { surface: 'cli', version: '2.1.2', agent: 'claude-code', source: 'header' }, + }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + request_id: 'req-1', + surface: 'cli', + client_version: '2.1.2', + coding_agent: 'claude-code', + }), + }) + ) + }) + + it('stamps the request, its authentication, and the workflow call chain', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + method: 'POST', + path: '/api/v2/workflows/wf-3/execute', + auth: { kind: 'oauth_access_token', clientId: 'sim-cli' }, + callChain: ['wf-1', 'wf-2'], + }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + api_method: 'POST', + api_path: '/api/v2/workflows/wf-3/execute', + auth_kind: 'oauth_access_token', + auth_client_id: 'sim-cli', + call_chain_depth: 2, + call_chain_root_workflow_id: 'wf-1', + caller_workflow_id: 'wf-2', + }), + }) + ) + }) + + it('leaves out what the request did not establish', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ requestId: 'req-1' }) + + captureServerEvent('user-1', 'workflow_deployed', { + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + }) + + expect(captureSpy.mock.calls[0][0].properties).toEqual({ + workflow_id: 'workflow-1', + workspace_id: 'workspace-1', + request_id: 'req-1', + }) + }) + + it('never overwrites attribution a caller set explicitly', () => { + vi.mocked(loggerMock.getRequestContext).mockReturnValue({ + requestId: 'req-1', + client: { surface: 'web', source: 'fetch_metadata' }, + }) + + captureServerEvent('user-1', 'search_result_selected', { + surface: 'copilot', + } as never) + + expect(captureSpy).toHaveBeenCalledWith( + expect.objectContaining({ properties: expect.objectContaining({ surface: 'copilot' }) }) + ) }) it('swallows a failing client instead of propagating to the caller', () => { diff --git a/apps/sim/lib/posthog/server.ts b/apps/sim/lib/posthog/server.ts index f2e5a828fa1..022c760990d 100644 --- a/apps/sim/lib/posthog/server.ts +++ b/apps/sim/lib/posthog/server.ts @@ -55,15 +55,52 @@ interface CaptureOptions { setOnce?: PersonProperties } +/** + * What the ambient request context contributes to every event: the request it + * happened in, which client the user was on (web, desktop, CLI, an SDK) and, + * for the CLI, which AI coding agent was driving it; how the request + * authenticated; and, when one workflow's run made the call, the chain of + * workflows behind it. Stamped here rather than at each of the many capture + * sites so no event can forget it, and only for properties the caller did not + * set itself. + */ +function contextProperties(explicit: Record): Record { + const context = getRequestContext() + if (!context) return {} + const merged: Record = {} + const stamp = (key: string, value: unknown) => { + if (value !== undefined && !(key in explicit)) merged[key] = value + } + + stamp('request_id', context.requestId) + stamp('api_method', context.method) + stamp('api_path', context.path) + + stamp('surface', context.client?.surface) + stamp('client_version', context.client?.version) + stamp('coding_agent', context.client?.agent) + + stamp('auth_kind', context.auth?.kind) + stamp('auth_service', context.auth?.service) + stamp('auth_client_id', context.auth?.clientId) + + const chain = context.callChain + if (chain && chain.length > 0) { + stamp('call_chain_depth', chain.length) + stamp('call_chain_root_workflow_id', chain[0]) + stamp('caller_workflow_id', chain[chain.length - 1]) + } + return merged +} + function buildCaptureProperties( properties: PostHogEventMap[E], options?: CaptureOptions ): Record { - const contextRequestId = getRequestContext()?.requestId const props = properties as Record return { ...properties, - ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), + ...contextProperties(props), ...(options?.insertId ? { $insert_id: options.insertId } : {}), ...(options?.groups ? { $groups: options.groups } : {}), ...(options?.set ? { $set: options.set } : {}), diff --git a/apps/sim/lib/posthog/surface.ts b/apps/sim/lib/posthog/surface.ts new file mode 100644 index 00000000000..139b10d7d4a --- /dev/null +++ b/apps/sim/lib/posthog/surface.ts @@ -0,0 +1,12 @@ +import { resolveBrowserSurface } from '@/lib/api/client-info' + +/** + * The super properties that attribute every browser-side event to the surface + * the user is on. Registered once at PostHog initialization so client events + * carry the same `surface` property the server stamps from `X-Sim-Client-Info`, + * and a single breakdown covers both. + */ +export function surfaceSuperProperties(): { surface: 'web' | 'desktop'; app_version?: string } { + const { surface, version } = resolveBrowserSurface() + return version === undefined ? { surface } : { surface, app_version: version } +} diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 3a85a478257..b1dc2145a2e 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -130,6 +130,7 @@ describe('resolveApiCorsPolicy', () => { expect(policy.credentials).toBe(false) expect(policy.headers).toContain('X-Run-Id') expect(policy.headers).toContain('X-Sim-Stream-Protocol') + expect(policy.headers).toContain('X-Sim-Client-Info') expect(policy.headers).toContain('Authorization') expect(policy.headers).not.toContain('X-Execution-Id') // Async is body-selected on v2 — the mode header is deliberately absent. diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 2bce1dd5f6d..24d09a30419 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -49,15 +49,39 @@ const DEFAULT_API_ALLOWED_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS' const DEFAULT_API_EXPOSED_HEADERS = 'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id' -const DEFAULT_API_ALLOWED_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization' +/** + * Every API policy allows these. `X-Sim-Client-Info` is here rather than on one + * policy because every official client sends it on every request. + */ +const BASE_API_ALLOWED_HEADERS = [ + 'X-CSRF-Token', + 'X-Requested-With', + 'Accept', + 'Accept-Version', + 'Content-Length', + 'Content-MD5', + 'Content-Type', + 'Date', + 'X-Api-Version', + 'X-API-Key', + 'Authorization', + 'X-Sim-Client-Info', +] as const + +function allowedHeaders(...extra: string[]): string { + return [...BASE_API_ALLOWED_HEADERS, ...extra].join(', ') +} + +const DEFAULT_API_ALLOWED_HEADERS = allowedHeaders() -const WORKFLOW_EXECUTE_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds' +const WORKFLOW_EXECUTE_HEADERS = allowedHeaders( + 'X-Execution-Id', + 'X-Execution-Mode', + 'X-Execution-Timeout-Seconds' +) /** v2 execute: run identity and modes use the v2 wire names while streaming negotiates its protocol. */ -const WORKFLOW_EXECUTE_V2_HEADERS = - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, X-Run-Id, X-Sim-Stream-Protocol' +const WORKFLOW_EXECUTE_V2_HEADERS = allowedHeaders('X-Run-Id', 'X-Sim-Stream-Protocol') /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) diff --git a/apps/sim/tools/brex/idempotency.test.ts b/apps/sim/tools/brex/idempotency.test.ts index 328bfbe449b..3d224088258 100644 --- a/apps/sim/tools/brex/idempotency.test.ts +++ b/apps/sim/tools/brex/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/outlook/calendar-idempotency.test.ts b/apps/sim/tools/outlook/calendar-idempotency.test.ts index 24dd884ef86..76493a5d5d4 100644 --- a/apps/sim/tools/outlook/calendar-idempotency.test.ts +++ b/apps/sim/tools/outlook/calendar-idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/square/idempotency.test.ts b/apps/sim/tools/square/idempotency.test.ts index 55093db3d0c..a649e8c0251 100644 --- a/apps/sim/tools/square/idempotency.test.ts +++ b/apps/sim/tools/square/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/apps/sim/tools/stripe/idempotency.test.ts b/apps/sim/tools/stripe/idempotency.test.ts index d4c228fabe8..f4315955698 100644 --- a/apps/sim/tools/stripe/idempotency.test.ts +++ b/apps/sim/tools/stripe/idempotency.test.ts @@ -15,6 +15,7 @@ vi.mock('@sim/logger', () => ({ logger: { info: vi.fn(), warn: mockWarn, error: vi.fn(), debug: vi.fn() }, runWithRequestContext: (_context: unknown, fn: () => T): T => fn(), getRequestContext: () => undefined, + setRequestAuth: vi.fn(), setRequestTraceId: vi.fn(), })) diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 201b238f891..17a0e0806e8 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -751,6 +751,27 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { } } +/** + * How a principal was authenticated, for request logs and analytics: its kind, + * plus the service behind a delegated or system principal and the OAuth client + * behind an access token. Identifiers that name a person, key, or token are + * deliberately left out — this describes the credential's kind, not the actor. + */ +export interface PrincipalAuthDescriptor { + kind: Principal['kind'] + service?: string + clientId?: string +} + +export function describePrincipalAuth(principal: Principal): PrincipalAuthDescriptor { + const actor = toPrincipalActor(principal) + return { + kind: actor.kind, + ...('serviceId' in actor ? { service: actor.serviceId } : {}), + ...('clientId' in actor ? { clientId: actor.clientId } : {}), + } +} + export function resolvePrincipalAuditAttribution(principal: Principal): PrincipalAuditAttribution { const actor = toPrincipalActor(principal) diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index a906c246abc..728153d9b0d 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -7,7 +7,7 @@ import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { filterUndefined, isRecordLike } from '@sim/utils/object' import chalk from 'chalk' -import { getRequestContext } from './request-context' +import { getRequestContext, type RequestContext } from './request-context' /** * LogLevel enum defines the severity levels for logging @@ -305,6 +305,29 @@ const materializeMetadata = (metadata: LoggerMetadata): LoggerMetadata => { return { metadataError: true } } +/** + * The request context's contribution to every log line in it. Only fields the + * request actually established are set, so a line outside a request, or before + * authentication, carries no empty keys to filter back out. + */ +const requestContextMetadata = (context: RequestContext): LoggerMetadata => { + const metadata: LoggerMetadata = { requestId: context.requestId } + if (context.method) metadata.method = context.method + if (context.path) metadata.path = context.path + if (context.traceId) metadata.traceId = context.traceId + if (context.client) { + metadata.surface = context.client.surface + if (context.client.version) metadata.clientVersion = context.client.version + if (context.client.agent) metadata.codingAgent = context.client.agent + } + if (context.auth) { + metadata.auth = context.auth.kind + if (context.auth.service) metadata.authService = context.auth.service + } + if (context.callChain) metadata.callDepth = context.callChain.length + return metadata +} + /** * Logger class for standardized console logging * @@ -402,13 +425,7 @@ export class Logger { const reqCtx = getRequestContext() const effectiveMetadata = reqCtx - ? { - requestId: reqCtx.requestId, - method: reqCtx.method, - path: reqCtx.path, - traceId: reqCtx.traceId, - ...this.metadata, - } + ? { ...requestContextMetadata(reqCtx), ...this.metadata } : this.metadata const metadataEntries = Object.entries(filterUndefined(effectiveMetadata)) const metadataStr = @@ -526,8 +543,13 @@ export function createLogger(module: string, config?: LoggerConfig): Logger { return new Logger(module, config) } -export type { RequestContext } from './request-context' -export { getRequestContext, runWithRequestContext, setRequestTraceId } from './request-context' +export type { RequestAuth, RequestContext } from './request-context' +export { + getRequestContext, + runWithRequestContext, + setRequestAuth, + setRequestTraceId, +} from './request-context' const OTEL_LOG_SEVERITY: Record = { [LogLevel.DEBUG]: { number: SeverityNumber.DEBUG, text: 'DEBUG' }, diff --git a/packages/logger/src/request-context.ts b/packages/logger/src/request-context.ts index c2e5923e032..ae07c2fd16b 100644 --- a/packages/logger/src/request-context.ts +++ b/packages/logger/src/request-context.ts @@ -1,3 +1,5 @@ +import type { ResolvedClientInfo } from '@sim/utils/client-info' + export interface RequestContext { requestId: string method?: string @@ -9,6 +11,37 @@ export interface RequestContext { * `setRequestTraceId` when the trace root is created locally. */ traceId?: string + /** + * Which official client sent the request (web, desktop, CLI, an SDK), when + * it could be established. Resolved once by the route handler so logs and + * analytics emitted anywhere in the request attribute it without each call + * site re-reading headers. + */ + client?: ResolvedClientInfo + /** + * How the request authenticated, stamped by the surface adapter once its + * credential resolved to a principal. Absent on public and unauthenticated + * requests, and until authentication has run. + */ + auth?: RequestAuth + /** + * The workflow-to-workflow call chain the request arrived with (`X-Sim-Via`), + * oldest first. Present only when one workflow's execution made this call. + */ + callChain?: readonly string[] +} + +/** + * The credential kind a request authenticated with, in the vocabulary of the + * principal it produced: `session`, `personal_api_key`, `workspace_api_key`, + * `oauth_access_token`, `delegated`, `system`, and so on. `service` names the + * delegating or system service (`copilot`, `schedule`, …) and `clientId` the + * OAuth client (`sim-cli`), when the kind carries one. + */ +export interface RequestAuth { + kind: string + service?: string + clientId?: string } /** @@ -64,3 +97,15 @@ export function setRequestTraceId(traceId: string): void { const store = storage.getStore() if (store && traceId) store.traceId = traceId } + +/** + * Records how the current request authenticated so every later log line and + * analytics event in it can say so. Authentication runs inside the handler, + * after the route context exists, hence a mutation of the live store rather + * than a field supplied at `runWithRequestContext` time. No-op outside a + * request context. + */ +export function setRequestAuth(auth: RequestAuth): void { + const store = storage.getStore() + if (store) store.auth = auth +} diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index cc577f82d87..1258b37c100 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -10,6 +10,7 @@ import time import random import os +import platform import requests @@ -21,6 +22,20 @@ _SUCCESSFUL_RUN_STATUSES = ('completed', 'paused') __version__ = "0.2.0" + + +def _client_headers() -> Dict[str, str]: + """ + Identify this SDK to the API on every request, the way every official Sim + client does, so a server log line or analytics event can say which client + made the call. ``X-Sim-Client-Info`` is the header the server reads. + """ + python_version = platform.python_version() + return { + 'User-Agent': f'simstudio-python-sdk/{__version__} python/{python_version}', + 'X-Sim-Client-Info': f'sdk-python/{__version__}; python/{python_version}', + } + __all__ = [ "SimStudioClient", "SimStudioError", @@ -133,6 +148,7 @@ def __init__(self, api_key: str, base_url: str = "https://sim.ai"): self.base_url = base_url.rstrip('/') self._session = requests.Session() self._session.headers.update({ + **_client_headers(), 'X-API-Key': self.api_key, 'Content-Type': 'application/json', }) diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 3ef5d711e88..eb084b2b45b 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -2,9 +2,11 @@ Tests for the Sim Python SDK """ +import platform + import pytest from unittest.mock import Mock, patch -from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus +from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus, __version__ def v2_execution_response(output=None, status="completed", error=None): @@ -765,3 +767,12 @@ def test_execute_workflow_with_dict_input_uses_v2_input_field(mock_post): request_body = call_args[1]["json"] assert request_body["input"] == {"ticker": "NVDA", "quantity": 100} + + +def test_identifies_the_sdk_on_every_request(): + client = SimStudioClient(api_key="test-key") + python_version = platform.python_version() + + assert client._session.headers["X-Sim-Client-Info"] == f"sdk-python/{__version__}; python/{python_version}" + assert client._session.headers["User-Agent"] == f"simstudio-python-sdk/{__version__} python/{python_version}" + assert client._session.headers["X-API-Key"] == "test-key" diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 5c96556a80b..366264388b4 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -317,6 +317,7 @@ The main environment variables are: | `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely | | `SIM_DEBUG` | Print request diagnostics to stderr | | `SIM_NO_UPDATE_CHECK` | Turn off update notices | +| `SIM_TELEMETRY_DISABLED` | Turn off anonymous usage reporting (`DO_NOT_TRACK=1` also works) | On eligible interactive invocations, `sim` uses a daily cache before asking `registry.npmjs.org` what is published under the `latest` tag and prints an @@ -334,6 +335,22 @@ use the public default; non-empty malformed or non-HTTP(S) values fail closed. The full list of cases where it stays quiet is in the [configuration guide](https://docs.sim.ai/cli/configuration). +## Usage data + +The CLI reports anonymous usage data — which commands run, whether they +succeed, and how long they take — so the team can see how it is used. Nothing +you type is sent: no argument or flag values, paths, ids, error messages, or +credentials. The first interactive run prints a notice and is not reported. + +```bash +sim telemetry status +sim telemetry disable +``` + +`DO_NOT_TRACK=1` or `SIM_TELEMETRY_DISABLED=1` in the environment also turns it +off. The full description of what is sent is in the +[usage data guide](https://docs.sim.ai/cli/usage-data). + ## Documentation - [CLI documentation](https://docs.sim.ai/cli) @@ -342,6 +359,7 @@ The full list of cases where it stays quiet is in the - [Profiles and configuration](https://docs.sim.ai/cli/configuration) - [Scripting](https://docs.sim.ai/cli/scripting) - [Troubleshooting](https://docs.sim.ai/cli/troubleshooting) +- [Usage data](https://docs.sim.ai/cli/usage-data) ## License diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 6fa86a4c51f..c6ec88be5f1 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -11,7 +11,7 @@ }, "scripts": { "prebuild": "bun run clean", - "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js", + "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --env='SIM_CLI_TELEMETRY_*' --outfile=dist/index.js", "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", diff --git a/packages/sim-cli/src/commands/telemetry.test.ts b/packages/sim-cli/src/commands/telemetry.test.ts new file mode 100644 index 00000000000..6df6124fed7 --- /dev/null +++ b/packages/sim-cli/src/commands/telemetry.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readTelemetryState } from '../telemetry/index' +import { telemetryCommand } from './telemetry' + +let dir: string +let output: string[] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + vi.stubEnv('SIM_CONFIG_DIR', dir) + output = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + output.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +function run(...args: string[]): Promise { + const root = new Command('sim').exitOverride() + root.addCommand(telemetryCommand()) + return root.parseAsync(['node', 'sim', 'telemetry', ...args]) +} + +describe('sim telemetry', () => { + it('saves the disable setting and reports the state', async () => { + await run('disable') + + expect(readTelemetryState(join(dir, 'telemetry.json'))?.enabled).toBe(false) + expect(output[0]).toMatch(/off/) + expect(output[0]).toContain('sim telemetry enable') + }) + + it('reports the environment override rather than the saved setting', async () => { + vi.stubEnv('DO_NOT_TRACK', '1') + + await run('enable') + + expect(readTelemetryState(join(dir, 'telemetry.json'))?.enabled).toBe(true) + expect(output[0]).toContain('DO_NOT_TRACK') + }) + + it('names a build without a destination', async () => { + await run('status') + + expect(output[0]).toMatch(/off: this build has no reporting destination/) + expect(output[1]).toContain('https://docs.sim.ai/cli/usage-data') + }) +}) diff --git a/packages/sim-cli/src/commands/telemetry.ts b/packages/sim-cli/src/commands/telemetry.ts new file mode 100644 index 00000000000..9bb8368c0a5 --- /dev/null +++ b/packages/sim-cli/src/commands/telemetry.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander' +import { + builtInIngestTarget, + DO_NOT_TRACK_VARIABLE, + loadTelemetryState, + TELEMETRY_DISABLED_VARIABLE, + type TelemetryStatus, + telemetryStatus, + writeTelemetryState, +} from '../telemetry/index' +import { USAGE_DATA_DOCS_URL } from '../telemetry/invocation' + +/** One line per state, naming the thing the user can change when it is off. */ +function describe(status: TelemetryStatus): string { + if (status.enabled) return 'Usage reporting is on.' + switch (status.reason) { + case 'do_not_track': + return `Usage reporting is off: ${DO_NOT_TRACK_VARIABLE} is set.` + case 'environment': + return `Usage reporting is off: ${TELEMETRY_DISABLED_VARIABLE} is set.` + case 'setting': + return 'Usage reporting is off. Turn it on with: sim telemetry enable' + case 'unconfigured': + return 'Usage reporting is off: this build has no reporting destination.' + } +} + +function currentStatus(): TelemetryStatus { + return telemetryStatus({ + env: process.env, + state: loadTelemetryState(), + configured: builtInIngestTarget() !== undefined, + }) +} + +/** + * Saves the setting, then reports the resulting state rather than the saved + * value: `enable` under `DO_NOT_TRACK=1` must not print "on" when nothing + * will be sent. + */ +function setEnabled(enabled: boolean): void { + writeTelemetryState({ ...loadTelemetryState(), enabled }) + console.log(describe(currentStatus())) +} + +export function telemetryCommand(): Command { + const telemetry = new Command('telemetry').description('Control anonymous usage reporting') + + telemetry + .command('status') + .description('Show whether usage reporting is on, and why not if it is off') + .action(() => { + console.log(describe(currentStatus())) + console.log(`Learn more: ${USAGE_DATA_DOCS_URL}`) + }) + + telemetry + .command('enable') + .description('Turn usage reporting on for this machine') + .action(() => setEnabled(true)) + + telemetry + .command('disable') + .description('Turn usage reporting off for this machine') + .action(() => setEnabled(false)) + + return telemetry +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index e7256a7ae77..7679ce4ba79 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -1,4 +1,4 @@ -export { configDir, configPath, credentialsPath } from './paths' +export { configDir, configPath, credentialsPath, telemetryStatePath } from './paths' export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, diff --git a/packages/sim-cli/src/config/json-file.ts b/packages/sim-cli/src/config/json-file.ts new file mode 100644 index 00000000000..61a03d32985 --- /dev/null +++ b/packages/sim-cli/src/config/json-file.ts @@ -0,0 +1,103 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname } from 'node:path' + +/** + * Small JSON state files under the config directory: the update-check cache + * and the telemetry state. Both are best-effort — a file that cannot be read + * or written must never fail the command that touched it — and both sit in a + * directory an attacker who controls the account could pre-populate, so reads + * are bounded and refuse symlinks, and writes replace atomically. + */ + +/** Makes adjacent temporary files unique across writes in this process. */ +let writeSequence = 0 + +/** + * Reads and parses a JSON file, or returns `null` for anything at all wrong. + * + * Follows no symlink and reads no more than `maxBytes`: the file lives where + * the user, or anything running as the user, can replace it, and the caller's + * only interest is in a small document it wrote itself. Shape validation is + * the caller's — this returns whatever JSON was there. + */ +export function readJsonFile(path: string, maxBytes: number): unknown { + let descriptor: number | null = null + try { + if (!lstatSync(path).isFile()) return null + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) + const stats = fstatSync(descriptor) + if (!stats.isFile() || stats.size > maxBytes) return null + + const buffer = Buffer.allocUnsafe(maxBytes + 1) + let bytesRead = 0 + while (bytesRead < buffer.byteLength) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.byteLength - bytesRead, + bytesRead + ) + if (count === 0) break + bytesRead += count + } + if (bytesRead > maxBytes) return null + + return JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) + } catch { + return null + } finally { + if (descriptor !== null) { + try { + closeSync(descriptor) + } catch {} + } + } +} + +/** + * Replaces a JSON file atomically, creating its directory if needed. + * + * An exclusive adjacent temporary file renamed into place means a reader never + * sees a partial document and a linked target is never modified through the + * link. Failures are swallowed: the callers are caches and preferences whose + * loss costs one extra request or one repeated notice. + */ +export function writeJsonFile(path: string, value: unknown, mode = 0o644): void { + let descriptor: number | null = null + let temporaryCreated = false + const temporaryPath = `${path}.${process.pid}.${Date.now()}.${writeSequence++}.tmp` + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + descriptor = openSync(temporaryPath, 'wx', mode) + temporaryCreated = true + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`) + closeSync(descriptor) + descriptor = null + renameSync(temporaryPath, path) + temporaryCreated = false + } catch { + } finally { + if (descriptor !== null) { + try { + closeSync(descriptor) + } catch {} + } + if (temporaryCreated) { + try { + unlinkSync(temporaryPath) + } catch {} + } + } +} diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts index 9618931e080..d7620fd4eb6 100644 --- a/packages/sim-cli/src/config/paths.ts +++ b/packages/sim-cli/src/config/paths.ts @@ -33,3 +33,14 @@ export function credentialsPath(): string { export function updateCachePath(): string { return join(configDir(), 'update-check.json') } + +/** + * Where usage telemetry keeps its device id, session, and on/off setting. + * + * State rather than configuration, so it follows `SIM_CONFIG_DIR` the way the + * update cache does and gets no override of its own. Deleting it forgets the + * device id and shows the first-run notice again; nothing else is lost. + */ +export function telemetryStatePath(): string { + return join(configDir(), 'telemetry.json') +} diff --git a/packages/sim-cli/src/environment.ts b/packages/sim-cli/src/environment.ts new file mode 100644 index 00000000000..0fcee5907ef --- /dev/null +++ b/packages/sim-cli/src/environment.ts @@ -0,0 +1,55 @@ +/** + * Facts about the process environment that more than one feature reads. + * + * The update notice and usage telemetry both suppress themselves in CI and + * both read `SIM_*` switches; one definition keeps the two from disagreeing + * about what "on" or "in CI" means. + */ + +/** Covers CI jobs that allocate a terminal despite being non-interactive. */ +const CI_VARIABLES = [ + 'CI', + 'GITHUB_ACTIONS', + 'JENKINS_URL', + 'TEAMCITY_VERSION', + 'BUILDKITE', +] as const + +/** Anything but unset, empty, `0` or `false` turns a switch on. */ +export function isEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + return normalized !== '' && normalized !== '0' && normalized !== 'false' +} + +/** Whether any of the {@link CI_VARIABLES} says this is a CI job. */ +export function isCi(env: NodeJS.ProcessEnv = process.env): boolean { + return CI_VARIABLES.some((variable) => isEnabled(env[variable])) +} + +/** + * The Node proxy flags this process was started with, for a child that must + * reach the network the same way. + */ +export function proxyExecArgv(): string[] { + return process.execArgv.filter( + (argument) => argument === '--use-env-proxy' || argument === '--no-use-env-proxy' + ) +} + +/** + * The process environment for a helper child: proxy and TLS settings intact, + * the named variables removed so a credential never reaches a process that + * does not need it, and `extra` added on top. + */ +export function childProcessEnv( + strip: readonly string[], + extra: NodeJS.ProcessEnv = {} +): NodeJS.ProcessEnv { + const env = { ...process.env } + const stripped = new Set(strip.map((name) => name.toLowerCase())) + for (const key of Object.keys(env)) { + if (stripped.has(key.toLowerCase())) delete env[key] + } + return { ...env, ...extra } +} diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index db155fcc0e5..ff7e08a669f 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,5 +1,7 @@ +import { CLIENT_INFO_HEADER } from '@sim/utils/client-info' import chalk from 'chalk' import type { ResolvedProfile, StoredCredential, StoredOAuthCredential } from '../config/index' +import { clientInfoHeader } from '../telemetry/client-info' import { USER_AGENT } from '../version' import { warnIfCredentialOverCleartext, warnIfProxyIgnored } from './environment' @@ -619,6 +621,7 @@ export class SimClient { : {}), accept: 'application/json', 'user-agent': USER_AGENT, + [CLIENT_INFO_HEADER]: clientInfoHeader(), ...(hasBody ? { 'content-type': 'application/json' } : {}), ...options.headers, }, diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 53c7b9a92ab..ecb31d618b0 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import chalk from 'chalk' +import type { Command } from 'commander' import { dump } from 'js-yaml' import { CliUpdateError } from '#sim-cli/update/install' import { ProfileConfigError } from './config/index' @@ -13,57 +14,73 @@ import { } from './http/client' import { sanitize } from './output/render' import { buildProgram } from './program' +import { createCommandTelemetry } from './telemetry/index' /** - * Anything the CLI can explain prints as one line and exits 1. An unexpected - * error keeps its stack trace — that is a bug in the CLI, and hiding it behind a - * friendly message would make it unreportable. + * Prints the one-line explanation for an error the CLI understands and returns + * the exit code it deserves, or `null` for an error it does not: that is a bug + * in the CLI, and hiding it behind a friendly message would make it + * unreportable, so the caller lets it keep its stack trace. + */ +function explainFailure(error: unknown, program: Command): number | null { + if (error instanceof ProfileConfigError || error instanceof CliUpdateError) { + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + return 1 + } + // `AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that + // elapses while the body is still being read — a large `files get`, say — + // surfaces here rather than inside the client. A user's own Ctrl-C raises + // `AbortError` instead, which is deliberately left alone. + if (isRequestTimeout(error)) { + console.error(chalk.red(`Error: the request timed out. ${RAISE_TIMEOUT_HINT}`)) + return 1 + } + if (error instanceof SimApiError) { + let output = program.opts().output + try { + output = clientFrom(program).profile.output + } catch { + /** Preserve the original error when configuration is invalid. */ + } + if (output === 'json' || output === 'yaml') { + const payload = { + error: { + code: error.code ?? 'CLI_ERROR', + message: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }, + } + process.stderr.write(output === 'json' ? `${JSON.stringify(payload)}\n` : dump(payload)) + return error.exitCode + } + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) { + console.error(chalk.dim(sanitize(line))) + } + } + return error.exitCode + } + return null +} + +/** + * Anything the CLI can explain prints as one line and exits with its code. A + * failure is reported here, where its class and code are known; every other + * way the process ends is reported from the exit listener telemetry installs. */ async function main() { + const telemetry = createCommandTelemetry() const program = buildProgram() + telemetry.observe(program) try { await program.parseAsync(process.argv) } catch (error) { - if (error instanceof ProfileConfigError || error instanceof CliUpdateError) { - console.error(chalk.red(`Error: ${sanitize(error.message)}`)) - process.exit(1) - } - // `AbortSignal.timeout` keeps firing after `fetch` resolves, so a bound that - // elapses while the body is still being read — a large `files get`, say — - // surfaces here rather than inside the client. A user's own Ctrl-C raises - // `AbortError` instead, which is deliberately left alone. - if (isRequestTimeout(error)) { - console.error(chalk.red(`Error: the request timed out. ${RAISE_TIMEOUT_HINT}`)) - process.exit(1) - } - if (error instanceof SimApiError) { - let output = program.opts().output - try { - output = clientFrom(program).profile.output - } catch { - /** Preserve the original error when configuration is invalid. */ - } - if (output === 'json' || output === 'yaml') { - const payload = { - error: { - code: error.code ?? 'CLI_ERROR', - message: error.message, - ...(error.details === undefined ? {} : { details: error.details }), - }, - } - process.stderr.write(output === 'json' ? `${JSON.stringify(payload)}\n` : dump(payload)) - process.exit(error.exitCode) - } - console.error(chalk.red(`Error: ${sanitize(error.message)}`)) - if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) - if (error.details !== undefined) { - for (const line of formatApiErrorDetails(error.details)) { - console.error(chalk.dim(sanitize(line))) - } - } - process.exit(error.exitCode) - } - throw error + const exitCode = explainFailure(error, program) + telemetry.complete({ exitCode: exitCode ?? 1, error }) + if (exitCode === null) throw error + process.exit(exitCode) } } diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index f64eef62d93..42125e2f4c6 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -5,6 +5,7 @@ import { configureCommand } from './commands/configure' import { attachCredentialCommands } from './commands/credentials' import { attachProtocolCommands } from './commands/protocol/index' import { attachSecretCommands } from './commands/secrets' +import { telemetryCommand } from './commands/telemetry' import { OUTPUT_FORMATS } from './config/index' import { assertNoReservedProgramFlags, @@ -145,6 +146,7 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addCommand(configureCommand()) const update = updateCommand() program.addCommand(update) + program.addCommand(telemetryCommand()) for (const command of buildGeneratedCommands()) { program.addCommand(command) diff --git a/packages/sim-cli/src/telemetry/client-info.test.ts b/packages/sim-cli/src/telemetry/client-info.test.ts new file mode 100644 index 00000000000..c61ef331c8b --- /dev/null +++ b/packages/sim-cli/src/telemetry/client-info.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_VERSION } from '../version' +import { clientInfoHeader } from './client-info' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-client-info-')) + vi.stubEnv('SIM_CONFIG_DIR', dir) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('clientInfoHeader', () => { + it('names the CLI, its runtime, the platform, and the driving agent', () => { + expect(clientInfoHeader({ CLAUDECODE: '1' })).toBe( + `cli/${CLI_VERSION}; node/${process.versions.node}; os/${process.platform}; arch/${process.arch}; agent/claude-code` + ) + }) + + it('withholds the agent when usage reporting is opted out', () => { + const header = clientInfoHeader({ CLAUDECODE: '1', DO_NOT_TRACK: '1' }) + expect(header).not.toContain('agent/') + expect(header).toContain(`cli/${CLI_VERSION}`) + }) +}) diff --git a/packages/sim-cli/src/telemetry/client-info.ts b/packages/sim-cli/src/telemetry/client-info.ts new file mode 100644 index 00000000000..b246ce2a5f5 --- /dev/null +++ b/packages/sim-cli/src/telemetry/client-info.ts @@ -0,0 +1,41 @@ +import { formatClientInfo } from '@sim/utils/client-info' +import { CLI_VERSION } from '../version' +import { detectCodingAgent } from './coding-agent' +import { telemetryStatus } from './policy' +import { loadTelemetryState } from './state' + +/** The process's own value; an explicit environment (tests) is never cached. */ +let cached: string | undefined + +/** + * The `X-Sim-Client-Info` value: the same facts as the user agent, in the + * header every official client sends, plus the AI coding agent driving this + * shell when one can be detected. The server reads this header, not the user + * agent, so a request from the CLI is attributed to the CLI on every log line + * and analytics event it produces. + * + * The agent is usage data, so it goes only where usage reporting is allowed: + * `DO_NOT_TRACK`, `SIM_TELEMETRY_DISABLED`, and `sim telemetry disable` all + * withhold it. Whether this build has a reporting destination is irrelevant — + * the server, not the CLI, is what records it. Computed once per process. + */ +export function clientInfoHeader(env: NodeJS.ProcessEnv = process.env): string { + if (env !== process.env) return buildClientInfoHeader(env) + cached ??= buildClientInfoHeader(env) + return cached +} + +function buildClientInfoHeader(env: NodeJS.ProcessEnv): string { + return formatClientInfo({ + surface: 'cli', + version: CLI_VERSION, + runtime: { name: 'node', version: process.versions.node }, + os: process.platform, + arch: process.arch, + ...(reportingAllowed(env) ? { agent: detectCodingAgent(env) } : {}), + }) +} + +function reportingAllowed(env: NodeJS.ProcessEnv): boolean { + return telemetryStatus({ env, state: loadTelemetryState(), configured: true }).enabled +} diff --git a/packages/sim-cli/src/telemetry/coding-agent.test.ts b/packages/sim-cli/src/telemetry/coding-agent.test.ts new file mode 100644 index 00000000000..583afe1e9d3 --- /dev/null +++ b/packages/sim-cli/src/telemetry/coding-agent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { detectCodingAgent } from './coding-agent' + +describe('detectCodingAgent', () => { + it('reports nothing for a person at a terminal', () => { + expect(detectCodingAgent({ TERM_PROGRAM: 'iTerm.app', SHELL: '/bin/zsh' })).toBeUndefined() + }) + + it.each([ + [{ CLAUDECODE: '1' }, 'claude-code'], + [{ CLAUDE_CODE: '1' }, 'claude-code'], + [{ CLAUDECODE: '1', CLAUDE_CODE_IS_COWORK: '1' }, 'cowork'], + [{ CODEX_THREAD_ID: 'thr_1' }, 'codex'], + [{ CODEX_SANDBOX: 'seatbelt' }, 'codex'], + [{ GEMINI_CLI: '1' }, 'gemini-cli'], + [{ CURSOR_AGENT: '1' }, 'cursor'], + [{ CURSOR_TRACE_ID: 'abc' }, 'cursor'], + [{ CURSOR_EXTENSION_HOST_ROLE: 'agent-exec' }, 'cursor'], + [{ OPENCODE: '1', AGENT: '1' }, 'opencode'], + [{ CLINE_ACTIVE: 'true' }, 'cline'], + [{ OZ_RUN_ID: 'run_1' }, 'warp'], + [{ PI_CODING_AGENT: 'true' }, 'pi'], + ])('recognises %o as %s', (env, expected) => { + expect(detectCodingAgent(env)).toBe(expected) + }) + + it('names Amp rather than the Claude Code marker it also sets', () => { + expect(detectCodingAgent({ AGENT: 'amp', CLAUDECODE: '1' })).toBe('amp') + expect(detectCodingAgent({ AMP_CURRENT_THREAD_ID: 'T-1', CLAUDECODE: '1' })).toBe('amp') + }) + + it('lets an agent declare its own name over every vendor marker', () => { + expect(detectCodingAgent({ AI_AGENT: 'Some-Agent_2', CLAUDECODE: '1' })).toBe('some-agent_2') + }) + + it('ignores a declared name that is not a well-formed token', () => { + expect(detectCodingAgent({ AI_AGENT: 'not a token', CLAUDECODE: '1' })).toBe('claude-code') + expect(detectCodingAgent({ AI_AGENT: 'x'.repeat(65) })).toBeUndefined() + }) + + it('ignores markers that only mean an agent is installed', () => { + expect(detectCodingAgent({ REPL_ID: 'abc', GOOSE_PROVIDER: 'x', AIDER_API_KEY: 'k' })).toBe( + undefined + ) + }) + + it('ignores a cursor role that is not the agent executor', () => { + expect(detectCodingAgent({ CURSOR_EXTENSION_HOST_ROLE: 'ui' })).toBeUndefined() + }) +}) diff --git a/packages/sim-cli/src/telemetry/coding-agent.ts b/packages/sim-cli/src/telemetry/coding-agent.ts new file mode 100644 index 00000000000..e0fd2c58b82 --- /dev/null +++ b/packages/sim-cli/src/telemetry/coding-agent.ts @@ -0,0 +1,85 @@ +/** + * Detects the AI coding agent whose shell this process runs in. + * + * Agents mark the shells they spawn with an environment variable, and the CLI + * reports that mark so usage driven by an agent can be told apart from a person + * at a terminal. The checks, their order, and the names follow the GitHub CLI + * (`internal/agents/detect.go`), which is the most complete verified table: + * generic conventions first, then vendor markers, with the more specific + * marker ahead of a broader one it implies (Amp sets `CLAUDECODE` too; Cowork + * is Claude Code plus its own flag). + * + * Only markers an agent sets on the shells it drives are consulted. Variables + * that merely mean an agent is installed or configured — `REPL_ID`, + * `GOOSE_PROVIDER`, `AIDER_*`, `COPILOT_*` — are deliberately absent, because + * they would attribute a person's own command to an agent. + */ + +/** The value an agent may declare itself with under the generic conventions. */ +const AGENT_NAME_PATTERN = /^[a-z0-9_-]+$/i +const MAX_AGENT_NAME_LENGTH = 64 + +interface AgentMarker { + readonly name: string + readonly matches: (env: NodeJS.ProcessEnv) => boolean +} + +const anyOf = + (...variables: readonly string[]) => + (env: NodeJS.ProcessEnv) => + variables.some((variable) => Boolean(env[variable])) + +/** Vendor markers, most specific first. */ +const AGENT_MARKERS: readonly AgentMarker[] = [ + { name: 'amp', matches: (env) => env.AGENT === 'amp' || Boolean(env.AMP_CURRENT_THREAD_ID) }, + { + name: 'codex', + matches: anyOf( + 'CODEX_THREAD_ID', + 'CODEX_SANDBOX', + 'CODEX_CI', + 'CODEX_SANDBOX_NETWORK_DISABLED' + ), + }, + { name: 'gemini-cli', matches: anyOf('GEMINI_CLI') }, + { name: 'opencode', matches: anyOf('OPENCODE') }, + { name: 'antigravity', matches: anyOf('ANTIGRAVITY_AGENT') }, + { name: 'augment', matches: anyOf('AUGMENT_AGENT') }, + { name: 'cline', matches: anyOf('CLINE_ACTIVE') }, + { name: 'cowork', matches: anyOf('CLAUDE_CODE_IS_COWORK') }, + { name: 'claude-code', matches: anyOf('CLAUDECODE', 'CLAUDE_CODE') }, + { + name: 'cursor', + matches: (env) => + anyOf('CURSOR_AGENT', 'CURSOR_TRACE_ID')(env) || + env.CURSOR_EXTENSION_HOST_ROLE === 'agent-exec', + }, + { name: 'warp', matches: anyOf('OZ_RUN_ID') }, + { name: 'pi', matches: anyOf('PI_CODING_AGENT') }, + { name: 'crush', matches: anyOf('CRUSH') }, +] + +/** A name an agent declared for itself, when it is a well-formed token. */ +function declaredAgentName(value: string | undefined): string | undefined { + const trimmed = value?.trim().toLowerCase() + if (!trimmed || trimmed.length > MAX_AGENT_NAME_LENGTH) return undefined + return AGENT_NAME_PATTERN.test(trimmed) ? trimmed : undefined +} + +/** + * The agent driving this shell, or `undefined` for a person at a terminal. + * + * `AI_AGENT` and `AGENT` are the two generic conventions agents have converged + * on for naming themselves and win over vendor markers when set. `AGENT` is + * consulted only when it carries a name: OpenCode sets it to `1`, which names + * nothing, and its own marker handles it. + */ +export function detectCodingAgent(env: NodeJS.ProcessEnv = process.env): string | undefined { + const declared = declaredAgentName(env.AI_AGENT) + if (declared) return declared + + const generic = declaredAgentName(env.AGENT) + if (generic && generic !== '1') return generic + + return AGENT_MARKERS.find((marker) => marker.matches(env))?.name +} diff --git a/packages/sim-cli/src/telemetry/index.ts b/packages/sim-cli/src/telemetry/index.ts new file mode 100644 index 00000000000..8fffcc31156 --- /dev/null +++ b/packages/sim-cli/src/telemetry/index.ts @@ -0,0 +1,10 @@ +export { clientInfoHeader } from './client-info' +export { createCommandTelemetry } from './invocation' +export { + DO_NOT_TRACK_VARIABLE, + TELEMETRY_DISABLED_VARIABLE, + type TelemetryStatus, + telemetryStatus, +} from './policy' +export { loadTelemetryState, readTelemetryState, writeTelemetryState } from './state' +export { builtInIngestTarget } from './transport' diff --git a/packages/sim-cli/src/telemetry/invocation.test.ts b/packages/sim-cli/src/telemetry/invocation.test.ts new file mode 100644 index 00000000000..81a803f31ba --- /dev/null +++ b/packages/sim-cli/src/telemetry/invocation.test.ts @@ -0,0 +1,319 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../http/client' +import { CLI_VERSION } from '../version' +import { + COMMAND_EVENT, + type CommandEventProperties, + type CommandTelemetryOptions, + createCommandTelemetry, + FIRST_RUN_NOTICE, +} from './invocation' +import { loadTelemetryState, readTelemetryState, writeTelemetryState } from './state' +import type { CaptureRequest, IngestTarget } from './transport' + +let dir: string +let statePath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + statePath = join(dir, 'telemetry.json') + vi.stubEnv('SIM_CONFIG_DIR', dir) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(dir, { recursive: true, force: true }) +}) + +const TARGET = { key: 'phc_test', host: 'https://us.i.posthog.com' } +const NOW = new Date('2026-09-10T12:00:00.000Z') + +/** A program shaped like the shipped one: root globals, a group with a leaf, and the telemetry group. */ +function buildProgram(): Command { + const program = new Command('sim') + .exitOverride() + .option('-P, --profile ') + .option('--endpoint ') + .option('-w, --workspace ') + .option('--output ') + const workflows = new Command('workflows') + workflows + .command('list') + .option('--limit ') + .option('--all') + .action(() => {}) + workflows + .command('get') + .argument('') + .action(() => {}) + workflows.command('fail').action(() => { + throw new SimApiError('Not found', 404, 'NOT_FOUND') + }) + program.addCommand(workflows) + const telemetry = new Command('telemetry') + telemetry.command('disable').action(() => {}) + program.addCommand(telemetry) + return program +} + +type SentRequest = CaptureRequest + +/** The one request a harness sent, typed as the CLI builds it. */ +function sentBy(send: { mock: { calls: unknown[][] } }): SentRequest { + return send.mock.calls[0][1] as SentRequest +} + +function harness(overrides: Partial = {}) { + const send = vi.fn<(target: IngestTarget, request: CaptureRequest) => void>() + const write = vi.fn<(message: string) => void>() + const exitListeners: Array<(exitCode: number) => void> = [] + const telemetry = createCommandTelemetry({ + env: {}, + ingestTarget: () => TARGET, + onExit: (listener) => exitListeners.push(listener), + send, + now: () => NOW, + elapsed: () => 1432.4, + stdoutIsTty: false, + stderrIsTty: false, + write, + ...overrides, + }) + const program = buildProgram() + telemetry.observe(program) + return { + telemetry, + program, + send, + write, + exit: (code: number) => exitListeners.forEach((l) => l(code)), + } +} + +async function run(program: Command, argv: string[]): Promise { + try { + await program.parseAsync(['node', 'sim', ...argv]) + return undefined + } catch (error) { + return error + } +} + +describe('command telemetry', () => { + it('reports the command path, typed flag names, and argument count — never values', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['--output', 'json', 'workflows', 'list', '--limit', '5', '--all']) + telemetry.complete({ exitCode: 0 }) + + expect(send).toHaveBeenCalledOnce() + const request = sentBy(send) + expect(request.event).toBe(COMMAND_EVENT) + expect(request.api_key).toBe('phc_test') + expect(request.timestamp).toBe(NOW.toISOString()) + expect(request.properties).toMatchObject({ + $lib: 'sim-cli', + $lib_version: CLI_VERSION, + $process_person_profile: false, + session_sequence: 1, + surface: 'cli', + command: 'workflows list', + flags: expect.arrayContaining(['--limit', '--all', '--output']), + arg_count: 0, + exit_code: 0, + duration_ms: 1432, + cli_version: CLI_VERSION, + node_version: process.versions.node, + os: process.platform, + arch: process.arch, + is_tty: false, + is_ci: false, + endpoint_kind: 'hosted', + }) + expect(JSON.stringify(request)).not.toContain('json') + expect(JSON.stringify(request)).not.toContain('"5"') + }) + + it('counts positional arguments without recording them', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'get', 'wf_secret_id']) + telemetry.complete({ exitCode: 0 }) + + expect(sentBy(send).properties).toMatchObject({ command: 'workflows get', arg_count: 1 }) + expect(JSON.stringify(sentBy(send))).not.toContain('wf_secret_id') + }) + + it('records a failure by class, code, and status, never by message', async () => { + const { telemetry, program, send } = harness() + + const error = await run(program, ['workflows', 'fail']) + telemetry.complete({ exitCode: 1, error }) + + expect(sentBy(send).properties).toMatchObject({ + exit_code: 1, + error_name: 'SimApiError', + error_code: 'NOT_FOUND', + http_status: 404, + }) + expect(JSON.stringify(sentBy(send))).not.toContain('Not found') + }) + + it('ties commands on one device into a numbered session', async () => { + const first = harness() + await run(first.program, ['workflows', 'list']) + first.telemetry.complete({ exitCode: 0 }) + + const second = harness() + await run(second.program, ['workflows', 'get', 'wf_1']) + second.telemetry.complete({ exitCode: 0 }) + + const [a, b] = [sentBy(first.send), sentBy(second.send)] + expect(a.distinct_id).toBe(b.distinct_id) + expect(a.properties.$session_id).toBe(b.properties.$session_id) + expect(b.properties.session_sequence).toBe(2) + expect(readTelemetryState(statePath)?.session?.sequence).toBe(2) + }) + + it('shows the notice when only stdout is piped, as in `sim … | jq`', async () => { + const { telemetry, program, send, write } = harness({ stdoutIsTty: false, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(write).toHaveBeenCalledWith(FIRST_RUN_NOTICE) + expect(send).not.toHaveBeenCalled() + }) + + it('honours an opt-out saved while the command was running', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'list']) + writeTelemetryState({ ...loadTelemetryState(statePath), enabled: false }, statePath) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(readTelemetryState(statePath)?.enabled).toBe(false) + }) + + it('reports the coding agent driving the shell', async () => { + const { telemetry, program, send } = harness({ env: { CLAUDECODE: '1' } }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(sentBy(send).properties.coding_agent).toBe('claude-code') + }) + + it('shows the first-run notice on a terminal and does not report that run', async () => { + const { telemetry, program, send, write } = harness({ stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(write).toHaveBeenCalledWith(FIRST_RUN_NOTICE) + expect(send).not.toHaveBeenCalled() + expect(readTelemetryState(statePath)?.noticeShownAt).toBe(NOW.toISOString()) + + const next = harness({ stderrIsTty: true }) + await run(next.program, ['workflows', 'list']) + next.telemetry.complete({ exitCode: 0 }) + + expect(next.write).not.toHaveBeenCalled() + expect(next.send).toHaveBeenCalledOnce() + }) + + it('shows no notice when stderr is redirected or in CI, and still reports', async () => { + const piped = harness({ stdoutIsTty: true, stderrIsTty: false }) + await run(piped.program, ['workflows', 'list']) + piped.telemetry.complete({ exitCode: 0 }) + + expect(piped.write).not.toHaveBeenCalled() + expect(piped.send).toHaveBeenCalledOnce() + + const ci = harness({ stderrIsTty: true, env: { CI: 'true' } }) + await run(ci.program, ['workflows', 'list']) + ci.telemetry.complete({ exitCode: 0 }) + + expect(ci.write).not.toHaveBeenCalled() + expect(sentBy(ci.send).properties.is_ci).toBe(true) + }) + + it.each([ + ['DO_NOT_TRACK', { DO_NOT_TRACK: '1' }], + ['SIM_TELEMETRY_DISABLED', { SIM_TELEMETRY_DISABLED: '1' }], + ])('reports nothing when %s is set', async (_name, env) => { + const { telemetry, program, send, write } = harness({ env, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + }) + + it('reports nothing after sim telemetry disable', async () => { + writeTelemetryState({ ...loadTelemetryState(statePath), enabled: false }, statePath) + const { telemetry, program, send } = harness() + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + }) + + it('reports nothing from a build with no destination', async () => { + const { telemetry, program, send, write } = harness({ + ingestTarget: () => undefined, + stderrIsTty: true, + }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + }) + + it('never reports the telemetry commands themselves', async () => { + const { telemetry, program, send } = harness() + + await run(program, ['telemetry', 'disable']) + telemetry.complete({ exitCode: 0 }) + + expect(send).not.toHaveBeenCalled() + }) + + it('reports from the process exit event, once, with the real exit code', async () => { + const { telemetry, program, send, exit } = harness() + + await run(program, ['workflows', 'list']) + exit(3) + exit(3) + telemetry.complete({ exitCode: 0 }) + + expect(send).toHaveBeenCalledOnce() + expect(sentBy(send).properties.exit_code).toBe(3) + }) + + it('never touches the state file when reporting is switched off', async () => { + const { telemetry, program } = harness({ env: { DO_NOT_TRACK: '1' }, stderrIsTty: true }) + + await run(program, ['workflows', 'list']) + telemetry.complete({ exitCode: 0 }) + + expect(existsSync(statePath)).toBe(false) + }) + + it('reports nothing when no command ran', () => { + const { telemetry, send } = harness() + + telemetry.complete({ exitCode: 1, error: new Error('usage') }) + + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/telemetry/invocation.ts b/packages/sim-cli/src/telemetry/invocation.ts new file mode 100644 index 00000000000..1370cf73aa7 --- /dev/null +++ b/packages/sim-cli/src/telemetry/invocation.ts @@ -0,0 +1,306 @@ +import type { Command } from 'commander' +import { profileFrom } from '../context' +import { isCi } from '../environment' +import { SimApiError } from '../http/client' +import { CLI_VERSION } from '../version' +import { detectCodingAgent } from './coding-agent' +import { telemetryStatus } from './policy' +import { loadTelemetryState, nextSession, type TelemetryState, writeTelemetryState } from './state' +import { + builtInIngestTarget, + type CaptureRequest, + type IngestTarget, + sendCapture, +} from './transport' + +/** + * Usage reporting for one CLI invocation: what ran, whether it worked, and + * how long it took. One event per command, sent after the command finishes. + * + * What is sent is the smallest set that answers "how is the CLI used": the + * command's name, the names of the flags typed, how many positional arguments + * there were, the exit code, the duration, and the runtime it ran on. What is + * never sent is anything the user typed — no argument values, flag values, + * paths, or error messages — matching the policy the Stripe, GitHub, and + * Supabase CLIs converge on. See {@link CommandEventProperties}. + */ + +export const COMMAND_EVENT = 'cli_command_executed' + +/** The user-facing name of this reporting, as `$lib` in each event. */ +const LIBRARY_NAME = 'sim-cli' + +/** The command group that manages reporting is never itself reported. */ +const EXCLUDED_ROOT_COMMAND = 'telemetry' + +export const USAGE_DATA_DOCS_URL = 'https://docs.sim.ai/cli/usage-data' + +/** + * Printed once, the first time reporting would happen on an interactive + * terminal, the way the Vercel and Next.js notices are. The run that shows it + * is not reported, so nothing leaves the machine before the user has read + * that something will. + */ +export const FIRST_RUN_NOTICE = [ + 'Sim collects anonymous usage data to improve the CLI: which commands run, whether', + 'they succeed, and how long they take. Nothing you type is sent.', + `Learn more: ${USAGE_DATA_DOCS_URL}`, + 'Turn it off: sim telemetry disable', + '', +].join('\n') + +/** + * The event's properties. Kept in one place so the documentation page can be + * checked against it and a new property is a deliberate addition here. + */ +export interface CommandEventProperties { + /** Analytics library conventions: the reporting client and its version. */ + $lib: typeof LIBRARY_NAME + $lib_version: string + /** Events are anonymous and must not create a person profile per device. */ + $process_person_profile: false + /** Commands close in time share a session, so a sequence of commands can be read back. */ + $session_id: string + /** The command's position within its session. */ + session_sequence: number + /** The same surface name the server stamps on requests carrying `X-Sim-Client-Info`. */ + surface: 'cli' + /** The command's path, such as `workflows list` — never its arguments. */ + command: string + /** The long names of flags that were typed, such as `--output`; never their values. */ + flags: string[] + /** How many positional arguments were given; never what they were. */ + arg_count: number + exit_code: number + /** Time from process start to completion, in milliseconds. */ + duration_ms: number + /** The failure's class name, such as `SimApiError`; never its message. */ + error_name?: string + /** The API's machine-readable error code, such as `NOT_FOUND`. */ + error_code?: string + http_status?: number + cli_version: string + node_version: string + os: string + arch: string + /** Whether stdout was a terminal, which separates people from scripts. */ + is_tty: boolean + is_ci: boolean + /** The AI coding agent driving this shell, when one could be detected. */ + coding_agent?: string + /** Whether the profile targets Sim's hosted deployment or a self-hosted one; never the address. */ + endpoint_kind?: 'hosted' | 'self_hosted' +} + +export interface InvocationOutcome { + exitCode: number + error?: unknown +} + +export interface CommandTelemetryOptions { + env?: NodeJS.ProcessEnv + ingestTarget?: () => IngestTarget | undefined + send?: typeof sendCapture + now?: () => Date + /** Milliseconds since the process started, for the duration. */ + elapsed?: () => number + /** Whether stdout is a terminal: the `is_tty` property, which separates people from scripts. */ + stdoutIsTty?: boolean + /** Whether stderr is a terminal: where the notice would go, so whether anyone would see it. */ + stderrIsTty?: boolean + /** Where the first-run notice goes; stderr, so piped output stays clean. */ + write?: (message: string) => void + /** Registers the listener that reports when the process ends; the real process by default. */ + onExit?: (listener: (exitCode: number) => void) => void +} + +export interface CommandTelemetry { + /** Installs the hook that records which command is about to run, and the exit listener that reports it. */ + observe(program: Command): void + /** + * Reports the recorded command, given how it ended. Idempotent, and a no-op + * when nothing was recorded: the entrypoint calls it with the failure it + * explained, and the exit listener calls it for every other way out. + */ + complete(outcome: InvocationOutcome): void +} + +interface RecordedInvocation { + action: Command + command: string + flags: string[] + argCount: number + state: TelemetryState + /** Set when this run printed the first-run notice and is therefore not reported. */ + noticeShown: boolean +} + +/** The command's own name and its ancestors', root excluded, in typing order. */ +function commandPath(command: Command): string[] { + const names: string[] = [] + for (let current: Command | null = command; current?.parent; current = current.parent) { + names.unshift(current.name()) + } + return names +} + +/** + * The flags typed on the command line, on the leaf and every ancestor, so + * root globals like `--output` count. Only source `cli`: a value that came + * from the environment or a default was not something the user typed here. + */ +function typedFlags(command: Command): string[] { + const flags = new Set() + for (let current: Command | null = command; current; current = current.parent) { + for (const option of current.options) { + if (current.getOptionValueSource(option.attributeName()) !== 'cli') continue + const name = option.long ?? option.short + if (name) flags.add(name) + } + } + return [...flags] +} + +/** Whether the profile points at Sim's hosted deployment; `undefined` when no profile resolves. */ +function endpointKind(command: Command): CommandEventProperties['endpoint_kind'] | undefined { + try { + const hostname = new URL(profileFrom(command).endpoint).hostname + return hostname === 'sim.ai' || hostname.endsWith('.sim.ai') ? 'hosted' : 'self_hosted' + } catch { + return undefined + } +} + +function failureProperties( + error: unknown +): Pick { + if (error === undefined) return {} + if (!(error instanceof Error)) return { error_name: 'unknown' } + const properties: ReturnType = { error_name: error.name } + if (error instanceof SimApiError) { + if (error.code) properties.error_code = error.code + if (error.status > 0) properties.http_status = error.status + } + return properties +} + +const listenForProcessExit = (listener: (exitCode: number) => void): void => { + process.once('exit', listener) +} + +export function createCommandTelemetry(options: CommandTelemetryOptions = {}): CommandTelemetry { + const env = options.env ?? process.env + const ingestTarget = options.ingestTarget ?? builtInIngestTarget + const send = options.send ?? sendCapture + const now = options.now ?? (() => new Date()) + const elapsed = options.elapsed ?? (() => performance.now()) + const stdoutIsTty = options.stdoutIsTty ?? process.stdout.isTTY === true + const stderrIsTty = options.stderrIsTty ?? process.stderr.isTTY === true + const write = options.write ?? ((message: string) => void process.stderr.write(message)) + const onExit = options.onExit ?? listenForProcessExit + + let recorded: RecordedInvocation | undefined + + /** + * Whether this run could report at all, decided from the environment and the + * build alone so a run that cannot report never touches the state file. + */ + function isReportable(state: Pick): boolean { + return telemetryStatus({ env, state, configured: ingestTarget() !== undefined }).enabled + } + + /** + * Shows the notice on the first interactive run that would report, and + * remembers having done so. Gated on stderr, where it is written: not in CI, + * where nobody is reading, and not when stderr is redirected, where it would + * land in a log — while `sim … | jq`, which redirects only stdout, still + * shows it. + */ + function showNoticeIfDue(state: TelemetryState): boolean { + if (state.noticeShownAt || !stderrIsTty || isCi(env)) return false + write(FIRST_RUN_NOTICE) + writeTelemetryState({ ...state, noticeShownAt: now().toISOString() }) + return true + } + + function complete(outcome: InvocationOutcome): void { + const invocation = recorded + recorded = undefined + if (!invocation || invocation.noticeShown) return + const target = ingestTarget() + if (!target) return + + /** + * Re-read rather than reuse the snapshot from before the command ran: an + * opt-out saved meanwhile — `sim telemetry disable` in another terminal, + * during a long command — must win, and the write below must not put the + * stale snapshot back over it. + */ + const state = loadTelemetryState() + if (!isReportable(state)) return + + const timestamp = now() + const session = nextSession(state, timestamp) + writeTelemetryState({ ...state, session }) + + const properties: CommandEventProperties = { + $lib: LIBRARY_NAME, + $lib_version: CLI_VERSION, + $process_person_profile: false, + $session_id: session.id, + session_sequence: session.sequence, + surface: 'cli', + command: invocation.command, + flags: invocation.flags, + arg_count: invocation.argCount, + exit_code: outcome.exitCode, + duration_ms: Math.round(elapsed()), + ...failureProperties(outcome.error), + cli_version: CLI_VERSION, + node_version: process.versions.node, + os: process.platform, + arch: process.arch, + is_tty: stdoutIsTty, + is_ci: isCi(env), + } + const kind = endpointKind(invocation.action) + if (kind) properties.endpoint_kind = kind + const agent = detectCodingAgent(env) + if (agent) properties.coding_agent = agent + + send(target, { + api_key: target.key, + event: COMMAND_EVENT, + distinct_id: state.deviceId, + timestamp: timestamp.toISOString(), + properties, + } satisfies CaptureRequest) + } + + return { + observe(program) { + program.hook('preAction', (_root, action) => { + const path = commandPath(action) + if (path[0] === EXCLUDED_ROOT_COMMAND || !isReportable({})) return + const state = loadTelemetryState() + if (!isReportable(state)) return + recorded = { + action, + command: path.join(' '), + flags: typedFlags(action), + argCount: action.args.length, + state, + noticeShown: showNoticeIfDue(state), + } + }) + /** + * Commands end in more ways than one: a handler that calls `process.exit` + * itself, a `process.exitCode` set on the way out, or the entrypoint's + * own exit. Reporting from the exit event sees all of them, and the + * sender is a synchronous spawn, which is what an exit listener allows. + */ + onExit((exitCode) => complete({ exitCode })) + }, + complete, + } +} diff --git a/packages/sim-cli/src/telemetry/policy.test.ts b/packages/sim-cli/src/telemetry/policy.test.ts new file mode 100644 index 00000000000..acfd18f5990 --- /dev/null +++ b/packages/sim-cli/src/telemetry/policy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { telemetryStatus } from './policy' + +describe('telemetryStatus', () => { + it('is on when nothing turns it off and the build can report', () => { + expect(telemetryStatus({ env: {}, state: {}, configured: true })).toEqual({ enabled: true }) + }) + + it.each(['1', 'true', 'TRUE', 'yes'])('honours DO_NOT_TRACK=%s before anything else', (value) => { + expect( + telemetryStatus({ env: { DO_NOT_TRACK: value }, state: { enabled: true }, configured: true }) + ).toEqual({ enabled: false, reason: 'do_not_track' }) + }) + + it.each(['0', 'false', ''])('ignores DO_NOT_TRACK=%s', (value) => { + expect(telemetryStatus({ env: { DO_NOT_TRACK: value }, state: {}, configured: true })).toEqual({ + enabled: true, + }) + }) + + it('names the environment switch ahead of the saved setting', () => { + expect( + telemetryStatus({ + env: { SIM_TELEMETRY_DISABLED: '1' }, + state: { enabled: false }, + configured: true, + }) + ).toEqual({ enabled: false, reason: 'environment' }) + }) + + it('names the saved setting ahead of a missing destination', () => { + expect(telemetryStatus({ env: {}, state: { enabled: false }, configured: false })).toEqual({ + enabled: false, + reason: 'setting', + }) + }) + + it('is off in a build with no destination', () => { + expect(telemetryStatus({ env: {}, state: {}, configured: false })).toEqual({ + enabled: false, + reason: 'unconfigured', + }) + }) +}) diff --git a/packages/sim-cli/src/telemetry/policy.ts b/packages/sim-cli/src/telemetry/policy.ts new file mode 100644 index 00000000000..2ba9979643b --- /dev/null +++ b/packages/sim-cli/src/telemetry/policy.ts @@ -0,0 +1,48 @@ +import { isEnabled } from '../environment' +import type { TelemetryState } from './state' + +/** + * The switch every CLI that reports usage honours before its own: `DO_NOT_TRACK=1` + * expresses a lack of consent to any usage reporting, from any tool. Spec at + * consoledonottrack.com; `true` is accepted as well, as Turborepo, Wrangler, + * and the GitHub CLI do. + */ +export const DO_NOT_TRACK_VARIABLE = 'DO_NOT_TRACK' + +/** The CLI's own switch, for turning reporting off in one environment or CI job. */ +export const TELEMETRY_DISABLED_VARIABLE = 'SIM_TELEMETRY_DISABLED' + +export type TelemetryDisabledReason = + /** `DO_NOT_TRACK` is set. */ + | 'do_not_track' + /** `SIM_TELEMETRY_DISABLED` is set. */ + | 'environment' + /** The user ran `sim telemetry disable`. */ + | 'setting' + /** This build was made without a reporting destination, so there is nowhere to send to. */ + | 'unconfigured' + +export type TelemetryStatus = + | { enabled: true } + | { enabled: false; reason: TelemetryDisabledReason } + +export interface TelemetryStatusInput { + env: NodeJS.ProcessEnv + state: Pick + /** Whether the build carries a reporting destination. */ + configured: boolean +} + +/** + * Whether usage reporting is on, and if not, the first reason that turns it + * off. The order is the order of authority: the universal opt-out, then the + * environment, then the saved setting, then whether this build can report at + * all — so `sim telemetry status` names the reason the user can act on. + */ +export function telemetryStatus({ env, state, configured }: TelemetryStatusInput): TelemetryStatus { + if (isEnabled(env[DO_NOT_TRACK_VARIABLE])) return { enabled: false, reason: 'do_not_track' } + if (isEnabled(env[TELEMETRY_DISABLED_VARIABLE])) return { enabled: false, reason: 'environment' } + if (state.enabled === false) return { enabled: false, reason: 'setting' } + if (!configured) return { enabled: false, reason: 'unconfigured' } + return { enabled: true } +} diff --git a/packages/sim-cli/src/telemetry/state.test.ts b/packages/sim-cli/src/telemetry/state.test.ts new file mode 100644 index 00000000000..c5615dca04d --- /dev/null +++ b/packages/sim-cli/src/telemetry/state.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + loadTelemetryState, + nextSession, + readTelemetryState, + SESSION_IDLE_MS, + type TelemetryState, + writeTelemetryState, +} from './state' + +let dir: string +let path: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-telemetry-')) + path = join(dir, 'telemetry.json') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('telemetry state', () => { + it('mints a fresh device id when there is no file, without writing one', () => { + const state = loadTelemetryState(path) + + expect(state.deviceId).toMatch(UUID) + expect(() => statSync(path)).toThrow() + }) + + it('round-trips through the file, readable by the owner only', () => { + const state: TelemetryState = { + version: 1, + deviceId: 'device-1', + enabled: false, + noticeShownAt: '2026-09-10T00:00:00.000Z', + session: { id: 'session-1', lastActiveAt: '2026-09-10T00:00:00.000Z', sequence: 3 }, + } + + writeTelemetryState(state, path) + + expect(readTelemetryState(path)).toEqual(state) + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(readFileSync(path, 'utf8').endsWith('\n')).toBe(true) + }) + + it.each([ + ['not json', 'nope'], + ['an unknown version', JSON.stringify({ version: 2, deviceId: 'd' })], + ['a missing device id', JSON.stringify({ version: 1 })], + ['an empty device id', JSON.stringify({ version: 1, deviceId: '' })], + ])('treats %s as absent', (_label, content) => { + writeFileSync(path, content) + + expect(readTelemetryState(path)).toBeNull() + expect(loadTelemetryState(path).deviceId).toMatch(UUID) + }) + + it('drops a malformed session or notice stamp but keeps the device id', () => { + writeFileSync( + path, + JSON.stringify({ + version: 1, + deviceId: 'device-1', + noticeShownAt: 'yesterday', + session: { id: 'session-1', lastActiveAt: 'never', sequence: -1 }, + }) + ) + + expect(readTelemetryState(path)).toEqual({ version: 1, deviceId: 'device-1' }) + }) +}) + +describe('nextSession', () => { + const state: TelemetryState = { version: 1, deviceId: 'device-1' } + const now = new Date('2026-09-10T12:00:00.000Z') + + it('starts a first session at sequence one', () => { + const session = nextSession(state, now) + + expect(session).toEqual({ + id: expect.stringMatching(UUID), + lastActiveAt: now.toISOString(), + sequence: 1, + }) + }) + + it('continues a session that was active within the idle window', () => { + const recent = new Date(now.getTime() - SESSION_IDLE_MS + 1000) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: recent.toISOString(), sequence: 4 } }, + now + ) + + expect(session).toEqual({ id: 'session-1', lastActiveAt: now.toISOString(), sequence: 5 }) + }) + + it('starts a new session after the idle window', () => { + const stale = new Date(now.getTime() - SESSION_IDLE_MS) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: stale.toISOString(), sequence: 4 } }, + now + ) + + expect(session.id).not.toBe('session-1') + expect(session.sequence).toBe(1) + }) + + it('starts a new session when the clock has moved backwards', () => { + const future = new Date(now.getTime() + 60_000) + const session = nextSession( + { ...state, session: { id: 'session-1', lastActiveAt: future.toISOString(), sequence: 4 } }, + now + ) + + expect(session.id).not.toBe('session-1') + }) +}) diff --git a/packages/sim-cli/src/telemetry/state.ts b/packages/sim-cli/src/telemetry/state.ts new file mode 100644 index 00000000000..57b360245ac --- /dev/null +++ b/packages/sim-cli/src/telemetry/state.ts @@ -0,0 +1,109 @@ +import { generateId } from '@sim/utils/id' +import { readJsonFile, writeJsonFile } from '../config/json-file' +import { telemetryStatePath } from '../config/paths' + +/** + * Two commands closer together than this belong to one session, the way the + * Vercel and Supabase CLIs group them. Far above a person's pause between + * commands and far below a lunch break, so a session reads as one sitting. + */ +export const SESSION_IDLE_MS = 30 * 60 * 1000 + +/** Far above the few-hundred-byte document while still bounding hostile files. */ +const MAX_STATE_BYTES = 4 * 1024 + +const STATE_VERSION = 1 + +/** The state file is not secret, but it identifies the device, so it is the user's alone. */ +const STATE_FILE_MODE = 0o600 + +export interface TelemetrySession { + id: string + /** When the last command in this session ran, for the idle cutoff. */ + lastActiveAt: string + /** Commands recorded in this session so far; the next one is `sequence + 1`. */ + sequence: number +} + +export interface TelemetryState { + /** Unknown versions are treated as absent. */ + version: typeof STATE_VERSION + /** + * A random id for this installation, minted the first time telemetry runs. + * It ties one device's commands together and nothing else: it is not derived + * from hardware, the account, or the network. + */ + deviceId: string + /** `false` after `sim telemetry disable`; absent or `true` otherwise. */ + enabled?: boolean + /** When the first-run notice was printed; absent until it has been. */ + noticeShownAt?: string + session?: TelemetrySession +} + +function isIsoTimestamp(value: unknown): value is string { + return typeof value === 'string' && !Number.isNaN(Date.parse(value)) +} + +function parseSession(value: unknown): TelemetrySession | undefined { + if (typeof value !== 'object' || value === null) return undefined + const session = value as Partial + if (typeof session.id !== 'string' || !session.id) return undefined + if (!isIsoTimestamp(session.lastActiveAt)) return undefined + if (!Number.isSafeInteger(session.sequence) || (session.sequence as number) < 0) return undefined + return { + id: session.id, + lastActiveAt: session.lastActiveAt, + sequence: session.sequence as number, + } +} + +/** A fresh state for an installation telemetry has never seen. Not written until something changes. */ +function initialTelemetryState(): TelemetryState { + return { version: STATE_VERSION, deviceId: generateId() } +} + +/** + * The persisted state, or `null` when there is none worth trusting. + * + * Validated field by field: the file is the user's to edit or corrupt, and a + * document that fails validation is replaced rather than repaired, so a bad + * `deviceId` never leaks into an event. + */ +export function readTelemetryState(path = telemetryStatePath()): TelemetryState | null { + const parsed = readJsonFile(path, MAX_STATE_BYTES) + if (typeof parsed !== 'object' || parsed === null) return null + const state = parsed as Partial + if (state.version !== STATE_VERSION) return null + if (typeof state.deviceId !== 'string' || !state.deviceId) return null + + const result: TelemetryState = { version: STATE_VERSION, deviceId: state.deviceId } + if (typeof state.enabled === 'boolean') result.enabled = state.enabled + if (isIsoTimestamp(state.noticeShownAt)) result.noticeShownAt = state.noticeShownAt + const session = parseSession(state.session) + if (session) result.session = session + return result +} + +/** The persisted state, or a fresh one when there is none. */ +export function loadTelemetryState(path = telemetryStatePath()): TelemetryState { + return readTelemetryState(path) ?? initialTelemetryState() +} + +export function writeTelemetryState(state: TelemetryState, path = telemetryStatePath()): void { + writeJsonFile(path, state, STATE_FILE_MODE) +} + +/** + * The session the next command belongs to: the current one, advanced by one, + * when it was active within {@link SESSION_IDLE_MS}; otherwise a new one. A + * clock that moved backwards reads as idle, which only starts a new session. + */ +export function nextSession(state: TelemetryState, now: Date): TelemetrySession { + const current = state.session + const idleFor = current ? now.getTime() - Date.parse(current.lastActiveAt) : Number.NaN + if (current && idleFor >= 0 && idleFor < SESSION_IDLE_MS) { + return { id: current.id, lastActiveAt: now.toISOString(), sequence: current.sequence + 1 } + } + return { id: generateId(), lastActiveAt: now.toISOString(), sequence: 1 } +} diff --git a/packages/sim-cli/src/telemetry/transport.test.ts b/packages/sim-cli/src/telemetry/transport.test.ts new file mode 100644 index 00000000000..2fb396a369d --- /dev/null +++ b/packages/sim-cli/src/telemetry/transport.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type CaptureRequest, + DEFAULT_INGEST_HOST, + type SpawnSender, + sendCapture, +} from './transport' + +const request: CaptureRequest = { + api_key: 'phc_test', + event: 'cli_command_executed', + distinct_id: 'device-1', + timestamp: '2026-09-10T12:00:00.000Z', + properties: { command: 'workflows list' }, +} + +function fakeSpawn() { + const child = { unref: vi.fn(), once: vi.fn() } + const spawn = vi.fn(() => child) + return { spawn, child } +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('sendCapture', () => { + it('hands the event to a detached sender and lets go of it', () => { + const { spawn, child } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + + expect(spawn).toHaveBeenCalledOnce() + const [command, args, options] = spawn.mock.calls[0] + expect(command).toBe(process.execPath) + expect(args).toContain('--input-type=module') + expect(options).toMatchObject({ detached: true, stdio: 'ignore', windowsHide: true }) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('addresses the capture endpoint on the target host and carries the event in the environment', () => { + const { spawn } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: 'https://eu.i.posthog.com' }, request, spawn) + + const payload = JSON.parse(spawn.mock.calls[0][2].env.SIM_TELEMETRY_CAPTURE as string) + expect(payload.url).toBe('https://eu.i.posthog.com/i/v0/e/') + expect(JSON.parse(payload.body)).toEqual(request) + expect(payload.timeoutMs).toBeGreaterThan(0) + }) + + it('never hands the sender the API key', () => { + vi.stubEnv('SIM_API_KEY', 'sim_secret') + vi.stubEnv('HTTPS_PROXY', 'http://proxy.internal:3128') + const { spawn } = fakeSpawn() + + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + + const env = spawn.mock.calls[0][2].env + expect(env.SIM_API_KEY).toBeUndefined() + expect(env.HTTPS_PROXY).toBe('http://proxy.internal:3128') + }) + + it('swallows a sender that cannot start', () => { + const spawn = vi.fn(() => { + throw new Error('spawn EAGAIN') + }) + + expect(() => + sendCapture({ key: 'phc_test', host: DEFAULT_INGEST_HOST }, request, spawn) + ).not.toThrow() + }) +}) diff --git a/packages/sim-cli/src/telemetry/transport.ts b/packages/sim-cli/src/telemetry/transport.ts new file mode 100644 index 00000000000..32d35dca2e0 --- /dev/null +++ b/packages/sim-cli/src/telemetry/transport.ts @@ -0,0 +1,113 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { childProcessEnv, proxyExecArgv } from '../environment' + +/** + * Where events go. The key is a PostHog project token — a public, write-only + * value — baked into the published build by `bun build --env='SIM_CLI_TELEMETRY_*'` + * the way the Supabase CLI injects its own at link time. A checkout built + * without one has no destination and reports nothing, and a self-hosted + * deployment can point its own build at its own project. + * + * These two reads must stay literal `process.env.` expressions: that is + * the only form the bundler substitutes. + */ +const BUILT_IN_KEY = process.env.SIM_CLI_TELEMETRY_KEY +const BUILT_IN_HOST = process.env.SIM_CLI_TELEMETRY_HOST + +export const DEFAULT_INGEST_HOST = 'https://us.i.posthog.com' + +/** The single-event capture endpoint, relative to the ingest host. */ +const CAPTURE_PATH = '/i/v0/e/' + +/** + * How long the sender may take. Generous for a request that carries a kilobyte, + * and irrelevant to the user, who has already got their prompt back. + */ +const SEND_TIMEOUT_MS = 5000 + +/** Carries the request into the sender process; not part of the bundled build's env glob. */ +const PAYLOAD_VARIABLE = 'SIM_TELEMETRY_CAPTURE' + +export interface IngestTarget { + key: string + host: string +} + +/** The destination this build was made with, if any. */ +export function builtInIngestTarget(): IngestTarget | undefined { + if (!BUILT_IN_KEY) return undefined + return { key: BUILT_IN_KEY, host: BUILT_IN_HOST || DEFAULT_INGEST_HOST } +} + +/** One event in the shape PostHog's capture endpoint accepts. */ +export interface CaptureRequest> { + api_key: string + event: string + distinct_id: string + timestamp: string + properties: Properties +} + +const SEND_SCRIPT = ` +try { + const { url, body, timeoutMs } = JSON.parse(process.env[${JSON.stringify(PAYLOAD_VARIABLE)}]) + const deadline = setTimeout(() => process.exit(1), timeoutMs) + await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + redirect: 'error', + }) + clearTimeout(deadline) + process.exit(0) +} catch { + process.exit(1) +} +` + +export type SpawnSender = ( + command: string, + args: readonly string[], + options: { detached: boolean; env: NodeJS.ProcessEnv; stdio: 'ignore'; windowsHide: boolean } +) => Pick + +/** + * Sends one event from a process whose lifetime is its own. + * + * The command has finished and the user has their prompt back; nothing about + * delivery should change that. An in-process request would keep the event loop + * alive for as long as the network took to answer — a DNS lookup on a dead + * network cannot be cancelled at all — so the request is made by a detached + * child that the parent does not wait for, the way the GitHub and Vercel CLIs + * send theirs. The child bounds its own life with a deadline. The request + * travels in the child's environment rather than on argv because the + * environment is handed over at spawn time, so the parent can exit at once + * without a pipe left half-written. + */ +export function sendCapture( + target: IngestTarget, + request: CaptureRequest, + spawnSender: SpawnSender = spawn +): void { + const payload = JSON.stringify({ + url: new URL(CAPTURE_PATH, target.host).href, + body: JSON.stringify(request), + timeoutMs: SEND_TIMEOUT_MS, + }) + try { + const child = spawnSender( + process.execPath, + [...proxyExecArgv(), '--input-type=module', '--eval', SEND_SCRIPT], + { + detached: true, + env: childProcessEnv(['sim_api_key'], { [PAYLOAD_VARIABLE]: payload }), + stdio: 'ignore', + windowsHide: true, + } + ) + child.once('error', () => {}) + child.unref() + } catch { + /** A sender that could not start is an event not worth the command noticing. */ + } +} diff --git a/packages/sim-cli/src/update/check.ts b/packages/sim-cli/src/update/check.ts index a6b105907bd..fede0442035 100644 --- a/packages/sim-cli/src/update/check.ts +++ b/packages/sim-cli/src/update/check.ts @@ -11,21 +11,10 @@ */ import { spawn } from 'node:child_process' -import { - closeSync, - constants, - fstatSync, - lstatSync, - mkdirSync, - openSync, - readSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { readJsonFile, writeJsonFile } from '../config/json-file' import { updateCachePath } from '../config/paths' +import { childProcessEnv, isCi, isEnabled, proxyExecArgv } from '../environment' import { CLI_VERSION } from '../version' /** How long a cached check suppresses another request. */ @@ -68,15 +57,6 @@ function isNewerVersion(candidate: StableVersion, current: StableVersion): boole return candidate[2] > current[2] } -/** Covers CI jobs that allocate a terminal despite being non-interactive. */ -const CI_VARIABLES = [ - 'CI', - 'GITHUB_ACTIONS', - 'JENKINS_URL', - 'TEAMCITY_VERSION', - 'BUILDKITE', -] as const - /** The shape written to the update cache. */ interface UpdateCacheEntry { /** Unknown cache versions are treated as absent. */ @@ -109,16 +89,6 @@ interface RegistryRequestOptions { type RegistryRequest = (url: URL, options: RegistryRequestOptions) => Promise -/** Makes adjacent temporary files unique across writes in this process. */ -let cacheWriteSequence = 0 - -/** Anything but unset, empty, `0` or `false` turns a switch on. */ -function isEnabled(value: string | undefined): boolean { - if (value === undefined) return false - const normalized = value.trim().toLowerCase() - return normalized !== '' && normalized !== '0' && normalized !== 'false' -} - /** Whether the package is installed in a node_modules tree above the working directory. */ function isProjectLocalInstall(modulePath: string, cwd: string): boolean { const normalizedModulePath = normalizeModulePath(modulePath) @@ -203,16 +173,6 @@ try { } ` -/** Preserves proxy/TLS settings without copying CLI credentials into the probe. */ -function registryProcessEnv(): NodeJS.ProcessEnv { - const env = { ...process.env } - for (const key of Object.keys(env)) { - const normalized = key.toLowerCase() - if (normalized === 'npm_config_registry' || normalized === 'sim_api_key') delete env[key] - } - return env -} - /** * Makes one request in a process whose lifetime is owned entirely by this check. * @@ -228,14 +188,11 @@ function requestRegistry( { headers, maxResponseBytes, timeoutMs }: RegistryRequestOptions ): Promise { return new Promise((resolve, reject) => { - const proxyArguments = process.execArgv.filter( - (argument) => argument === '--use-env-proxy' || argument === '--no-use-env-proxy' - ) const child = spawn( process.execPath, - [...proxyArguments, '--input-type=module', '--eval', REGISTRY_REQUEST_SCRIPT], + [...proxyExecArgv(), '--input-type=module', '--eval', REGISTRY_REQUEST_SCRIPT], { - env: registryProcessEnv(), + env: childProcessEnv(['npm_config_registry', 'sim_api_key']), killSignal: 'SIGKILL', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs, @@ -305,49 +262,12 @@ async function fetchDistTags( } function readCache(path: string): UpdateCacheEntry | null { - let descriptor: number | null = null - try { - if (!lstatSync(path).isFile()) return null - descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) - const descriptorStats = fstatSync(descriptor) - if (!descriptorStats.isFile() || descriptorStats.size > MAX_CACHE_BYTES) { - return null - } - - const buffer = Buffer.allocUnsafe(MAX_CACHE_BYTES + 1) - let bytesRead = 0 - while (bytesRead < buffer.byteLength) { - const count = readSync( - descriptor, - buffer, - bytesRead, - buffer.byteLength - bytesRead, - bytesRead - ) - if (count === 0) break - bytesRead += count - } - if (bytesRead > MAX_CACHE_BYTES) return null - - const parsed: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) - if (typeof parsed !== 'object' || parsed === null) return null - const entry = parsed as Partial - if (entry.version !== CACHE_VERSION) return null - if (typeof entry.checkedAt !== 'string' || Number.isNaN(Date.parse(entry.checkedAt))) - return null - return { - version: CACHE_VERSION, - checkedAt: entry.checkedAt, - } - } catch { - return null - } finally { - if (descriptor !== null) { - try { - closeSync(descriptor) - } catch {} - } - } + const parsed = readJsonFile(path, MAX_CACHE_BYTES) + if (typeof parsed !== 'object' || parsed === null) return null + const entry = parsed as Partial + if (entry.version !== CACHE_VERSION) return null + if (typeof entry.checkedAt !== 'string' || Number.isNaN(Date.parse(entry.checkedAt))) return null + return { version: CACHE_VERSION, checkedAt: entry.checkedAt } } /** @@ -355,36 +275,9 @@ function readCache(path: string): UpdateCacheEntry | null { * * Stamping on failure too is what keeps a blackholed registry costing one second * a day instead of one second per command. - * - * Failures are ignored because the cache is best-effort. An exclusive adjacent - * temporary file makes replacement atomic without modifying a linked target. */ function writeCache(path: string, entry: UpdateCacheEntry): void { - let descriptor: number | null = null - let temporaryCreated = false - const temporaryPath = `${path}.${process.pid}.${Date.now()}.${cacheWriteSequence++}.tmp` - try { - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - descriptor = openSync(temporaryPath, 'wx', 0o644) - temporaryCreated = true - writeFileSync(descriptor, `${JSON.stringify(entry, null, 2)}\n`) - closeSync(descriptor) - descriptor = null - renameSync(temporaryPath, path) - temporaryCreated = false - } catch { - } finally { - if (descriptor !== null) { - try { - closeSync(descriptor) - } catch {} - } - if (temporaryCreated) { - try { - unlinkSync(temporaryPath) - } catch {} - } - } + writeJsonFile(path, entry) } /** Treats future timestamps as stale in case the clock moved backward. */ @@ -445,7 +338,7 @@ export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {} if (isEnabled(env.SIM_NO_UPDATE_CHECK)) return if (!isTty) return - if (CI_VARIABLES.some((variable) => isEnabled(env[variable]))) return + if (isCi(env)) return if (isUnadvisableInstall(modulePath, env, cwd)) return const currentVersion = options.currentVersion ?? CLI_VERSION diff --git a/packages/testing/src/mocks/hybrid-auth.mock.ts b/packages/testing/src/mocks/hybrid-auth.mock.ts index e9739496172..771edcea148 100644 --- a/packages/testing/src/mocks/hybrid-auth.mock.ts +++ b/packages/testing/src/mocks/hybrid-auth.mock.ts @@ -39,7 +39,6 @@ export const hybridAuthMockFns = { mockCheckHybridAuth: vi.fn(defaultCheckSessionOrInternalAuth), mockCheckSessionOrInternalAuth: vi.fn(defaultCheckSessionOrInternalAuth), mockCheckInternalAuth: vi.fn(), - mockHasExternalApiCredentials: vi.fn(() => false), } /** @@ -71,5 +70,4 @@ export const hybridAuthMock = { checkHybridAuth: hybridAuthMockFns.mockCheckHybridAuth, checkSessionOrInternalAuth: hybridAuthMockFns.mockCheckSessionOrInternalAuth, checkInternalAuth: hybridAuthMockFns.mockCheckInternalAuth, - hasExternalApiCredentials: hybridAuthMockFns.mockHasExternalApiCredentials, } diff --git a/packages/testing/src/mocks/logger.mock.ts b/packages/testing/src/mocks/logger.mock.ts index f7330cb2bc8..dc4d10628c9 100644 --- a/packages/testing/src/mocks/logger.mock.ts +++ b/packages/testing/src/mocks/logger.mock.ts @@ -40,6 +40,7 @@ export const loggerMock = { runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), getRequestContext: vi.fn(() => undefined), setRequestTraceId: vi.fn(), + setRequestAuth: vi.fn(), } /** diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index 8050171dd7b..0a1f7955627 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -1,5 +1,6 @@ +import { readFileSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SimStudioClient, SimStudioError } from './index' +import { SDK_VERSION, SimStudioClient, SimStudioError } from './index' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) @@ -788,3 +789,31 @@ describe('SimStudioError', () => { expect(error.status).toBe(400) }) }) + +describe('client identity', () => { + it('keeps SDK_VERSION in step with package.json', () => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + expect(SDK_VERSION).toBe(manifest.version) + }) + + it('identifies the SDK on every request', async () => { + const client = new SimStudioClient({ apiKey: 'test-api-key', baseUrl: 'https://test.sim.ai' }) + vi.mocked(mockFetch).mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ data: { isDeployed: true } }), + } as any) + + await client.getWorkflowStatus('workflow-id') + + const headers = vi.mocked(mockFetch).mock.calls[0][1]?.headers as Record + expect(headers['X-Sim-Client-Info']).toBe( + `sdk-js/${SDK_VERSION}; node/${process.versions.node}` + ) + expect(headers['User-Agent']).toBe( + `simstudio-ts-sdk/${SDK_VERSION} node/${process.versions.node}` + ) + expect(headers['X-API-Key']).toBe('test-api-key') + }) +}) diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index 22bf0fed732..6982f9acd85 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -3,6 +3,29 @@ export interface SimStudioConfig { baseUrl?: string } +/** + * The published package version. Kept in step with `package.json` by a test + * rather than read at runtime, so the SDK stays usable where there is no file + * system to read it from. + */ +export const SDK_VERSION = '0.2.0' + +/** + * Identifies this SDK to the API on every request, the way every official Sim + * client does, so a server log line or analytics event can say which client + * made the call. `X-Sim-Client-Info` is the header the server reads and works + * everywhere; `User-Agent` is added only where the runtime lets a script set it. + */ +const CLIENT_HEADERS: Readonly> = (() => { + const node = typeof process !== 'undefined' ? process.versions?.node : undefined + const runtime = node ? `; node/${node}` : '' + const headers: Record = { + 'X-Sim-Client-Info': `sdk-js/${SDK_VERSION}${runtime}`, + } + if (node) headers['User-Agent'] = `simstudio-ts-sdk/${SDK_VERSION} node/${node}` + return headers +})() + export interface LargeValueRef { __simLargeValueRef: true version: 1 @@ -196,6 +219,11 @@ export class SimStudioClient { this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai') } + /** The headers every request carries: the credential and the client's identity. */ + private requestHeaders(): Record { + return { ...CLIENT_HEADERS, 'X-API-Key': this.apiKey } + } + /** * Convert File objects in input to API format (base64) * Recursively processes nested objects and arrays @@ -284,8 +312,8 @@ export class SimStudioClient { }) const headers: Record = { + ...this.requestHeaders(), 'Content-Type': 'application/json', - 'X-API-Key': this.apiKey, } let workflowInput: any = {} @@ -418,9 +446,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) if (!response.ok) { @@ -500,9 +526,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) @@ -548,9 +572,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) @@ -676,9 +698,7 @@ export class SimStudioClient { try { const response = await fetch(url, { method: 'GET', - headers: { - 'X-API-Key': this.apiKey, - }, + headers: this.requestHeaders(), }) this.updateRateLimitInfo(response) diff --git a/packages/utils/package.json b/packages/utils/package.json index 8d930f16c9e..97ce4f54663 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -18,6 +18,10 @@ "types": "./src/random.ts", "default": "./src/random.ts" }, + "./client-info": { + "types": "./src/client-info.ts", + "default": "./src/client-info.ts" + }, "./color": { "types": "./src/color.ts", "default": "./src/color.ts" diff --git a/packages/utils/src/client-info.test.ts b/packages/utils/src/client-info.test.ts new file mode 100644 index 00000000000..516d2b778e5 --- /dev/null +++ b/packages/utils/src/client-info.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + CLIENT_INFO_HEADER, + formatClientInfo, + parseClientInfo, + resolveClientInfo, +} from './client-info' + +function headers(entries: Record) { + const map = new Map(Object.entries(entries).map(([key, value]) => [key.toLowerCase(), value])) + return { get: (name: string) => map.get(name.toLowerCase()) ?? null } +} + +describe('formatClientInfo', () => { + it('renders every field as a product token in a fixed order', () => { + expect( + formatClientInfo({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '22.14.0' }, + os: 'darwin', + arch: 'arm64', + agent: 'claude-code', + }) + ).toBe('cli/2.1.2; node/22.14.0; os/darwin; arch/arm64; agent/claude-code') + }) + + it('renders an unversioned surface as a bare token', () => { + expect(formatClientInfo({ surface: 'web' })).toBe('web') + }) + + it('refuses a value that is not an RFC 9110 token', () => { + expect(() => formatClientInfo({ surface: 'cli', version: '2.1.2 beta' })).toThrow( + /not a valid token/ + ) + }) +}) + +describe('parseClientInfo', () => { + it('round-trips a formatted value', () => { + const info = { + surface: 'desktop' as const, + version: '1.4.2', + runtime: { name: 'electron', version: '43.5.0' }, + os: 'darwin', + arch: 'arm64', + } + expect(parseClientInfo(formatClientInfo(info))).toEqual(info) + }) + + it('accepts trailing tokens in any order', () => { + expect(parseClientInfo('cli/2.1.2; agent/codex; arch/x64; node/20.0.0; os/linux')).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '20.0.0' }, + os: 'linux', + arch: 'x64', + agent: 'codex', + }) + }) + + it('returns undefined for an unknown surface', () => { + expect(parseClientInfo('curl/8.0.0')).toBeUndefined() + }) + + it('returns undefined for an empty, missing, or oversized value', () => { + expect(parseClientInfo('')).toBeUndefined() + expect(parseClientInfo(null)).toBeUndefined() + expect(parseClientInfo(undefined)).toBeUndefined() + expect(parseClientInfo(`cli/1.0.0; ${'x'.repeat(300)}/1`)).toBeUndefined() + }) + + it('skips malformed and unknown trailing tokens instead of failing', () => { + expect(parseClientInfo('cli/2.1.2; ; not a token; future/thing; os/darwin')).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'future', version: 'thing' }, + os: 'darwin', + }) + }) + + it('carries any well-formed agent token through', () => { + expect(parseClientInfo('cli/2.1.2; agent/some-new-agent')?.agent).toBe('some-new-agent') + }) + + it('keeps the first of a repeated token', () => { + expect(parseClientInfo('cli/2.1.2; os/darwin; os/linux')?.os).toBe('darwin') + }) +}) + +describe('resolveClientInfo', () => { + it('prefers a declared header over every other signal', () => { + const resolved = resolveClientInfo( + headers({ + [CLIENT_INFO_HEADER]: 'cli/2.1.2; node/22.0.0', + 'user-agent': 'Mozilla/5.0', + 'sec-fetch-mode': 'cors', + }), + { hasExternalCredentials: true } + ) + expect(resolved).toEqual({ + surface: 'cli', + version: '2.1.2', + runtime: { name: 'node', version: '22.0.0' }, + source: 'header', + }) + }) + + it('recognises the user agent of CLI releases that predate the header', () => { + const resolved = resolveClientInfo( + headers({ 'user-agent': 'sim-cli/2.0.9 node/22.14.0 (darwin; arm64)' }), + { hasExternalCredentials: true } + ) + expect(resolved).toEqual({ surface: 'cli', version: '2.0.9', source: 'user_agent' }) + }) + + it('infers the web app from fetch metadata on an uncredentialed browser request', () => { + const resolved = resolveClientInfo(headers({ 'sec-fetch-mode': 'cors' }), { + hasExternalCredentials: false, + }) + expect(resolved).toEqual({ surface: 'web', source: 'fetch_metadata' }) + }) + + it('leaves a credentialed browser request unattributed', () => { + expect( + resolveClientInfo(headers({ 'sec-fetch-mode': 'cors' }), { hasExternalCredentials: true }) + ).toBeUndefined() + }) + + it('leaves a bare request unattributed', () => { + expect( + resolveClientInfo(headers({ 'user-agent': 'curl/8.0.0' }), { hasExternalCredentials: true }) + ).toBeUndefined() + }) +}) diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts new file mode 100644 index 00000000000..09537a4fafa --- /dev/null +++ b/packages/utils/src/client-info.ts @@ -0,0 +1,204 @@ +/** + * Client attribution: which official Sim client sent a request. + * + * Every first-party client declares itself with one header, `X-Sim-Client-Info`, + * whose value is a list of `name/version` product tokens in the `User-Agent` + * grammar of RFC 9110 §10.1.5, separated by `;` the way Supabase's + * `X-Client-Info` and Google's `x-goog-api-client` are. A custom header rather + * than `User-Agent` alone because a browser and an Electron renderer cannot set + * `User-Agent`, so a single header every client can send is the only channel + * that gives the server one place to look. + * + * ```text + * X-Sim-Client-Info: cli/2.1.2; node/22.14.0; os/darwin; arch/arm64; agent/claude-code + * X-Sim-Client-Info: desktop/1.4.2; electron/43.5.0; os/darwin; arch/arm64 + * X-Sim-Client-Info: web + * ``` + * + * The first token names the surface and, optionally, its version. The tokens + * after it are keyed by name: `os`, `arch` and `agent` are reserved, and the + * first unreserved one is the runtime. Order among the trailing tokens does + * not matter, and unknown tokens are ignored so a newer client can add one + * without breaking an older server. + * + * Attribution is analytics and log metadata only. It is caller-controlled and + * is never an authorization input. + */ + +export const CLIENT_INFO_HEADER = 'x-sim-client-info' + +/** The official Sim clients, as they name themselves on the wire. */ +export const SIM_SURFACES = ['web', 'desktop', 'cli', 'sdk-js', 'sdk-python'] as const + +export type SimSurface = (typeof SIM_SURFACES)[number] + +export interface ClientInfo { + surface: SimSurface + /** The client's own version, absent when the client is not versioned (the web app). */ + version?: string + /** The runtime the client executes in, such as `node`, `electron`, or `python`. */ + runtime?: { name: string; version: string } + /** Operating system and CPU architecture, in the names the runtime reports. */ + os?: string + arch?: string + /** + * The AI coding agent driving the client (`claude-code`, `codex`, `cursor`, + * …), when one could be detected. Detection lives with the CLI, the only + * client that runs inside an agent's shell; the server carries the value + * through. An open token rather than a closed list because new agents appear + * faster than a server can be redeployed to know their names. + */ + agent?: string +} + +/** How the server established a request's client. */ +export type ClientInfoSource = 'header' | 'user_agent' | 'fetch_metadata' + +export interface ResolvedClientInfo extends ClientInfo { + source: ClientInfoSource +} + +/** Bounds a caller-controlled header before it is parsed or logged. */ +const MAX_HEADER_LENGTH = 256 + +/** RFC 9110 `token` characters minus the delimiters this header reserves. */ +const TOKEN_PATTERN = /^[A-Za-z0-9._+-]+$/ + +/** Trailing token names with a fixed meaning; anything else is the runtime. */ +const OS_KEY = 'os' +const ARCH_KEY = 'arch' +const AGENT_KEY = 'agent' + +/** The `User-Agent` older CLI releases sent before the header existed. */ +const LEGACY_CLI_USER_AGENT = /^sim-cli\/([A-Za-z0-9._+-]+)/ + +/** The header browsers attach to every request and non-browser clients never do. */ +const FETCH_METADATA_HEADER = 'sec-fetch-mode' + +const SURFACE_SET: ReadonlySet = new Set(SIM_SURFACES) + +function isSurface(value: string): value is SimSurface { + return SURFACE_SET.has(value) +} + +function isToken(value: string): boolean { + return TOKEN_PATTERN.test(value) +} + +function product(name: string, version?: string): string { + if (!isToken(name)) throw new Error(`Client info token name is not a valid token: ${name}`) + if (version === undefined) return name + if (!isToken(version)) + throw new Error(`Client info token version is not a valid token: ${version}`) + return `${name}/${version}` +} + +/** + * Renders the `X-Sim-Client-Info` value a client sends. + * + * Throws on a value that is not an RFC 9110 token, because every field comes + * from the client's own build or runtime constants and a bad one is a bug in + * the client, not input to tolerate. + */ +export function formatClientInfo(info: ClientInfo): string { + const tokens = [product(info.surface, info.version)] + if (info.runtime) tokens.push(product(info.runtime.name, info.runtime.version)) + if (info.os) tokens.push(product(OS_KEY, info.os)) + if (info.arch) tokens.push(product(ARCH_KEY, info.arch)) + if (info.agent) tokens.push(product(AGENT_KEY, info.agent)) + return tokens.join('; ') +} + +function splitProduct(token: string): { name: string; version?: string } | undefined { + const slash = token.indexOf('/') + const name = slash === -1 ? token : token.slice(0, slash) + const version = slash === -1 ? undefined : token.slice(slash + 1) + if (!isToken(name)) return undefined + if (version !== undefined && !isToken(version)) return undefined + return { name, version } +} + +/** + * Parses an `X-Sim-Client-Info` value. + * + * Returns `undefined` for anything that does not name a known surface, so an + * unrecognised or malformed header reads as "unattributed" rather than as a + * client that does not exist. Trailing tokens are tolerant: a malformed or + * unknown one is skipped, never fatal, because a client may legitimately be + * newer than the server reading it. + */ +export function parseClientInfo(value: string | null | undefined): ClientInfo | undefined { + if (!value || value.length > MAX_HEADER_LENGTH) return undefined + + const tokens = value.split(';').map((token) => token.trim()) + const first = tokens[0] ? splitProduct(tokens[0]) : undefined + if (!first || !isSurface(first.name)) return undefined + + const info: ClientInfo = { surface: first.name } + if (first.version !== undefined) info.version = first.version + + for (const token of tokens.slice(1)) { + if (token === '') continue + const parsed = splitProduct(token) + if (!parsed || parsed.version === undefined) continue + + if (parsed.name === OS_KEY) { + info.os ??= parsed.version + } else if (parsed.name === ARCH_KEY) { + info.arch ??= parsed.version + } else if (parsed.name === AGENT_KEY) { + info.agent ??= parsed.version + } else { + info.runtime ??= { name: parsed.name, version: parsed.version } + } + } + + return info +} + +/** The one method every request abstraction exposes, so a minimal test double qualifies. */ +export interface HeaderReader { + get(name: string): string | null +} + +export interface ResolveClientInfoOptions { + /** + * Whether the request carries external API credentials (an API key or a + * bearer token). Decided by the caller, which owns the credential header + * names, so this module never has to know them. + */ + hasExternalCredentials: boolean +} + +/** + * Establishes which client sent a request, from the strongest signal available. + * + * 1. `X-Sim-Client-Info`, when a client declared itself. + * 2. The `User-Agent` of CLI releases that predate the header. + * 3. Fetch Metadata. Browsers stamp `Sec-Fetch-*` on every request and nothing + * else does, so a browser request that carries no external credentials can + * only have come from a page Sim served — the web app, or a public surface + * such as a shared chat. A browser request that does carry an API key is a + * third-party integration and is deliberately left unattributed. The web app + * declares itself on its contract-bound calls; this covers the raw-`fetch` + * exceptions and stale bundles that do not. + * + * Returns `undefined` when none of these apply: direct API traffic from an + * unofficial client, or a server-to-server call. + */ +export function resolveClientInfo( + headers: HeaderReader, + options: ResolveClientInfoOptions +): ResolvedClientInfo | undefined { + const declared = parseClientInfo(headers.get(CLIENT_INFO_HEADER)) + if (declared) return { ...declared, source: 'header' } + + const legacyCli = LEGACY_CLI_USER_AGENT.exec(headers.get('user-agent') ?? '') + if (legacyCli) return { surface: 'cli', version: legacyCli[1], source: 'user_agent' } + + if (headers.get(FETCH_METADATA_HEADER) !== null && !options.hasExternalCredentials) { + return { surface: 'web', source: 'fetch_metadata' } + } + + return undefined +} diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 6cd86c50db8..aee73ae7ff3 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -46,6 +46,7 @@ export const GUIDE_PAGES = [ 'scripting', 'workflow-sync', 'troubleshooting', + 'usage-data', ] as const /** Generated page holding the global options and the commands that take no resource. */