Harden GA telemetry disclosure

This commit is contained in:
rcourtman 2026-07-02 11:50:03 +01:00
parent c63533790c
commit 6c9b4f8e8d
37 changed files with 217 additions and 87 deletions

View file

@ -68,7 +68,7 @@ Power-user shortcuts:
- **One-Click Updates**: Easy upgrades for supported deployments
- **OIDC/SSO/SAML**: Single sign-on with multi-provider support
- **Mobile Remote Access**: Relay protocol with end-to-end encryption for supported Pulse Mobile clients (Relay and above)
- **Privacy Focused**: Anonymous outbound telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — no hostnames, credentials, or personal data is ever sent. Disable any time in Settings or via `PULSE_TELEMETRY=false`.
- **Privacy Focused**: Outbound usage telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — the payload uses a rotating pseudonymous install ID and does not include hostnames, credentials, names, email addresses, IP addresses, or infrastructure identifiers. Disable any time in Settings or via `PULSE_TELEMETRY=false`.
## ⚡ Quick Start

View file

@ -461,7 +461,7 @@ Use this only in trusted environments.
## Privacy
Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). Anonymous outbound telemetry (counts, feature flags, and coarse Patrol mode and governed Pulse Intelligence operations adoption flags and counters only; no hostnames, credentials, prompts, chat messages, command text, action output, token values, or resource identifiers) is enabled by default and can be disabled any time. See [Privacy](PRIVACY.md) for details.
Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). Outbound usage telemetry (a rotating pseudonymous install ID, counts, feature flags, and coarse Patrol mode and governed Pulse Intelligence operations adoption flags and counters only; no hostnames, credentials, prompts, chat messages, command text, action output, token values, IP addresses, or resource identifiers in the payload) is enabled by default and can be disabled any time. See [Privacy](PRIVACY.md) for details.
---

View file

@ -306,7 +306,7 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame
| `PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION` | Allow Proxmox-side LXC Docker socket hinting with `pct exec` | `false` |
| `PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY` | Allow Proxmox-side minimal LXC Docker inventory collection with `pct exec`; collects Docker host/container summary, not inspect/env/mount/process data | `false` |
| `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS` | Optional comma-separated VMID allowlist for Proxmox-side LXC Docker inventory; empty means all running Docker-enabled LXCs are eligible when inventory is enabled | *(unset)* |
| `PULSE_TELEMETRY` | Anonymous outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
| `PULSE_TELEMETRY` | Outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
### Logging Overrides

View file

@ -4,7 +4,7 @@ Pulse is designed to run locally. By default, your monitoring data stays on your
## Usage Data
Pulse has one outbound usage-data scope: **anonymous outbound telemetry** to
Pulse has one outbound usage-data scope: **outbound usage telemetry** to
help me understand active installations, release uptake, and feature use in
aggregate.
@ -12,15 +12,15 @@ Commercial activation and license-recovery runtime records stay on the Pulse
instance where they were created. They are not exported to Pulse infrastructure,
third-party analytics, support diagnostics, or ordinary Settings surfaces.
### Anonymous outbound telemetry
### Outbound usage telemetry
Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted.
Pulse includes outbound usage telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours with a rotating pseudonymous install ID to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted.
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, or personally identifiable information is ever sent. See the full field list below.
The telemetry payload does not include hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, names, email addresses, or account identifiers. See the full field list below.
#### How to disable
- **Settings → System → General → Anonymous outbound telemetry** (toggle off), or
- **Settings → System → General → Outbound usage telemetry** (toggle off), or
- Set the environment variable `PULSE_TELEMETRY=false`
#### How to inspect or rotate it
@ -31,7 +31,7 @@ No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, ch
#### Exactly what is sent
Every field is listed below with the reason it exists — nothing else leaves your server:
Every field is listed below with the reason it exists. Nothing else is included in the telemetry payload:
| Field | Example | Purpose |
|-------|---------|---------|
@ -148,19 +148,19 @@ Every field is listed below with the reason it exists — nothing else leaves yo
- Pulse may derive aggregate Pulse Intelligence adoption reports from those same rows, including whether an install reached Patrol issue activity, Patrol resolution, Assistant, direct external-agent, or MCP collaboration, Patrol mode starter use, paid Patrol mode cohorts, governed-action activity, approved or rejected action decisions, approved action success, completed Patrol control work, recent retention, and observed free-to-paid movement within the source window. Those reports do not add prompts, findings, resource identifiers, tool names, tool inputs, tool outputs, command payloads, action outputs, account links, or exact commercial tiers.
- External-agent/MCP activity is stored only as a coarse adapter-origin flag plus capability-class counters: context, event stream, provisioning, operator state, findings, and action requests.
- Telemetry rows older than **90 days** are purged automatically.
- The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows.
- The license server uses request IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows.
#### What is NOT sent
- No IP addresses are stored in telemetry rows
- No IP addresses are included in the telemetry payload or stored in telemetry rows
- No hostnames, node names, VM names, or any infrastructure identifiers
- No Proxmox credentials, API tokens, or passwords
- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values
- No personally identifiable information of any kind
- No names, email addresses, account identifiers, or other intentionally identifying personal content
#### Install ID rotation
The telemetry install ID is pseudonymous and rotates automatically every 30 days.
The telemetry install ID is pseudonymous, is not tied to a Pulse account, and rotates automatically every 30 days.
Pulse keeps it only to avoid treating every startup ping as a brand-new install
while still limiting long-term linkage from one heartbeat window to the next.
Operators can also rotate it immediately from **Settings → System → General → Reset ID**.

View file

@ -138,12 +138,12 @@ command completed. External-agent/MCP readiness is optional collaboration
setup for agents outside the app, not a first-party Patrol control completion
gate and not a lifecycle setup stage.
Server update funnel telemetry is lifecycle-adjacent only. The updater's local
history may feed anonymous 30-day attempt, success, failure, and coarse failure
history may feed outbound usage 30-day attempt, success, failure, and coarse failure
category counters, but those counters are adoption analytics, not proof that a
particular agent update, profile rollout, host command, registration, or fleet
operation succeeded. Lifecycle surfaces must keep reading update readiness and
continuity from the updater, installer, connection ledger, and agent runtime
state instead of inferring it from anonymous telemetry.
state instead of inferring it from outbound usage telemetry.
1. `frontend-modern/src/api/agentProfiles.ts` shared with `api-contracts`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary.
2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary.

View file

@ -500,7 +500,7 @@ request payload.
must update the MCP README's Pulse Intelligence surface contract and the public
`docs/AI.md` overview from the manifest instead of leaving hand-written
Core/Patrol/Assistant/MCP relationship prose beside generated inventories.
Public AI docs may describe anonymous outbound telemetry only as counts,
Public AI docs may describe outbound usage telemetry only as counts,
feature flags, and coarse Patrol control and governed Pulse Intelligence
operations adoption flags and counters.
They must keep prompts, chat messages, command text, action output, token

