> ## Documentation Index
> Fetch the complete documentation index at: https://e2e.tester.army/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Telemetry

> What the CLI sends, what it never sends, and how to turn it off.

The `e2e` CLI sends anonymous usage data: which commands, engines, and
features people use, and where runs fail. Telemetry is on by default. It
sends counts and versions, never test content, app content, or credentials.

## Opt out

Any one of these turns telemetry off:

<CodeGroup>
```bash npm
npx e2e telemetry disable   # saved in ~/.config/e2e/telemetry.json
export E2E_TELEMETRY_DISABLED=1          # for a shell or a CI job
export DO_NOT_TRACK=1                    # the cross-tool convention
```

```bash pnpm
pnpm exec e2e telemetry disable   # saved in ~/.config/e2e/telemetry.json
export E2E_TELEMETRY_DISABLED=1          # for a shell or a CI job
export DO_NOT_TRACK=1                    # the cross-tool convention
```

```bash bun
bunx e2e telemetry disable   # saved in ~/.config/e2e/telemetry.json
export E2E_TELEMETRY_DISABLED=1          # for a shell or a CI job
export DO_NOT_TRACK=1                    # the cross-tool convention
```
</CodeGroup>

The environment variables win over the saved setting. `e2e telemetry enable`
reverses a saved `disable`. `e2e telemetry` prints the status and, when off,
the reason. The preferences file is under `$XDG_CONFIG_HOME/e2e/` when that is
set, and `%APPDATA%\e2e\` on Windows.

A `disable` also stops commands already running in another terminal. Nothing
is sent when the preferences directory isn't writable, or from a source
checkout of the e2e repository.

## See what would be sent

<CodeGroup>
```bash npm
E2E_TELEMETRY_DEBUG=1 npx e2e run
```

```bash pnpm
E2E_TELEMETRY_DEBUG=1 pnpm exec e2e run
```

```bash bun
E2E_TELEMETRY_DEBUG=1 bunx e2e run
```
</CodeGroup>

The CLI prints the exact payloads to stderr as `[telemetry] {...}` and
sends nothing.

## What is sent

Each command sends one `e2e_cli_session` event. `run` and `explore` also
send `e2e_run_completed`, `init` sends `e2e_init_completed` (and so does
`explore` when it runs the init wizard first), `upgrade` sends
`e2e_upgrade_completed`, and `mcp` sends one `e2e_mcp_session` per MCP
session. `--help` and `--version` send
nothing, on any command. `run()` and `explore()` from `e2e/runner` send no
session event, only one `e2e_run_completed` per call that returns a report.

The events go in one request when the command, `run()`, or `explore()` ends, with a
two-second deadline. `e2e mcp` sends each session event in its own request,
and `e2e run --watch` sends each run's event when that run ends.

Events hold only the properties below. An error code that isn't an upper-case
token is sent as `OTHER`. An engine, platform, model, fleet, or MCP client
name, or an MCP client version, that isn't a plain token of letters, digits,
dots, dashes, and underscores is sent as `other`. An engine version that isn't
semver is sent as `unversioned`. In action and tool call counts, a project's
own tools count together as `tool`, and any other name as `other`.

<AccordionGroup>
<Accordion title="Every event">

| Property | Value |
| --- | --- |
| `distinct_id` | A random id stored in the preferences file. CI uses `ci:<vendor>` instead, and a fleet uses `fleet:<name>` (see [In a fleet](#in-a-fleet)) |
| `project_id` | A hash of the repository's root commit, shared across full clones. `null` outside git and in a shallow clone |
| `session_id` | A random id for this one invocation |
| `first_run`, `days_since_first_run` | Whether this command created the preferences file, and whole days since then. `null` in CI, in a fleet, and for a preferences file from before the time was recorded |
| `os`, `os_release`, `arch`, `cpu_count`, `memory_gb`, `docker`, `wsl`, `tty` | The machine class |
| `sandbox` | The microVM the kernel announces itself as (`firecracker`), else `null` |
| `ci`, `ci_name` | Whether `CI` is set, and the vendor when one announces itself (`github-actions`, `gitlab`, `circleci`, ...) |
| `fleet` | The value of `E2E_TELEMETRY_FLEET`, `null` when unset |
| `coding_agent` | The coding agent whose shell ran the command, when one announces itself (`claude-code`, `cursor`, `codex`, ...) |
| `e2e_version`, `package_manager` | The e2e version, and the package manager that started the CLI |
| `runtime`, `runtime_version`, `node_version` | `node`, `bun`, or `deno`, and its version. `node_version` is Node's own version, or the Node compatibility version under Bun or Deno |
| `$lib`, `$lib_version`, `$process_person_profile`, `$geoip_disable` | `e2e`, its version, `false`, and `true`: PostHog keeps no person profile and derives no location |

</Accordion>
<Accordion title="e2e_cli_session, once per command">

| Property | Value |
| --- | --- |
| `command` | `init`, `run`, `list`, `cache ls`, `guide`, `telemetry`, ... |
| `flags` | The names of the flags given, such as `--headed` or `--tag`. Never their values |
| `exit_code`, `duration_ms` | How the command ended and how long it took |
| `error_code` | The runner code of the failure that stopped the command before it could run (`CONFIG_NOT_FOUND`, `INVALID_CONFIG`, ...), `CLI_USAGE` for a flag or argument the CLI rejected, else `null` |

</Accordion>
<Accordion title="e2e_init_completed, once per init wizard">

| Property | Value |
| --- | --- |
| `result` | `scaffolded`, `already-initialized`, `cancelled`, `install-failed`, `not-interactive`, `invalid-project`, or `example-failed` |
| `yes`, `existing_config` | Whether `--yes` was given, and whether a config already existed, so no engine or gateway was asked |
| `example` | The `--example` name (`with-vite`, `with-expo`, ...), `null` without the flag |
| `engine`, `gateway` | The ids of the engines and the model gateway chosen for a new config (`web`, `mobile`, `web,mobile`, `none`; `vercel`, `openrouter`, `openai-compatible`, `chatgpt`, `copilot`, `opencode-console`, `grok`, `none`), `null` when not asked. Never the endpoint typed for an OpenAI-compatible gateway |
| `skill`, `mcp`, `install` | Whether the agent skill, the MCP registration, and the dependency install were chosen |

</Accordion>
<Accordion title="e2e_upgrade_completed, once per e2e upgrade">

| Property | Value |
| --- | --- |
| `result` | `upgraded`, `up-to-date`, `dry-run`, `no-project`, `invalid-project`, `linked`, `unknown-target`, `incompatible`, `registry-failed`, or `install-failed` |
| `target` | `version` for a named version, else the dist-tag: `latest`, `nightly`, `canary`, `beta`, or `other` |
| `from_version`, `to_version`, `prerelease` | The `e2e` version installed before, the one the target resolved to, and whether that one is a prerelease. `null` when unknown |
| `manager` | The package manager the command picked for the project: `npm`, `pnpm`, `yarn`, or `bun`. It installs only on `upgraded`. `null` when the command stopped before picking one |
| `siblings` | The `@e2e-dev` packages in the upgrade plan, without the scope: `["web", "kernel"]`. Linked packages and ones the registry doesn't have are left out |
| `packages_moved`, `packages_linked` | How many `e2e` and `@e2e-dev` entries in `package.json` the upgrade rewrote, and how many it left alone because they aren't registry versions (`workspace:`, `file:`, `catalog:`, a path, git, an alias). Not the lockfile's package count |

</Accordion>
<Accordion title="e2e_run_completed, once per e2e run, e2e explore, or completed run() or explore()">

| Property | Value |
| --- | --- |
| `command` | `run`, `explore`, `runner.run` for `run()` from `e2e/runner`, or `runner.explore` for `explore()` |
| `status`, `exit_code`, `duration_ms` | The run's outcome |
| `flags` | The names of the flags given to the run command, never their values |
| `tests_discovered`, `tests_selected`, `tests_executed`, `tests_passed`, `tests_failed`, `tests_interrupted`, `tests_flaky`, `tests_skipped` | The summary counts |
| `attempts_total`, `tests_retried` | How many attempts the run made across tests and serial groups, and how many of these needed more than one |
| `filters_run`, `filters_timed_out`, `filters_failed`, `filters_skipped`, `filters_changed_files`, `tests_left_out_by_filters` | How many [test filters](https://e2e.tester.army/docs/filters) the run applied, how many ran out of time, and how many failed, whether a run with `--changed-since` called none of them, such as when no file changed, how many files the change had, and how many tests the filters left out. Never a filter's name, reason, or note, a path, or a commit |
| `targets`, `platforms`, `engines` | How many targets, on which platforms, and with which engines and versions: `web`, `ios`, `web@0.6.1`, or your own engine's name and version |
| `steps_total`, `steps_agent`, `steps_locator`, `steps_assertion`, `steps_screen`, `steps_app`, `steps_session`, `steps_resource` | Step counts by kind |
| `steps_agent_act`, `steps_agent_assert`, `steps_agent_extract`, `steps_agent_waitfor` | The agent steps by entry point |
| `tests_with_agent_steps`, `tests_with_act` | How many executed tests ran any agent step, and how many ran `agent.act` |
| `agent_steps_replayed`, `agent_steps_partial`, `agent_steps_missed`, `agent_steps_uncached`, `agent_steps_vision` | How the replay cache served the agent steps, how many `agent.act` steps ran with the cache off, and how many used pixel evidence |
| `agent_miss_reasons`, `agent_handoff_reasons` | Why steps missed the cache and why replays handed off to the agent, by the runner's own reason names: `{"no-entry": 3, "retry": 1}` |
| `tests_code_only`, `tests_replayed`, `tests_assisted`, `tests_live` | The executed tests by how the cache carried them: no agent step, every agent step replayed from the cache, the agent ran but the cache carried part of a step, the agent ran every agent step |
| `agent_actions` | How often the agent took each action, by the runner's own names: `{"tap": 12, "typeText": 3, "scroll": 2}` |
| `model_gateway`, `model_provider`, `model_id`, `model_calls`, `model_tokens`, `model_cached_tokens`, `estimated_cost_usd` | For the first model-backed step: the AI SDK provider that served it (`gateway` for the Vercel AI Gateway, `openrouter.chat`, `openai.chat`, ...), the vendor (the first segment of a `vendor/model` id, else the provider), and the public model id. Then the run's totals |
| `model_calls_act`, `model_calls_assert`, `model_calls_extract`, `model_calls_waitfor` | The model calls by agent entry point |
| `cost_source` | `provider` when the provider priced the calls, `none` when nobody priced them, `null` for a run without model calls |
| `artifact_bytes` | The size of the artifacts the run wrote |
| `errors`, `error_codes` | How many run-level errors, and the distinct error codes seen anywhere in the run (`LOCATOR_NOT_FOUND`, `APP_UNREACHABLE`, ...), at most 20 |
| `primary_error_code` | The first error the run recorded, which decided its status: a run-level error, else the failed attempt's verdict, else a step's. `null` for a clean run |
| `error_kinds` | For `ENGINE_FAILURE`, `MODEL_PROVIDER_FAILED`, and `ERROR`: the code and a kind read off the message against a closed list (`auth`, `rate-limit`, `overloaded`, `timeout`, `network`, `server`, `bad-request`, `device`, `daemon`, `browser`, `permission`, `not-found`, `memory`, `other`), as `MODEL_PROVIDER_FAILED:rate-limit`. Only the kind's name leaves the machine, never the message |

Config features, absent when the config didn't load. Each is a count, a
boolean, or one of the runner's own option values, never text you wrote:

| Property | Value |
| --- | --- |
| `config_workers`, `config_retries` | The worker count and retries the run resolved |
| `config_agents`, `config_custom_executor`, `config_separate_judge`, `config_project_tools` | How many agents are configured, whether any has a custom executor or a judge model of its own, and how many distinct project tools they declare |
| `config_credentials`, `config_secrets` | How many credentials and secrets are configured |
| `config_cache_mode`, `config_cache_store`, `config_cache_strict` | `off`, `read-only`, or `read-write`; `file` or `custom`; whether strict replay is on |
| `config_reporters`, `config_custom_reporters`, `config_artifact_store` | The built-in reporter ids (`list`, `json`, `junit`, `markdown`), how many reporter objects the config adds, and whether an artifact store is set |
| `config_filters` | How many test filters the config declares |
| `config_trace_modes`, `config_video_modes` | The distinct recording modes across targets (`off`, `on`, `retain-on-failure`, `on-first-retry`, `on-all-retries`) |
| `config_app_commands`, `config_environments` | How many targets start an app command, and the distinct app environments (`test`, `staging`, `production`) |

On `e2e explore` and `explore()` only, counts of what the exploration was given and found:

| Property | Value |
| --- | --- |
| `explore_ended` | Why it stopped: `finished`, `step-limit`, `time`, `stuck`, or `aborted` |
| `explore_max_steps`, `explore_timeout_ms` | The budgets it ran with |
| `explore_steps`, `explore_steps_passed`, `explore_steps_failed`, `explore_steps_blocked`, `explore_steps_exhausted` | Exploration steps, by how each ended |
| `explore_assessed` | Whether the agent gave a closing assessment |
| `explore_issues`, `explore_warnings`, `explore_findings_by_severity` | Findings by kind, and by severity: `{"1": 0, "2": 1, "3": 0, "4": 0, "5": 1}` |

</Accordion>
<Accordion title="e2e_mcp_session, once per MCP session">

An `e2e mcp` server sends one for each `open_session` it receives, when the
session closes or the open fails. A session that is still open when the
client kills the server isn't sent.

| Property | Value |
| --- | --- |
| `client`, `client_version` | The name and version the MCP client gives itself (`claude-code`, `cursor-vscode`, ...). `null` before the client introduced itself |
| `outcome`, `error_code` | `closed` for a session that opened, `open-failed` with the runner code of the failure (`APP_UNREACHABLE`, `ENGINE_FAILURE`, ...) |
| `ended_by` | `agent` for `close_session`, `idle`, `step` for the session's time limit, `server` when the server shut down. `null` for a failed open |
| `platform`, `engine`, `headed` | The target's platform and engine with its version, as for a run, and whether the UI was shown |
| `duration_ms`, `sessions_open` | How long the session lived, and how many other sessions were open when it opened |
| `calls_total`, `calls_failed`, `calls` | Tool calls, failed calls, and calls by the session's own tool names: `{"observe": 4, "tap": 9, "locate": 2, "screenshot": 1}` |
| `error_codes` | How often each runner code failed a call: `{"LOCATOR_NOT_FOUND": 2}`, at most 20 codes |

</Accordion>
</AccordionGroup>

## What is never sent

Telemetry never sends test titles, file paths, URLs, origins, instructions,
observations, page content, screenshots, error messages, stack traces,
environment variable values, credentials, or file contents. It never sends
the names of targets, agents, credentials, secrets, or project tools, MCP
tool arguments or results, an exploration's goal or charters, or its
findings' words.

## In CI

CI runs count as the CI vendor, not as a machine, with no preferences file
and no notice. A coding agent that sets `CI` in its shell, with no CI vendor
around it, counts as the machine it runs on.

In CI, only the environment variables turn telemetry off. Set
`E2E_TELEMETRY_DISABLED: '1'` in the job's `env`.

## In a fleet

A platform that runs e2e for its users on new machines would count each
machine as a new user. Set `E2E_TELEMETRY_FLEET=<name>` on those machines.
Their events count as `fleet:<name>`, with no preferences file and no
notice, as in CI. Or set `E2E_TELEMETRY_DISABLED=1` to send nothing.

## Where it goes

Events go to `eu.i.posthog.com`, a PostHog project that the e2e
maintainers operate. PostHog keeps no person profile, stores no request
address, and skips GeoIP for each event. The data is read in aggregate.

Telemetry falls under the
[security policy](https://github.com/tester-army/e2e/blob/main/SECURITY.md).
[Outbound connections](https://e2e.tester.army/docs/reference/security#outbound-connections) lists every
other connection a run can make.

## Feedback

[`e2e feedback`](https://e2e.tester.army/docs/reference/cli#e2e-feedback) isn't telemetry. It sends the
words in its flags as one `e2e_feedback` event to the same PostHog project,
with the machine facts above. When telemetry is on, it has the same anonymous
id as your telemetry events.

A saved `e2e telemetry disable` doesn't stop it. `E2E_TELEMETRY_DISABLED=1`
and `DO_NOT_TRACK=1` do. The [CLI reference](https://e2e.tester.army/docs/reference/cli#e2e-feedback)
covers redaction, `--dry-run`, and bugs without `--repro`.

## The notice

The first command, or the first `run()` or `explore()` from `e2e/runner`, prints a notice
on stderr that tells how to opt out. It leaves `--reporter json` output
intact. `e2e init` and `e2e telemetry` don't print it, so it waits for the
next command. The notice shows again after a large change to the data
collected.