View file

@ -3765,14 +3765,14 @@ for logs and response headers.
That same shared settings/licensing contract now also owns the split usage-data
payload model. `frontend-modern/src/api/settings.ts`,
`internal/api/router_routes_licensing.go`, and adjacent settings callers must
keep anonymous outbound telemetry as the only browser-visible usage-data scope;
keep outbound usage telemetry as the only browser-visible usage-data scope;
local commercial reporting controls stay internal/admin-owned and must not
surface as ordinary Settings controls. The telemetry preview payload must ship
normalized version identity fields (`version`, `version_raw`, `version_channel`,
`version_build`, `version_is_development`, and
`version_is_published_release`) instead of leaving browser callers to infer
published-release truth from raw build strings.
That same preview contract now includes the complete anonymous telemetry
That same preview contract now includes the complete outbound usage telemetry
payload shape, including aggregate self-hosted adoption counters for monitored
platforms, workloads, storage, and availability targets plus coarse feature
booleans, content-free update funnel counters, and content-free Patrol,

View file

@ -232,7 +232,7 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
`pkg/licensing/grant_refresh.go`, and cloud-paid transport must send those
values on activate, legacy exchange, and grant refresh instead of inferring
install version or paid-runtime status from browser state, dev build
metadata, public image tags, or anonymous telemetry.
metadata, public image tags, or outbound usage telemetry.
Active self-hosted paid entitlements must also treat runtime identity as a
first-class product state. A paid Pro, Pro Annual, Pro+, lifetime, or
enterprise entitlement on a non-Pro or missing runtime identity must render
@ -395,7 +395,7 @@ files.
Pulse Intelligence paid-value evidence is a separate private admin aggregate,
not a revival of those retired funnel stores. The `pulse-pro` license server
may expose `/v1/admin/pulse-intelligence/value` and the matching admin-console
strip only as coarse install cohorts over anonymous telemetry pings: paid
strip only as coarse install cohorts over outbound usage telemetry pings: paid
install rate, free-to-paid conversion, returning-install retention, and whether
the full Patrol -> contextual collaboration -> governed action loop, the
stricter Patrol -> contextual collaboration -> approved execution loop, and

View file

@ -507,7 +507,7 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
4. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `security-privacy`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
5. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `security-privacy`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
6. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `security-privacy`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary.
The panel owns compact settings-shell framing for anonymous telemetry, but
The panel owns compact settings-shell framing for outbound usage telemetry, but
its vocabulary must stay aligned with `security-privacy`: aggregate
self-hosted adoption counts, coarse feature flags, and coarse Patrol,
Assistant, and external-agent usage counters may be named, while
@ -2057,7 +2057,7 @@ default` instead of fusing provider and badge text such as
`AIRuntimeControlsSection.tsx` must not hardcode GitHub `main` doc URLs for
privacy, security, proxy-auth, scope-reference, or Terms-of-Service links.
29. Keep shared settings-shell telemetry transparency controls on the governed
general settings panel. Preview/reset affordances for anonymous telemetry
general settings panel. Preview/reset affordances for outbound usage telemetry
must stay rendered inside
`frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
instead of drifting into route-local modals, hidden dev tools, or shell
@ -2069,7 +2069,7 @@ default` instead of fusing provider and badge text such as
`GeneralSettingsPanel.tsx` must state those facts plainly instead of
reverting to a stronger but inaccurate shorthand.
31. Keep maintainer commercial-event controls out of customer settings.
The shared general settings privacy panel may expose anonymous outbound
The shared general settings privacy panel may expose outbound usage
telemetry controls, preview, and reset affordances, but it must not render
local commercial handoff event toggles, `PULSE_DISABLE_LOCAL_UPGRADE_METRICS`,
or other commercial-debug controls as normal customer-facing preferences.
@ -2446,8 +2446,8 @@ That same shared settings and modal boundary now also owns the public usage-data
vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
`frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and
`frontend-modern/src/utils/systemSettingsPresentation.ts` must present one
explicit `Usage data and privacy` model centered on `Anonymous outbound
telemetry`; maintainer commercial-event controls, upgrade-metrics labels, and
explicit `Usage data and privacy` model centered on `Outbound usage telemetry`;
maintainer commercial-event controls, upgrade-metrics labels, and
sales/onboarding reporting language must not appear in customer-facing Settings
or support diagnostics, and public configuration docs must not list their
internal compatibility switches as ordinary operator settings. Customer
@ -4627,7 +4627,7 @@ through `frontend-modern/src/utils/docsLinks.ts` rather than panel-local
external URLs.
That same shared-shell framing also covers the concise telemetry summary in
General settings. The shell may present the privacy contract in compact product
copy, but the vocabulary for anonymous outbound telemetry must stay aligned
copy, but the vocabulary for outbound usage telemetry must stay aligned
with `security-privacy`: aggregate self-hosted adoption counts, coarse feature
flags, and coarse Patrol, Assistant, and external-agent usage counters are
allowed, while hostnames, credentials, infrastructure identifiers, prompts,

View file

@ -950,7 +950,7 @@ reporting surface that claims installation totals must aggregate across the
provisioned tenant set through the reloadable multi-tenant monitor boundary,
not by reading `GetMonitor()`'s default-org compatibility shim.
Those install-wide counts are now the canonical aggregate adoption signal for
anonymous telemetry: monitoring owns the source counts for agent hosts, Docker
outbound usage telemetry: monitoring owns the source counts for agent hosts, Docker
and Kubernetes workloads, storage pools and physical disks, Ceph, network
shares, TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, availability
targets, and active alerts. Telemetry callers may consume those coarse totals,

View file

@ -398,7 +398,7 @@ the API boundary before it reaches persisted AI config: the settings handler
trim, drop blanks, de-duplicate) so untrusted request bodies cannot widen the
alert-driven investigation surface beyond the validated shape.
That same governed home now also owns the single customer-facing "usage data"
vocabulary for anonymous outbound telemetry. Local commercial activation and
vocabulary for outbound usage telemetry. Local commercial activation and
license-recovery runtime records must stay out of ordinary Settings, support
diagnostics, outbound telemetry disclosure copy, and public configuration
reference tables.
@ -607,14 +607,14 @@ That same disclosure boundary now also fixes the telemetry payload floor:
commercial and auth-adjacent telemetry may report only coarse posture signals
such as whether a paid license is active or whether any API tokens exist.
Exact license tiers and exact API-token counts are not part of the canonical
anonymous telemetry contract and may not be reintroduced without updating this
outbound usage telemetry contract and may not be reintroduced without updating this
trust boundary and the governed privacy disclosure together.
That same rule also applies at the license-server ingest and storage boundary:
server-side telemetry rows may preserve the canonical normalized version
identity plus those same coarse booleans, but they must not retain legacy
exact commercial tier or exact API-token count fields as first-class analytics
dimensions just because older clients once sent them.
That same anonymous telemetry floor now also permits only privacy-safe
That same outbound usage telemetry floor now also permits only privacy-safe
aggregate self-hosted adoption counters: counts of monitored platforms,
workloads, storage resources, physical disks, Ceph clusters, network shares,
TrueNAS and VMware resource categories, availability targets, and active
@ -622,7 +622,7 @@ alerts. Those counts may describe scale and feature adoption, but they must not
include hostnames, resource IDs, infrastructure identifiers, credentials,
prompts, chat messages, command text, action output, token values, or personal
information.
That same anonymous telemetry floor now also permits content-free update
That same outbound usage telemetry floor now also permits content-free update
funnel counters derived from local update history inside the same rotating
30-day telemetry window: update attempts, successful updates, failed or
rolled-back updates, and the latest coarse failure category. The category may
@ -631,7 +631,7 @@ identify only the governed class (`download`, `signature`, `checksum`,
`cancelled`, or `unknown`). It must not export raw updater error text,
download URLs, command output, log lines, paths, hostnames, release asset URLs,
checksums, signatures, or operator-entered values.
That same anonymous telemetry floor now also permits only content-free Pulse
That same outbound usage telemetry floor now also permits only content-free Pulse
Patrol control and governed Pulse Intelligence operations adoption flags and
counters inside the same rotating 30-day telemetry window:
configured/active/completed/resolved governed-operation and approved-execution
@ -713,7 +713,7 @@ content-free Pulse Intelligence fields only alongside the canonical coarse
entry-to-retention cohorts, paid Patrol-control completed/resolved
cohorts, and observed free-to-paid conversion counts without linking telemetry
to customer accounts or storing exact commercial tiers. The report may also
derive or persist a completed governed-operation signal from those same anonymous
derive or persist a completed governed-operation signal from those same content-free
fields, but completion may only mean observed Patrol
issue evidence plus Assistant governed-context or MCP collaboration activity
plus approved/rejected governed-action decision evidence inside the source window;
@ -733,7 +733,7 @@ collaboration, and at least one approved governed action completed
successfully. It may not encode finding IDs, resource IDs, fix details,
verification detail, command text, action output, approver identity, or a
causal claim that the approved action directly resolved the finding.
The Patrol control completed-loop status count follows that same anonymous
The Patrol control completed-loop status count follows that same content-free telemetry
evidence contract: it may only mean the same content-free window also had
Patrol issue evidence, contextual collaboration, and either a rejected governed
decision or an approved governed decision with verified outcome proof. Legacy
@ -741,7 +741,7 @@ Patrol autonomy and Pro activation completed-loop fields may mirror that value
for compatibility, but must not add checkout/account identity, prompt content,
action identity, resource identity, finding identity, token identity, or a
causality claim.
The Patrol control resolved-loop status count follows that same anonymous
The Patrol control resolved-loop status count follows that same content-free telemetry
evidence contract: it may only mean the same content-free window also had
Patrol issue evidence, contextual collaboration, an approved governed decision,
and verified outcome proof. It must not require MCP readiness, treat rejected
@ -751,7 +751,7 @@ the status projection and outbound telemetry must derive these Patrol control
completed/resolved values through the shared `internal/telemetry` proof
classifier so privacy-sensitive reporting cannot drift into a richer runtime
event join in one caller.
That same anonymous telemetry contract also treats `install_id` as a rotating
That same outbound usage telemetry contract also treats `install_id` as a rotating
pseudonymous identifier, not a lifetime install handle. The runtime may keep a
local rotating UUID so startup and heartbeat pings can still represent an
active installation window, but it may not preserve one stable install

View file

@ -109,6 +109,12 @@ handlers, including Pro activation entry-point telemetry for the same
operations-loop prompt, is likewise API/privacy/commercial activation evidence
only; storage and recovery surfaces must not treat it as backup readiness,
restore capability, recovered-state proof, or storage-health verification.
Outbound usage telemetry projection in `internal/api/telemetry_pulse_intelligence.go`
is likewise API/privacy evidence only. Update funnel and governed-action
adoption counters may summarize content-free activity for product analytics,
but storage and recovery surfaces must not treat those counters as backup
coverage, restore capability, recovery-job proof, storage-health verification,
or evidence that a specific resource was protected or recovered.
Proxmox page stale-agent notices are adjacent frontend and agent-lifecycle
plumbing even though `ProxmoxPageSurface` is a storage/recovery canonical file.
Those notices may link an operator to scoped agent update commands for

View file

@ -306,7 +306,7 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame
| `PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION` | Allow Proxmox-side LXC Docker socket hinting with `pct exec` | `false` |
| `PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY` | Allow Proxmox-side minimal LXC Docker inventory collection with `pct exec`; collects Docker host/container summary, not inspect/env/mount/process data | `false` |
| `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS` | Optional comma-separated VMID allowlist for Proxmox-side LXC Docker inventory; empty means all running Docker-enabled LXCs are eligible when inventory is enabled | *(unset)* |
| `PULSE_TELEMETRY` | Anonymous outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
| `PULSE_TELEMETRY` | Outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
### Logging Overrides

View file

@ -4,7 +4,7 @@ Pulse is designed to run locally. By default, your monitoring data stays on your
## Usage Data
Pulse has one outbound usage-data scope: **anonymous outbound telemetry** to
Pulse has one outbound usage-data scope: **outbound usage telemetry** to
help me understand active installations, release uptake, and feature use in
aggregate.
@ -12,15 +12,15 @@ Commercial activation and license-recovery runtime records stay on the Pulse
instance where they were created. They are not exported to Pulse infrastructure,
third-party analytics, support diagnostics, or ordinary Settings surfaces.
### Anonymous outbound telemetry
### Outbound usage telemetry
Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted.
Pulse includes outbound usage telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours with a rotating pseudonymous install ID to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted.
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, or personally identifiable information is ever sent. See the full field list below.
The telemetry payload does not include hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, names, email addresses, or account identifiers. See the full field list below.
#### How to disable
- **Settings → System → General → Anonymous outbound telemetry** (toggle off), or
- **Settings → System → General → Outbound usage telemetry** (toggle off), or
- Set the environment variable `PULSE_TELEMETRY=false`
#### How to inspect or rotate it
@ -31,7 +31,7 @@ No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, ch
#### Exactly what is sent
Every field is listed below with the reason it exists — nothing else leaves your server:
Every field is listed below with the reason it exists. Nothing else is included in the telemetry payload:
| Field | Example | Purpose |
|-------|---------|---------|
@ -148,19 +148,19 @@ Every field is listed below with the reason it exists — nothing else leaves yo
- Pulse may derive aggregate Pulse Intelligence adoption reports from those same rows, including whether an install reached Patrol issue activity, Patrol resolution, Assistant, direct external-agent, or MCP collaboration, Patrol mode starter use, paid Patrol mode cohorts, governed-action activity, approved or rejected action decisions, approved action success, completed Patrol control work, recent retention, and observed free-to-paid movement within the source window. Those reports do not add prompts, findings, resource identifiers, tool names, tool inputs, tool outputs, command payloads, action outputs, account links, or exact commercial tiers.
- External-agent/MCP activity is stored only as a coarse adapter-origin flag plus capability-class counters: context, event stream, provisioning, operator state, findings, and action requests.
- Telemetry rows older than **90 days** are purged automatically.
- The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows.
- The license server uses request IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows.
#### What is NOT sent
- No IP addresses are stored in telemetry rows
- No IP addresses are included in the telemetry payload or stored in telemetry rows
- No hostnames, node names, VM names, or any infrastructure identifiers
- No Proxmox credentials, API tokens, or passwords
- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values
- No personally identifiable information of any kind
- No names, email addresses, account identifiers, or other intentionally identifying personal content
#### Install ID rotation
The telemetry install ID is pseudonymous and rotates automatically every 30 days.
The telemetry install ID is pseudonymous, is not tied to a Pulse account, and rotates automatically every 30 days.
Pulse keeps it only to avoid treating every startup ping as a brand-new install
while still limiting long-term linkage from one heartbeat window to the next.
Operators can also rotate it immediately from **Settings → System → General → Reset ID**.

View file

@ -5,7 +5,7 @@ import { EN_MESSAGES } from '@/i18n/messages';
const migratedGeneralSettingsCopy = [
'Usage data and privacy',
'Anonymous outbound telemetry',
'Outbound usage telemetry',
'Preview payload',
'Refresh payload',
'Reset ID',
@ -54,7 +54,10 @@ describe('GeneralSettingsPanel guardrails', () => {
'retained for up to 90 days',
);
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
'IP addresses are not stored in telemetry rows',
'request IP addresses are used only transiently for rate limiting',
);
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
'not stored in telemetry rows',
);
});

View file

@ -70,7 +70,7 @@ describe('GeneralSettingsPanel localization', () => {
screen.getByText(/Commands, resource names, and API fields stay unchanged/),
).toBeInTheDocument();
expect(screen.getByText('Usage data and privacy')).toBeInTheDocument();
expect(screen.getByText('Anonymous outbound telemetry')).toBeInTheDocument();
expect(screen.getByText('Outbound usage telemetry')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument();
expect(screen.getByText('Monitoring cadence')).toBeInTheDocument();

View file

@ -626,13 +626,13 @@ describe('settings architecture guardrails', () => {
'aggregate self-hosted adoption',
);
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
'counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters',
'aggregate self-hosted adoption counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters',
);
expect(EN_MESSAGES['settings.general.telemetry.description']).not.toContain(
'Pulse Intelligence loop adoption',
);
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
'identifiers, prompts, chat messages, command text, action output, token values, or personal information are sent.',
'identifiers, prompts, chat messages, command text, action output, token values, names, email addresses, or IP addresses',
);
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.payloadAriaLabel');
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.resetId');

View file

@ -322,7 +322,7 @@ export function useSystemSettingsState({
try {
await SettingsAPI.updateSystemSettings({ telemetryEnabled: enabled });
notificationStore.success(
enabled ? 'Anonymous outbound telemetry enabled' : 'Anonymous outbound telemetry disabled',
enabled ? 'Outbound usage telemetry enabled' : 'Outbound usage telemetry disabled',
3000,
);
} catch (error) {
@ -374,7 +374,7 @@ export function useSystemSettingsState({
if (
!confirm(
'Reset the rotating telemetry install ID now? This immediately replaces the local identifier used for anonymous telemetry.',
'Reset the rotating telemetry install ID now? This immediately replaces the local pseudonymous identifier used for outbound usage telemetry.',
)
) {
return;

View file

@ -76,6 +76,7 @@ describe('localized setup wizard journey', () => {
expect(screen.getByText('Bienvenido a Pulse')).toBeInTheDocument();
expect(screen.getByText('Desbloquear configuración')).toBeInTheDocument();
expect(screen.getByText(/Conecta una API de plataforma/)).toBeInTheDocument();
expect(screen.getByText('La telemetría de uso está activada por defecto')).toBeInTheDocument();
expect(screen.getByText('sudo pulse bootstrap-token')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Verificar token de bootstrap →' }),

View file

@ -71,6 +71,14 @@ describe('WelcomeStep', () => {
'Connect a platform API, install Pulse Agent, or use both for full coverage.',
),
).toBeInTheDocument();
expect(screen.getByText('Usage telemetry is enabled by default')).toBeInTheDocument();
expect(
screen.getByText(/To disable it before any ping, set PULSE_TELEMETRY=false/),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Full details' })).toHaveAttribute(
'href',
'/docs/PRIVACY.md',
);
expect(screen.getByText('What this token does')).toBeInTheDocument();
expect(
screen.getByText(

View file

@ -4,7 +4,9 @@ import { showError, showSuccess } from '@/utils/toast';
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
import { copyToClipboard } from '@/utils/clipboard';
import { Copy, Check, Terminal } from 'lucide-solid';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
import { Copy, Check, Terminal, ShieldCheck } from 'lucide-solid';
interface WelcomeStepProps {
onNext: () => void;
@ -194,6 +196,23 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
</p>
</div>
<div class="mb-6 max-w-2xl mx-auto rounded-md border border-border bg-surface px-4 py-3 text-left">
<div class="flex items-start gap-3">
<ShieldCheck class="mt-0.5 h-4 w-4 shrink-0 text-blue-500" strokeWidth={2} />
<div class="min-w-0">
<p class="text-sm font-medium text-base-content">
{t('setup.welcome.telemetryNotice.title')}
</p>
<p class="mt-1 text-xs leading-5 text-muted">
{t('setup.welcome.telemetryNotice.description')}{' '}
<ExternalTextLink href={PRIVACY_DOC_URL} variant="muted">
{t('setup.welcome.telemetryNotice.detailsLink')}
</ExternalTextLink>
</p>
</div>
</div>
</div>
<Show when={!props.isUnlocked}>
<div class="p-6 sm:p-8 max-w-2xl mx-auto bg-surface border border-border rounded-md text-left">
<h3 class="text-lg font-semibold text-base-content mb-2 tracking-tight">

View file

@ -193,9 +193,13 @@ describe('i18n foundation', () => {
it('keeps machine-facing identifiers unchanged in first-wave settings general catalog copy', () => {
for (const locale of FIRST_LOCALIZATION_LOCALES) {
const telemetryDescription = I18N_MESSAGES[locale]['settings.general.telemetry.description'];
expect(I18N_MESSAGES[locale]['settings.general.language.description']).toContain('API');
expect(I18N_MESSAGES[locale]['settings.general.telemetry.description']).toContain('Pulse');
expect(I18N_MESSAGES[locale]['settings.general.telemetry.description']).toContain('IP');
expect(telemetryDescription).toContain('Pulse');
expect(telemetryDescription).toContain('IP');
expect(telemetryDescription).toContain('90');
expect(telemetryDescription).not.toMatch(/anonymous/i);
expect(I18N_MESSAGES[locale]['settings.general.telemetry.copyJson']).toContain('JSON');
expect(
t(
@ -228,6 +232,11 @@ describe('i18n foundation', () => {
it('keeps machine-facing identifiers unchanged in first-session monitoring catalog copy', () => {
for (const locale of FIRST_LOCALIZATION_LOCALES) {
const telemetryNotice = I18N_MESSAGES[locale]['setup.welcome.telemetryNotice.description'];
expect(telemetryNotice).toContain('Pulse');
expect(telemetryNotice).toContain('PULSE_TELEMETRY=false');
expect(telemetryNotice).not.toMatch(/anonymous/i);
expect(I18N_MESSAGES[locale]['setup.welcome.deploymentHint.dockerUnnamed']).toContain(
'<pulse-container>',
);

View file

@ -276,6 +276,10 @@ export const DE_MESSAGE_OVERRIDES = {
'setup.welcome.hero.title': 'Willkommen bei Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Bootstrap-Token einfuegen',
'setup.welcome.success.commandCopied': 'Befehl in Zwischenablage kopiert',
'setup.welcome.telemetryNotice.description':
'Ausgehende Nutzungstelemetrie ist standardmaessig aktiviert. Pulse sendet einen verzoegerten Start-Ping und einen taeglichen Heartbeat mit einer rotierenden pseudonymen Installations-ID, Release-/Runtime-Details, aggregierten Zaehlern und Funktionsflags. Um sie vor jedem Ping zu deaktivieren, setzen Sie PULSE_TELEMETRY=false, bevor Sie Pulse starten; spaeter koennen Sie sie auch in den Einstellungen ausschalten.',
'setup.welcome.telemetryNotice.detailsLink': 'Details',
'setup.welcome.telemetryNotice.title': 'Nutzungstelemetrie ist standardmaessig aktiviert',
'setup.welcome.tokenHelp.afterVerify':
'Nachdem Pulse dieses Token geprueft hat, erstellen Sie im naechsten Schritt das Admin-Konto fuer diesen Server.',
'setup.welcome.tokenHelp.docker':
@ -322,7 +326,7 @@ export const DE_MESSAGE_OVERRIDES = {
'settings.general.temperature.title': 'Temperatureinheit',
'settings.general.telemetry.copyJson': 'JSON kopieren',
'settings.general.telemetry.description':
'Helfen Sie, Pulse zu verbessern, indem Sie anonyme ausgehende Nutzungsdaten teilen: eine rotierende Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, aggregierte Self-hosted-Nutzungszahlen, grobe Funktionsflags sowie grobe Nutzungszaehler fuer Patrol, Assistant und externe Agents. Es werden keine Hostnamen, Zugangsdaten, Infrastrukturkennungen, Prompts, Chatnachrichten, Befehlstexte, Aktionsausgaben, Token-Werte oder personenbezogenen Daten gesendet. Telemetriezeilen werden bis zu 90 Tage aufbewahrt, und IP-Adressen werden nicht in Telemetriezeilen gespeichert.',
'Helfen Sie, Pulse zu verbessern, indem Sie ausgehende Nutzungsdaten teilen: eine rotierende pseudonyme Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, aggregierte Self-hosted-Nutzungszahlen, grobe Funktionsflags sowie grobe Nutzungszaehler fuer Patrol, Assistant und externe Agents. Der Payload enthaelt keine Hostnamen, Zugangsdaten, Infrastrukturkennungen, Prompts, Chatnachrichten, Befehlstexte, Aktionsausgaben, Token-Werte, Namen, E-Mail-Adressen oder IP-Adressen. Telemetriezeilen werden bis zu 90 Tage aufbewahrt, und Anfrage-IP-Adressen werden nur kurzzeitig fuer Rate-Limiting verwendet und nicht in Telemetriezeilen gespeichert.',
'settings.general.telemetry.disabledPreview':
'Telemetrie ist derzeit deaktiviert. Diese Vorschau zeigt den Payload, den Pulse senden wuerde, wenn Sie sie aktivieren.',
'settings.general.telemetry.fullDetails': 'Details',
@ -332,9 +336,9 @@ export const DE_MESSAGE_OVERRIDES = {
'settings.general.telemetry.refreshPayload': 'Payload aktualisieren',
'settings.general.telemetry.resetId': 'ID zuruecksetzen',
'settings.general.telemetry.section.description':
'Steuern Sie anonyme ausgehende Telemetrie von dieser Pulse-Instanz.',
'Steuern Sie ausgehende Nutzungstelemetrie von dieser Pulse-Instanz.',
'settings.general.telemetry.section.title': 'Nutzungsdaten und Datenschutz',
'settings.general.telemetry.title': 'Anonyme ausgehende Telemetrie',
'settings.general.telemetry.title': 'Ausgehende Nutzungstelemetrie',
'settings.general.theme.description': 'Waehlen Sie hell, dunkel oder die Systemeinstellung.',
'settings.general.theme.option.dark': 'Dunkel',
'settings.general.theme.option.light': 'Hell',

View file

@ -268,6 +268,10 @@ export const ES_MESSAGE_OVERRIDES = {
'setup.welcome.hero.title': 'Bienvenido a Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Pega tu token de bootstrap',
'setup.welcome.success.commandCopied': 'Comando copiado al portapapeles',
'setup.welcome.telemetryNotice.description':
'La telemetría de uso saliente está activada por defecto. Pulse envía un ping de inicio retrasado y un heartbeat diario con un ID de instalación seudónimo rotativo, detalles de versión/runtime, conteos agregados y flags de funciones. Para desactivarla antes de cualquier ping, define PULSE_TELEMETRY=false antes de iniciar Pulse; también puedes desactivarla luego en Ajustes.',
'setup.welcome.telemetryNotice.detailsLink': 'Detalles completos',
'setup.welcome.telemetryNotice.title': 'La telemetría de uso está activada por defecto',
'setup.welcome.tokenHelp.afterVerify':
'Después de que Pulse verifique este token, el siguiente paso es crear la cuenta de administrador para este servidor.',
'setup.welcome.tokenHelp.docker':
@ -314,7 +318,7 @@ export const ES_MESSAGE_OVERRIDES = {
'settings.general.temperature.title': 'Unidad de temperatura',
'settings.general.telemetry.copyJson': 'Copiar JSON',
'settings.general.telemetry.description':
'Ayuda a mejorar Pulse compartiendo datos de uso salientes anónimos: un ID de instalación rotativo, identidad de versión normalizada, plataforma de ejecución, conteos agregados de adopción autohospedada, flags de funciones de alto nivel, y contadores generales de uso de Patrol, Assistant y agentes externos. No se envían hostnames, credenciales, identificadores de infraestructura, prompts, mensajes de chat, texto de comandos, salida de acciones, valores de tokens ni información personal. Las filas de telemetría se conservan hasta 90 días y las direcciones IP no se guardan en filas de telemetría.',
'Ayuda a mejorar Pulse compartiendo datos de uso salientes: un ID de instalación seudónimo rotativo, identidad de versión normalizada, plataforma de ejecución, conteos agregados de adopción autohospedada, flags de funciones de alto nivel, y contadores generales de uso de Patrol, Assistant y agentes externos. El payload no incluye hostnames, credenciales, identificadores de infraestructura, prompts, mensajes de chat, texto de comandos, salida de acciones, valores de tokens, nombres, direcciones de email ni direcciones IP. Las filas de telemetría se conservan hasta 90 días, y las direcciones IP de las solicitudes se usan solo transitoriamente para rate limiting y no se guardan en filas de telemetría.',
'settings.general.telemetry.disabledPreview':
'La telemetría está desactivada. Esta vista previa muestra el payload que Pulse enviaría si la activas.',
'settings.general.telemetry.fullDetails': 'Detalles completos',
@ -324,9 +328,9 @@ export const ES_MESSAGE_OVERRIDES = {
'settings.general.telemetry.refreshPayload': 'Actualizar payload',
'settings.general.telemetry.resetId': 'Restablecer ID',
'settings.general.telemetry.section.description':
'Controla telemetría saliente anónima de esta instancia de Pulse.',
'Controla la telemetría de uso saliente de esta instancia de Pulse.',
'settings.general.telemetry.section.title': 'Datos de uso y privacidad',
'settings.general.telemetry.title': 'Telemetría saliente anónima',
'settings.general.telemetry.title': 'Telemetría de uso saliente',
'settings.general.theme.description': 'Elige claro, oscuro o sincroniza con el tema del sistema.',
'settings.general.theme.option.dark': 'Oscuro',
'settings.general.theme.option.light': 'Claro',

View file

@ -262,6 +262,10 @@ export const EN_MESSAGES = {
'setup.welcome.hero.title': 'Welcome to Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Paste your bootstrap token',
'setup.welcome.success.commandCopied': 'Command copied to clipboard',
'setup.welcome.telemetryNotice.description':
'Outbound usage telemetry is on by default. Pulse sends a delayed startup ping and daily heartbeat with a rotating pseudonymous install ID, release/runtime details, aggregate counts, and feature flags. To disable it before any ping, set PULSE_TELEMETRY=false before starting Pulse; you can also turn it off later in Settings.',
'setup.welcome.telemetryNotice.detailsLink': 'Full details',
'setup.welcome.telemetryNotice.title': 'Usage telemetry is enabled by default',
'setup.welcome.tokenHelp.afterVerify':
'After Pulse verifies this token, the next step is creating the admin account for this server.',
'setup.welcome.tokenHelp.docker':
@ -307,7 +311,7 @@ export const EN_MESSAGES = {
'settings.general.temperature.title': 'Temperature unit',
'settings.general.telemetry.copyJson': 'Copy JSON',
'settings.general.telemetry.description':
'Help improve Pulse by sharing anonymous outbound usage data: a rotating install ID, normalized release identity, runtime platform, aggregate self-hosted adoption counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters. No hostnames, credentials, infrastructure identifiers, prompts, chat messages, command text, action output, token values, or personal information are sent. Telemetry rows are retained for up to 90 days, and IP addresses are not stored in telemetry rows.',
'Help improve Pulse by sharing outbound usage data: a rotating pseudonymous install ID, normalized release identity, runtime platform, aggregate self-hosted adoption counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters. The payload does not include hostnames, credentials, infrastructure identifiers, prompts, chat messages, command text, action output, token values, names, email addresses, or IP addresses. Telemetry rows are retained for up to 90 days, and request IP addresses are used only transiently for rate limiting and are not stored in telemetry rows.',
'settings.general.telemetry.disabledPreview':
'Telemetry is currently disabled. This preview shows the payload Pulse would send if you enable it.',
'settings.general.telemetry.fullDetails': 'Full details',
@ -317,9 +321,9 @@ export const EN_MESSAGES = {
'settings.general.telemetry.refreshPayload': 'Refresh payload',
'settings.general.telemetry.resetId': 'Reset ID',
'settings.general.telemetry.section.description':
'Control anonymous outbound telemetry from this Pulse instance.',
'Control outbound usage telemetry from this Pulse instance.',
'settings.general.telemetry.section.title': 'Usage data and privacy',
'settings.general.telemetry.title': 'Anonymous outbound telemetry',
'settings.general.telemetry.title': 'Outbound usage telemetry',
'settings.general.theme.description': 'Choose light, dark, or sync with your system theme.',
'settings.general.theme.option.dark': 'Dark',
'settings.general.theme.option.light': 'Light',
@ -631,6 +635,9 @@ export const FIRST_SESSION_MONITORING_MIGRATED_MESSAGE_KEYS = [
'setup.welcome.hero.title',
'setup.welcome.placeholder.bootstrapToken',
'setup.welcome.success.commandCopied',
'setup.welcome.telemetryNotice.description',
'setup.welcome.telemetryNotice.detailsLink',
'setup.welcome.telemetryNotice.title',
'setup.welcome.tokenHelp.afterVerify',
'setup.welcome.tokenHelp.docker',
'setup.welcome.tokenHelp.host',

View file

@ -50,7 +50,7 @@ export interface SystemConfig {
publicURL?: string; // Public URL for email notifications (e.g., http://198.51.100.100:8080)
disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide)
reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts
telemetryEnabled?: boolean; // Opt-in anonymous usage telemetry
telemetryEnabled?: boolean; // Outbound usage telemetry, enabled by default unless disabled
}
/**

View file

@ -127,7 +127,7 @@ describe('systemSettingsPresentation', () => {
expect(getDockerUpdateActionsUpdateErrorMessage()).toBe(
'Unable to update Docker / Podman update actions.',
);
expect(getTelemetryUpdateErrorMessage()).toBe('Unable to update anonymous telemetry.');
expect(getTelemetryUpdateErrorMessage()).toBe('Unable to update outbound usage telemetry.');
expect(getTemperatureMonitoringUpdateErrorMessage()).toBe(
'Unable to update temperature monitoring.',
);

View file

@ -173,7 +173,7 @@ export function getDockerUpdateActionsUpdateErrorMessage(message?: string): stri
}
export function getTelemetryUpdateErrorMessage(message?: string): string {
return message || 'Unable to update anonymous telemetry.';
return message || 'Unable to update outbound usage telemetry.';
}
export function getTemperatureMonitoringUpdateErrorMessage(message?: string): string {

View file

@ -15109,7 +15109,7 @@ func TestContract_ExternalAgentActivityUsesRouteSpecificMarkers(t *testing.T) {
}
// TestContract_UpdateFunnelTelemetryStaysContentFree pins the server-owned
// update funnel telemetry path. Update history may feed anonymous adoption
// update funnel telemetry path. Update history may feed outbound usage adoption
// analysis, but only as aggregate 30-day counts and a coarse failure category,
// and the operator preview type must expose the same JSON fields.
func TestContract_UpdateFunnelTelemetryStaysContentFree(t *testing.T) {
@ -15144,12 +15144,16 @@ func TestContract_UpdateFunnelTelemetryStaysContentFree(t *testing.T) {
}
for _, fragment := range []string{
"func (r *Router) ApplyUpdateTelemetrySnapshot(s *telemetry.Snapshot, now time.Time)",
"counters to the outbound usage telemetry snapshot",
"telemetry.ApplyUpdateTelemetrySnapshot(s, r.updateHistory, now)",
} {
if !strings.Contains(string(routerSource), fragment) {
t.Errorf("router-owned update telemetry boundary missing %s", fragment)
}
}
if strings.Contains(string(routerSource), "anonymous telemetry") {
t.Error("router-owned update telemetry boundary must not describe outbound usage telemetry as anonymous")
}
settingsSource, err := os.ReadFile(filepath.Join("..", "..", "frontend-modern", "src", "api", "settings.ts"))
if err != nil {

View file

@ -10,7 +10,7 @@ import (
)
// ApplyUpdateTelemetrySnapshot adds router-owned, content-free update funnel
// counters to the anonymous telemetry snapshot.
// counters to the outbound usage telemetry snapshot.
func (r *Router) ApplyUpdateTelemetrySnapshot(s *telemetry.Snapshot, now time.Time) {
if r == nil || s == nil {
return
@ -19,7 +19,7 @@ func (r *Router) ApplyUpdateTelemetrySnapshot(s *telemetry.Snapshot, now time.Ti
}
// GetPulseIntelligenceActionTelemetry returns count-only action-governance
// telemetry for the anonymous Pulse Intelligence loop. It deliberately drops
// telemetry for the outbound Pulse Intelligence usage loop. It deliberately drops
// command text, approval actors/reasons, action outputs, and resource IDs.
func (r *Router) GetPulseIntelligenceActionTelemetry(since time.Time) telemetry.PulseIntelligenceActionSnapshot {
var snapshot telemetry.PulseIntelligenceActionSnapshot

View file

@ -206,7 +206,7 @@ type Config struct {
EnableProxmoxGuestDockerDetection bool `envconfig:"PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION" default:"false" json:"-"` // Allow Proxmox-side pct exec probes that only detect Docker socket presence inside LXC guests
EnableProxmoxGuestDockerInventory bool `envconfig:"PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY" default:"false" json:"-"` // Allow Proxmox-side pct exec collection of minimal Docker inventory from LXC guests
ProxmoxGuestDockerInventoryVMIDs string `envconfig:"PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS" default:"" json:"-"` // Optional comma-separated Proxmox VMID allowlist for LXC Docker inventory
TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Anonymous outbound usage telemetry enabled by default (install ID, version, resource counts, feature flags — opt out any time)
TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Outbound usage telemetry enabled by default (pseudonymous install ID, version, resource counts, feature flags — opt out any time)
MultiTenantEnabled bool `envconfig:"PULSE_MULTI_TENANT_ENABLED" default:"false"` // Enable multi-tenant support
MetricsToken string `envconfig:"PULSE_METRICS_TOKEN" default:"" json:"-"` // Bearer token for /metrics endpoint (empty = unauthenticated)
MetricsBindAddress string `envconfig:"PULSE_METRICS_BIND_ADDRESS" default:"127.0.0.1" json:"-"` // Bind address for /metrics endpoint
@ -1112,7 +1112,7 @@ func load(initLogging bool) (*Config, error) {
cfg.TelemetryEnabled = enabled
cfg.EnvOverrides["PULSE_TELEMETRY"] = true
cfg.EnvOverrides["telemetryEnabled"] = true
log.Info().Bool("enabled", enabled).Msg("Overriding anonymous outbound telemetry setting from environment")
log.Info().Bool("enabled", enabled).Msg("Overriding outbound usage telemetry setting from environment")
} else {
log.Warn().Str("value", telemetryStr).Msg("Invalid PULSE_TELEMETRY value, ignoring")
}

View file

@ -85,6 +85,24 @@ func TestLoad_EnvOverrides(t *testing.T) {
assert.Equal(t, "admin", cfg.AuthUser)
}
func TestLoad_TelemetryEnabledDefaultAndEnvOverride(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
os.Unsetenv("PULSE_TELEMETRY")
cfg, err := Load()
require.NoError(t, err)
assert.True(t, cfg.TelemetryEnabled)
assert.False(t, cfg.EnvOverrides["PULSE_TELEMETRY"])
assert.False(t, cfg.EnvOverrides["telemetryEnabled"])
t.Setenv("PULSE_TELEMETRY", "false")
cfg, err = Load()
require.NoError(t, err)
assert.False(t, cfg.TelemetryEnabled)
assert.True(t, cfg.EnvOverrides["PULSE_TELEMETRY"])
assert.True(t, cfg.EnvOverrides["telemetryEnabled"])
}
func TestLoad_AgentIngestPortDefaultsDisabled(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
os.Unsetenv("PULSE_AGENT_INGEST_PORT")

View file

@ -1467,7 +1467,7 @@ type SystemSettings struct {
ReduceProUpsellNoise bool `json:"reduceProUpsellNoise,omitempty"` // Legacy compatibility preference for proactive commercial prompts
// Telemetry (enabled by default, opt-out)
TelemetryEnabled *bool `json:"telemetryEnabled,omitempty"` // Send anonymous usage telemetry (install ID, version, resource counts, feature flags — no PII)
TelemetryEnabled *bool `json:"telemetryEnabled,omitempty"` // Send outbound usage telemetry (rotating pseudonymous install ID, version, resource counts, feature flags)
// APIToken is not persisted in system settings; API tokens are managed in api_tokens.json.
}

View file

@ -23,7 +23,7 @@ func (e PulseIntelligenceAIUsageEvidence) AssistantCollaborationActive() bool {
}
// PulseIntelligenceAIUsageEvidenceFromHistory projects AI usage history into
// the same anonymous Pulse Intelligence evidence used by telemetry and the
// the same content-free Pulse Intelligence evidence used by telemetry and the
// agent operations-loop status endpoint.
func PulseIntelligenceAIUsageEvidenceFromHistory(history *config.AIUsageHistoryData, since time.Time) PulseIntelligenceAIUsageEvidence {
var evidence PulseIntelligenceAIUsageEvidence
@ -103,7 +103,7 @@ func (e PulseIntelligenceExternalAgentEvidence) CollaborationCount() int {
}
// PulseIntelligenceExternalAgentEvidenceFromHistory projects external-agent
// activity history into anonymous collaboration evidence.
// activity history into content-free collaboration evidence.
func PulseIntelligenceExternalAgentEvidenceFromHistory(history *config.ExternalAgentActivityHistoryData, since time.Time) PulseIntelligenceExternalAgentEvidence {
var evidence PulseIntelligenceExternalAgentEvidence
if history == nil {

View file

@ -1,4 +1,4 @@
// Package telemetry provides anonymous usage telemetry for Pulse.
// Package telemetry provides outbound usage telemetry for Pulse.
//
// Pulse sends a lightweight ping on startup and once every 24 hours to help the
// developer understand how many active installations exist and which features are
@ -41,17 +41,17 @@
//
// # What is NOT sent
//
// - No IP addresses are stored server-side
// - No IP addresses are included in the payload or stored in telemetry rows
// - No hostnames, node names, VM names, or any infrastructure identifiers
// - No Proxmox credentials, API tokens, or passwords
// - No alert content, AI prompts, chat messages, command text, action output, or token values
// - No action targets, resource IDs, finding IDs, approval actors, or approval reasons
// - No personally identifiable information of any kind
// - No names, email addresses, account identifiers, or other intentionally identifying personal content
//
// # How to disable
//
// Set the environment variable PULSE_TELEMETRY=false, or toggle off
// "Anonymous outbound telemetry" in Settings → System → General.
// "Outbound usage telemetry" in Settings → System → General.
package telemetry
import (
@ -73,7 +73,7 @@ import (
"github.com/rs/zerolog/log"
)
// pingEndpoint is the URL that receives anonymous telemetry pings.
// pingEndpoint is the URL that receives outbound usage telemetry pings.
// It is a var (not const) so that tests can redirect it to a local server.
var pingEndpoint = "https://license.pulserelay.pro/v1/telemetry/ping"
@ -440,7 +440,7 @@ type PulseIntelligencePatrolControlProofInput struct {
}
// PulseIntelligencePatrolControlProof is the shared classification used by
// the native status endpoint and anonymous outbound telemetry.
// the native status endpoint and outbound usage telemetry.
type PulseIntelligencePatrolControlProof struct {
Completed bool
Resolved bool
@ -591,15 +591,15 @@ var (
current *runner
)
// Start begins anonymous outbound telemetry if enabled.
// Start begins outbound usage telemetry if enabled.
// It reads or creates a rotating install ID in dataDir, waits for the monitor
// to populate state, sends a startup ping, and schedules a daily heartbeat.
// Call Stop() on shutdown.
//
// This is a no-op when anonymous outbound telemetry is not opted in.
// This is a no-op when outbound usage telemetry is disabled.
func Start(ctx context.Context, cfg Config) {
if !cfg.Enabled {
log.Info().Msg("Anonymous outbound telemetry is disabled (enable via PULSE_TELEMETRY=true or Settings → System)")
log.Info().Msg("Outbound usage telemetry is disabled (enable via PULSE_TELEMETRY=true or Settings → System)")
return
}
@ -623,7 +623,7 @@ func Start(ctx context.Context, cfg Config) {
log.Info().
Str("platform", base.Platform).
Msg("Anonymous outbound telemetry enabled — sends a rotating install ID, version identity, platform, OS/arch, resource counts, feature flags, and coarse Patrol, Assistant, and external-agent usage counters (nothing else)")
Msg("Outbound usage telemetry enabled — sends a rotating pseudonymous install ID, version identity, platform, OS/arch, resource counts, feature flags, and coarse Patrol, Assistant, and external-agent usage counters")
r.wg.Add(1)
go func() {

View file

@ -639,6 +639,38 @@ func TestPulseIntelligenceTelemetryFieldsAreDisclosed(t *testing.T) {
}
}
func TestTelemetryPrivacyDocsDisclosePseudonymousIdentityAndIPHandling(t *testing.T) {
for _, relativePath := range []string{
filepath.Join("..", "..", "docs", "PRIVACY.md"),
filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"),
} {
raw, err := os.ReadFile(relativePath)
if err != nil {
t.Fatalf("read %s: %v", relativePath, err)
}
content := string(raw)
normalized := normalizedTelemetryDisclosureText(content)
for _, required := range []string{
"outbound usage telemetry",
"enabled by default",
"rotating pseudonymous install ID",
"PULSE_TELEMETRY=false",
"The license server uses request IP addresses transiently for abuse/rate limiting",
} {
if !strings.Contains(content, required) {
t.Errorf("%s must disclose %q", relativePath, required)
}
}
if !strings.Contains(normalized, "does not store ip addresses in telemetry rows") {
t.Errorf("%s must disclose that telemetry rows do not store IP addresses", relativePath)
}
if strings.Contains(normalized, "anonymous telemetry") {
t.Errorf("%s must not describe outbound usage telemetry as anonymous", relativePath)
}
}
}
func normalizedTelemetryDisclosureTableText(value string) string {
tableLines := make([]string, 0)
for _, line := range strings.Split(value, "\n") {
@ -666,6 +698,7 @@ func normalizedTelemetryDisclosureText(value string) string {
".", " ",
":", " ",
";", " ",
"*", " ",
"(", " ",
")", " ",
"[", " ",

View file

@ -129,6 +129,20 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase):
self.assertNotIn('EVAL_RESOURCE_CONTEXT_FORBIDDEN="/mnt/pve/finance-db,/var/lib/homeassistant,secret"', content)
self.assertNotRegex(content, r"(?i)understands resources before you ask")
def test_public_ai_privacy_copy_discloses_outbound_usage_telemetry(self) -> None:
content = read_repo_text("docs/AI.md")
normalized_content = " ".join(content.split())
self.assertIn("## Privacy", content)
self.assertIn("Outbound usage telemetry", content)
self.assertIn("rotating pseudonymous install ID", normalized_content)
self.assertIn(
"no hostnames, credentials, prompts, chat messages, command text, action output, token values, IP addresses, or resource identifiers in the payload",
normalized_content,
)
self.assertIn("enabled by default and can be disabled any time", normalized_content)
self.assertNotIn("anonymous telemetry", normalized_content.lower())
def test_public_ai_docs_use_current_surface_naming(self) -> None:
for doc_path in PUBLIC_AI_DOC_PATHS:
with self.subTest(doc_path=doc_path):