From dbca44937b2195f0a2f817768d99445ceeb7be18 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Jul 2026 01:10:38 +0100 Subject: [PATCH] Add privacy-safe telemetry lifecycle and outcome signals --- Dockerfile | 1 + README.md | 1 + docker-compose.yml | 1 + docs/CONFIGURATION.md | 2 + docs/DOCKER.md | 2 + docs/INSTALL.md | 2 + docs/PRIVACY.md | 27 +- .../v6/internal/subsystems/api-contracts.md | 18 +- .../subsystems/deployment-installability.md | 8 + .../subsystems/frontend-primitives.md | 22 +- .../v6/internal/subsystems/monitoring.md | 10 + .../v6/internal/subsystems/notifications.md | 12 + .../internal/subsystems/security-privacy.md | 30 ++ frontend-modern/public/docs/PRIVACY.md | 27 +- .../src/api/__tests__/settings.test.ts | 22 ++ frontend-modern/src/api/settings.ts | 22 ++ .../__tests__/settingsArchitecture.test.ts | 6 +- .../__tests__/useSystemSettingsState.test.ts | 22 ++ .../src/i18n/__tests__/i18n.test.ts | 8 + frontend-modern/src/i18n/messages.de.ts | 2 +- frontend-modern/src/i18n/messages.es.ts | 2 +- frontend-modern/src/i18n/messages.ts | 2 +- .../stores/__tests__/systemSettings.test.ts | 5 +- install.sh | 1 + .../monitoring/canonical_guardrails_test.go | 12 +- internal/monitoring/reload.go | 96 +++-- internal/monitoring/reload_test.go | 22 ++ internal/notifications/notifications.go | 13 + internal/notifications/queue.go | 37 ++ internal/notifications/queue_test.go | 55 +++ internal/telemetry/telemetry.go | 359 ++++++++++++++++-- internal/telemetry/telemetry_test.go | 156 +++++++- pkg/server/server.go | 22 +- scripts/check_telemetry_schema_parity.py | 167 ++++++++ .../installtests/build_release_assets_test.go | 11 + scripts/installtests/root_install_sh_test.go | 19 + scripts/telemetry_adoption_report.py | 190 +++++++-- .../tests/test_telemetry_adoption_report.py | 120 +++++- scripts/tests/test_telemetry_schema_parity.py | 69 ++++ 39 files changed, 1470 insertions(+), 133 deletions(-) create mode 100644 scripts/check_telemetry_schema_parity.py create mode 100644 scripts/tests/test_telemetry_schema_parity.py diff --git a/Dockerfile b/Dockerfile index e3af4d535..67570873e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -286,6 +286,7 @@ EXPOSE 7655 # Only PULSE_DATA_DIR is used - all node config is done via web UI ENV PULSE_DATA_DIR=/data ENV PULSE_DOCKER=true +ENV PULSE_DEPLOYMENT_METHOD=container_other # Create default user (will be adjusted by entrypoint if PUID/PGID are set) RUN adduser -D -u 1000 -g 1000 pulse && \ diff --git a/README.md b/README.md index dfaffbf61..576b21712 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ docker run -d \ --name pulse \ -p 7655:7655 \ -v pulse_data:/data \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ --restart unless-stopped \ rcourtman/pulse:vX.Y.Z ``` diff --git a/docker-compose.yml b/docker-compose.yml index a0f942a54..ebd4eb5cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: # Temperature monitoring: install pulse-agent on each Proxmox host with --enable-proxmox, or use SSH (see docs/TEMPERATURE_MONITORING.md). environment: - TZ=${TZ:-UTC} + - PULSE_DEPLOYMENT_METHOD=docker_compose healthcheck: test: ["CMD-SHELL", "[ -x /docker-healthcheck.sh ] && /docker-healthcheck.sh || wget --quiet --tries=1 --spider http://localhost:7655/api/health"] interval: 30s diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a7b4d822c..8306b895e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -80,6 +80,7 @@ You can pre-configure Pulse by setting environment variables. Plain text credent ```bash # Docker Example docker run -d \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ -e PULSE_AUTH_USER=admin \ -e PULSE_AUTH_PASS=secret123 \ rcourtman/pulse:latest @@ -308,6 +309,7 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame | `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` | Outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` | +| `PULSE_DEPLOYMENT_METHOD` | Optional closed telemetry label: `docker_compose`, `docker_run`, `container_other`, `systemd`, `binary_other`, or `other`; invalid values are reported only as the safe runtime fallback | Inferred as `container_other` or `binary_other` | ### Logging Overrides diff --git a/docs/DOCKER.md b/docs/DOCKER.md index e9a96277f..b8e075d10 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -19,6 +19,7 @@ docker run -d \ --name pulse \ -p 7655:7655 \ -v pulse_data:/data \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ --restart unless-stopped \ rcourtman/pulse:vX.Y.Z ``` @@ -43,6 +44,7 @@ services: - pulse_data:/data environment: - TZ=Europe/London + - PULSE_DEPLOYMENT_METHOD=docker_compose # Optional: Pre-configure auth (skips setup wizard) # - PULSE_AUTH_USER=admin # - PULSE_AUTH_PASS=secret123 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 30f796370..e23cfe2aa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -55,6 +55,7 @@ docker run -d \ --name pulse \ -p 7655:7655 \ -v pulse_data:/data \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ --restart unless-stopped \ rcourtman/pulse:vX.Y.Z ``` @@ -73,6 +74,7 @@ services: volumes: - pulse_data:/data environment: + - PULSE_DEPLOYMENT_METHOD=docker_compose - PULSE_AUTH_USER=admin - PULSE_AUTH_PASS=secret123 diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 3ce0b5044..f45d36992 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces. 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. -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. +The telemetry payload does not include hostnames, credentials, infrastructure identifiers, IP addresses, URLs, paths, locale, prompts, chat messages, command text, action output, token values, names, email addresses, or account identifiers. Lifecycle and outcome signals are deliberately limited to closed buckets, booleans, and aggregate counts. Pulse does not send browser events or an event-level clickstream. See the full field list below. While mock/demo fixture mode is enabled, Pulse suppresses outbound telemetry entirely: a mock-mode instance reports a synthetic fixture fleet rather than a real installation, so it never pings. @@ -37,6 +37,8 @@ Every field is listed below with the reason it exists. Nothing else is included | Field | Example | Purpose | |-------|---------|---------| +| Schema version | `2` | Identify the exact payload contract so old and new signals are not mixed silently | +| Sent at | `2026-07-23T08:30:00Z` | Date the individual heartbeat without sending a history of client activity | | Install ID | `a1b2c3d4-...` | Distinguish active installations within one rotation window without tying telemetry to an account or person | | Version | `6.0.0-rc.1` | Track the canonical release identity currently deployed | | Version raw | `v6.0.0-rc.1-45-gabcdef` | Preserve the original build string when it differs so manual/dev builds do not pollute release reporting | @@ -48,6 +50,15 @@ Every field is listed below with the reason it exists. Nothing else is included | OS | `linux` | See whether operating-system-specific issues exist | | Arch | `amd64` | See whether CPU-architecture-specific issues exist | | Event | `startup` or `heartbeat` | Distinguish first-run/session starts from daily active-install heartbeats | +| Deployment method | `docker_compose`, `docker_run`, `container_other`, `systemd`, `binary_other`, or `other` | Compare coarse installation paths without sending an image name, filesystem path, command, or URL | +| Known install age bucket | `under_1d`, `1_7d`, `8_30d`, `31_90d`, `91_365d`, or `over_365d` | Understand activation by coarse age; for upgraded installs this is a lower bound measured from the first v2 observation, not an original installation date | +| Activation stage | `started`, `secured`, `connected`, `monitoring`, or `outcome_observed` | Measure the highest coarse setup milestone reached without sending a user journey or event log | +| Time to first monitored resource bucket | `not_observed`, `present_at_first_observation`, `under_15m`, `15m_1h`, `1_6h`, `6_24h`, `1_3d`, `4_7d`, `8_30d`, or `over_30d` | Measure coarse time to initial monitoring value without sending exact timestamps or resource identity; `present_at_first_observation` keeps upgraded installs from being assigned an invented historical duration | +| Estate size bucket | `empty`, `1_10`, `11_50`, `51_200`, `201_1000`, or `over_1000` | Segment aggregate usage by approximate monitored-resource scale without adding a new identifier | +| Auth configured | `true`/`false` | See whether an installation has crossed the basic security setup milestone without sending auth type, usernames, or account data | +| Configured connections | `4` | Count configured monitoring connections in aggregate without sending connection names, addresses, credentials, or resource IDs | +| Monitoring active | `true`/`false` | Distinguish currently populated monitoring from historical activation without sending resource identity | +| Outcome observed 30d | `true`/`false` | See whether alert or notification outcome evidence exists in the aggregate windows without sending alert or notification content | | PVE nodes | `3` | Understand Proxmox VE deployment size in aggregate | | PBS instances | `1` | Understand Proxmox Backup Server adoption in aggregate | | PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate | @@ -77,6 +88,12 @@ Every field is listed below with the reason it exists. Nothing else is included | Notifications enabled | `true`/`false` | See whether alert notification delivery is configured | | AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content | | Active alerts | `4` | Understand how noisy or quiet installations are in aggregate | +| Alerts fired 30d | `18` | Count locally retained alert-history entries in the current 30-day window without sending alert text, resource IDs, or timestamps | +| Alerts acknowledged 30d | `7` | Count acknowledgements in the current 30-day window without sending actors, reasons, alert IDs, or timestamps | +| Alerts resolved 30d | `12` | Count resolved alert records in the current 30-day window without sending resolution details, alert IDs, or resource IDs | +| Notification attempts 7d | `14` | Count delivery attempts in the locally retained seven-day queue window without sending recipients, endpoints, titles, or message content | +| Notification deliveries 7d | `11` | Count successfully delivered queue records in the local seven-day window without sending channel, recipient, endpoint, or content | +| Notification failures 7d | `3` | Count failed delivery attempts in the local seven-day window without sending error text, endpoint, recipient, or message content | | Relay enabled | `true`/`false` | See whether remote-access features are being used | | SSO enabled | `true`/`false` | See whether single-sign-on support is being used | | Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used | @@ -154,6 +171,7 @@ Every field is listed below with the reason it exists. Nothing else is included - The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers, exact API-token counts, prompts, chat messages, command text, action output, token values, or resource identifiers. - 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. +- The receiver stores only fields in its versioned telemetry allowlist. A cross-repository parity check prevents client fields from being silently dropped and prevents the storage contract from growing beyond the disclosed payload. - Telemetry rows older than **90 days** are purged automatically. - The license server uses request IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. @@ -161,6 +179,7 @@ Every field is listed below with the reason it exists. Nothing else is included - 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 URLs, filesystem paths, locale, browser events, or event-level clickstream - 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 names, email addresses, account identifiers, or other intentionally identifying personal content @@ -172,6 +191,12 @@ 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**. +Pulse separately keeps three coarse lifecycle values on the local instance: the +first v2 observation time, the first monitored-resource milestone time, and the +highest activation stage reached. This local state contains no user, account, +resource, URL, or content identifiers. It exists so daily pings can report +buckets instead of exporting a sequence of setup events. + #### Source code The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 228cecd6b..8c0daf32f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4451,6 +4451,20 @@ 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. +Telemetry schema v2 also requires the preview type to expose +`schema_version`, `sent_at`, closed deployment/lifecycle/estate buckets, +authentication and current-monitoring posture, configured-connection count, +and aggregate alert and notification outcomes from the governed 30-day and +seven-day windows. These fields are pseudonymous aggregate install signals, +not a user or browser journey: the payload must not add user/account identity, +locale, URLs, paths, exact lifecycle event timestamps, recipients, endpoints, +resource identifiers, alert/notification content, or clickstream events. +An install that already has monitoring data when schema v2 is first observed +must use `present_at_first_observation` instead of an invented elapsed-time +bucket. +`scripts/check_telemetry_schema_parity.py` must prove that this TypeScript +preview interface, `internal/telemetry.Ping`, and the Pulse Pro receiver have +matching public fields and primitive types. 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 @@ -4475,7 +4489,7 @@ may display or copy the exact payload, but they must not derive hostnames, infrastructure identifiers, prompt/chat content, command text, action output, license tiers, token values, token counts, resource IDs, finding IDs, or approval identities from it. -Approved action decision telemetry is part of that same anonymous API +Approved action decision telemetry is part of that same pseudonymous API contract: `pulse_intelligence_approved_action_decisions_30d` must count distinct action IDs with approved-decision lifecycle evidence in the telemetry window, or approval records whose approved decision timestamp is inside the @@ -4484,7 +4498,7 @@ operations-loop proof, but it must not be conflated with approved execution attempts or approved action successes, and it must never expose action IDs, actors, reasons, resource IDs, command text, action output, or verification detail. -Approved execution attempt telemetry is part of that same anonymous API +Approved execution attempt telemetry is part of that same pseudonymous API contract: `pulse_intelligence_approved_action_attempts_30d` must be counted as distinct action IDs with execution-attempt lifecycle evidence (`executing`, `completed`, or `failed`) resolved back to an approved action audit, with final diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 45fb03798..05bf1ed95 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -426,6 +426,14 @@ TLS floor in the dynamic config. `scripts/validate-release.sh` at build time and re-verified by `install-sh-smoke.yml` against the served asset. 3. Add or change root server installer, shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `install.sh`, `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` + Canonical server deployment paths also stamp the privacy-bounded outbound + telemetry deployment label without changing runtime behavior: the image + defaults to `container_other`, repo-root Compose overrides it to + `docker_compose`, documented direct Docker commands set `docker_run`, and + the root server installer writes `systemd` into its generated unit. The + runtime accepts only the fixed labels documented in `docs/PRIVACY.md` and + must collapse an arbitrary operator value to its container/binary fallback + rather than exporting an image name, path, command, URL, or free-form text. The root `install.sh` server installer owns its fresh-host dependency bootstrap for supported Debian, Ubuntu, and Proxmox targets. It must install `curl`, `wget`, `ca-certificates`, and `openssh-client` before installing diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8824c095d..b67d31248 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -547,12 +547,13 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`, 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 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 - hostnames, credentials, infrastructure identifiers, prompts, chat messages, - command text, action output, token values, and personal information must - stay explicitly excluded. + its vocabulary must stay aligned with `security-privacy`: coarse deployment + and lifecycle buckets, aggregate resource and outcome counts, coarse feature + flags, and content-free Patrol, Assistant, and capability-API usage counters + may be named, while hostnames, credentials, infrastructure identifiers, + URLs, paths, locale, browser events, prompts, chat messages, command text, + action output, token values, and personal information must stay explicitly + excluded. 7. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. 8. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. These settings panels consume the privileged security-status projection, @@ -4846,10 +4847,11 @@ 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 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, -chat messages, command text, action output, token values, and personal +with `security-privacy`: coarse deployment and lifecycle buckets, aggregate +resource and outcome counts, coarse feature flags, and content-free Patrol, +Assistant, and capability-API usage counters are allowed, while hostnames, +credentials, infrastructure identifiers, URLs, paths, locale, browser events, +prompts, chat messages, command text, action output, token values, and personal information are not. That same docs-link boundary also governs local legal docs surfaced from the settings shell: shared settings surfaces such as diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index c9cad4963..427f872ea 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1209,6 +1209,16 @@ shares, TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, availability targets, and active alerts. Telemetry callers may consume those coarse totals, but they must not bypass monitoring to read provider-local identifiers or tenant-local resource names. +That install-wide boundary also owns privacy-bounded outcome aggregation for +telemetry schema v2. It may count alert history entries fired, acknowledged, +or resolved within the existing 30-day local history window and notification +attempt/delivery/failure totals within the notification queue's existing +seven-day retention window, across the provisioned tenant set. It must consume +only content-free totals from tenant-owned managers and must not export alert +IDs, resource IDs, actors, reasons, destinations, recipients, endpoints, +timestamps, error text, or message content. Notification queue state remains +delivery evidence rather than alert-lifecycle truth, and monitoring must not +infer alert resolution from delivery success or failure. That same reloadable multi-tenant monitor boundary also owns wiring tenant identity into per-org notification delivery. When a tenant monitor is initialized for a non-default org, monitoring installs an org-backed tenant diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index b33ab62ac..37a3f00e5 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -257,6 +257,18 @@ without destination, record, resource, or evidence labels. Delivery state remains notification truth only and cannot resolve or reopen the alert lifecycle. +The queue owner may expose a read-only, content-free telemetry aggregate over +its retained per-attempt audit rows: total delivery attempts plus successful +and failed attempt counts since a caller-supplied cutoff. The aggregate must +be computed in storage, +must not return or copy notification IDs, alert links, destinations, +recipients, endpoint URLs, titles, bodies, error text, or timestamps, and must +not mutate queue or alert state. The install-wide monitoring owner may sum that +aggregate across provisioned tenants for the outbound seven-day notification +outcome counters. Because completed queue rows are retention-bounded, this is a +seven-day delivery signal only and must not be presented as lifetime delivery +history or as proof that an alert was resolved. + ### Occurrence-bound delivery receipts The notification owner records successful firing delivery by exact alert ID, diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index f5bb20af8..a98f5e3b5 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -943,6 +943,36 @@ 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 identifier indefinitely or echo that identifier back into routine logs. +The identifier must be resolved at each outbound event, not frozen when the +process starts, so a continuously running installation still rotates after the +30-day window. Each payload also carries a schema version and one UTC build +time so receiver and reporting semantics can be selected explicitly. +Schema v2 may add only privacy-bounded user-base signals: a closed deployment +method, known-age/activation/time-to-first-resource/estate-size buckets, +authentication and current-monitoring booleans, configured-connection count, +and aggregate alert and notification outcome counts from the runtime's existing +30-day and seven-day local retention windows. The lifecycle record kept on the +instance may contain only first observation time, first monitored-resource +milestone time, and highest coarse activation stage. It must not retain or send +names, email addresses, account IDs, locale, URLs, paths, host/resource IDs, +recipients, endpoints, alert content, notification content, prompts, commands, +or an event-level journey/clickstream. Known install age for an upgraded +installation is therefore a lower bound beginning with its first schema-v2 +observation, not a reconstructed installation date. +If monitoring is already populated at that first observation, time-to-first +resource must report a dedicated present-at-first-observation bucket rather +than inventing an under-15-minute activation duration. +The public Go payload, Settings preview TypeScript interface, and Pulse Pro +receiver must remain field/type-equivalent under +`scripts/check_telemetry_schema_parity.py`, except for explicitly named legacy +receiver-only compatibility fields. The receiver JSON struct is the storage +allowlist: unknown input fields are discarded, and inserts are generated from +that allowlist so a declared client field cannot be silently dropped by a +hand-maintained SQL list. Receiver values must clamp counts and validate every +new categorical value against its fixed set before storage. The adoption +report may aggregate those latest-per-install signals and use indexed time +filters plus compressed remote transport, but it must not enrich them with +accounts, request IPs, customer records, or event-level browser data. That same telemetry trust boundary must remain operator-inspectable in-product: the shared system settings surface may preview only the exact runtime payload Pulse would send, and it must allow an operator to rotate the local telemetry diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 3ce0b5044..f45d36992 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces. 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. -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. +The telemetry payload does not include hostnames, credentials, infrastructure identifiers, IP addresses, URLs, paths, locale, prompts, chat messages, command text, action output, token values, names, email addresses, or account identifiers. Lifecycle and outcome signals are deliberately limited to closed buckets, booleans, and aggregate counts. Pulse does not send browser events or an event-level clickstream. See the full field list below. While mock/demo fixture mode is enabled, Pulse suppresses outbound telemetry entirely: a mock-mode instance reports a synthetic fixture fleet rather than a real installation, so it never pings. @@ -37,6 +37,8 @@ Every field is listed below with the reason it exists. Nothing else is included | Field | Example | Purpose | |-------|---------|---------| +| Schema version | `2` | Identify the exact payload contract so old and new signals are not mixed silently | +| Sent at | `2026-07-23T08:30:00Z` | Date the individual heartbeat without sending a history of client activity | | Install ID | `a1b2c3d4-...` | Distinguish active installations within one rotation window without tying telemetry to an account or person | | Version | `6.0.0-rc.1` | Track the canonical release identity currently deployed | | Version raw | `v6.0.0-rc.1-45-gabcdef` | Preserve the original build string when it differs so manual/dev builds do not pollute release reporting | @@ -48,6 +50,15 @@ Every field is listed below with the reason it exists. Nothing else is included | OS | `linux` | See whether operating-system-specific issues exist | | Arch | `amd64` | See whether CPU-architecture-specific issues exist | | Event | `startup` or `heartbeat` | Distinguish first-run/session starts from daily active-install heartbeats | +| Deployment method | `docker_compose`, `docker_run`, `container_other`, `systemd`, `binary_other`, or `other` | Compare coarse installation paths without sending an image name, filesystem path, command, or URL | +| Known install age bucket | `under_1d`, `1_7d`, `8_30d`, `31_90d`, `91_365d`, or `over_365d` | Understand activation by coarse age; for upgraded installs this is a lower bound measured from the first v2 observation, not an original installation date | +| Activation stage | `started`, `secured`, `connected`, `monitoring`, or `outcome_observed` | Measure the highest coarse setup milestone reached without sending a user journey or event log | +| Time to first monitored resource bucket | `not_observed`, `present_at_first_observation`, `under_15m`, `15m_1h`, `1_6h`, `6_24h`, `1_3d`, `4_7d`, `8_30d`, or `over_30d` | Measure coarse time to initial monitoring value without sending exact timestamps or resource identity; `present_at_first_observation` keeps upgraded installs from being assigned an invented historical duration | +| Estate size bucket | `empty`, `1_10`, `11_50`, `51_200`, `201_1000`, or `over_1000` | Segment aggregate usage by approximate monitored-resource scale without adding a new identifier | +| Auth configured | `true`/`false` | See whether an installation has crossed the basic security setup milestone without sending auth type, usernames, or account data | +| Configured connections | `4` | Count configured monitoring connections in aggregate without sending connection names, addresses, credentials, or resource IDs | +| Monitoring active | `true`/`false` | Distinguish currently populated monitoring from historical activation without sending resource identity | +| Outcome observed 30d | `true`/`false` | See whether alert or notification outcome evidence exists in the aggregate windows without sending alert or notification content | | PVE nodes | `3` | Understand Proxmox VE deployment size in aggregate | | PBS instances | `1` | Understand Proxmox Backup Server adoption in aggregate | | PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate | @@ -77,6 +88,12 @@ Every field is listed below with the reason it exists. Nothing else is included | Notifications enabled | `true`/`false` | See whether alert notification delivery is configured | | AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content | | Active alerts | `4` | Understand how noisy or quiet installations are in aggregate | +| Alerts fired 30d | `18` | Count locally retained alert-history entries in the current 30-day window without sending alert text, resource IDs, or timestamps | +| Alerts acknowledged 30d | `7` | Count acknowledgements in the current 30-day window without sending actors, reasons, alert IDs, or timestamps | +| Alerts resolved 30d | `12` | Count resolved alert records in the current 30-day window without sending resolution details, alert IDs, or resource IDs | +| Notification attempts 7d | `14` | Count delivery attempts in the locally retained seven-day queue window without sending recipients, endpoints, titles, or message content | +| Notification deliveries 7d | `11` | Count successfully delivered queue records in the local seven-day window without sending channel, recipient, endpoint, or content | +| Notification failures 7d | `3` | Count failed delivery attempts in the local seven-day window without sending error text, endpoint, recipient, or message content | | Relay enabled | `true`/`false` | See whether remote-access features are being used | | SSO enabled | `true`/`false` | See whether single-sign-on support is being used | | Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used | @@ -154,6 +171,7 @@ Every field is listed below with the reason it exists. Nothing else is included - The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers, exact API-token counts, prompts, chat messages, command text, action output, token values, or resource identifiers. - 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. +- The receiver stores only fields in its versioned telemetry allowlist. A cross-repository parity check prevents client fields from being silently dropped and prevents the storage contract from growing beyond the disclosed payload. - Telemetry rows older than **90 days** are purged automatically. - The license server uses request IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. @@ -161,6 +179,7 @@ Every field is listed below with the reason it exists. Nothing else is included - 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 URLs, filesystem paths, locale, browser events, or event-level clickstream - 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 names, email addresses, account identifiers, or other intentionally identifying personal content @@ -172,6 +191,12 @@ 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**. +Pulse separately keeps three coarse lifecycle values on the local instance: the +first v2 observation time, the first monitored-resource milestone time, and the +highest activation stage reached. This local state contains no user, account, +resource, URL, or content identifiers. It exists so daily pings can report +buckets instead of exporting a sequence of setup events. + #### Source code The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted. diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index 86560b15c..d42ce63de 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -7,6 +7,8 @@ vi.mock('@/utils/apiClient', () => ({ })); const mockTelemetryPreviewPayload = { + schema_version: 2, + sent_at: '2026-07-23T08:30:00Z', install_id: 'preview-install-id', version: '6.0.0', version_channel: 'stable', @@ -16,6 +18,15 @@ const mockTelemetryPreviewPayload = { os: 'linux', arch: 'amd64', event: 'heartbeat', + deployment_method: 'docker_compose', + known_install_age_bucket: '1_7d', + activation_stage: 'monitoring', + time_to_first_monitored_resource_bucket: 'under_15m', + estate_size_bucket: '1_10', + auth_configured: true, + configured_connections: 1, + monitoring_active: true, + outcome_observed_30d: false, pve_nodes: 1, pbs_instances: 0, pmg_instances: 0, @@ -54,6 +65,12 @@ const mockTelemetryPreviewPayload = { update_successes_30d: 0, update_failures_30d: 0, update_last_failure_category: undefined, + alerts_fired_30d: 0, + alerts_acknowledged_30d: 0, + alerts_resolved_30d: 0, + notification_attempts_7d: 0, + notification_deliveries_7d: 0, + notification_failures_7d: 0, pulse_intelligence_loop_configured: false, pulse_intelligence_loop_active_30d: false, pulse_intelligence_complete_operations_loop_30d: false, @@ -110,6 +127,11 @@ const mockTelemetryPreviewPayload = { pulse_intelligence_approved_action_decisions_30d: 0, pulse_intelligence_approved_action_attempts_30d: 0, pulse_intelligence_approved_action_successes_30d: 0, + pulse_intelligence_approved_action_failures_pre_dispatch_30d: 0, + pulse_intelligence_approved_action_failures_execution_30d: 0, + pulse_intelligence_approved_action_failures_unverified_30d: 0, + pulse_intelligence_approved_action_stuck_executing_30d: 0, + pulse_intelligence_approved_action_last_failure_reason_30d: undefined, } satisfies TelemetryPingPreview; describe('SettingsAPI', () => { diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index d3920466a..4cafd8f71 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -6,6 +6,8 @@ export interface SystemSettingsResponse extends SystemConfig { } export interface TelemetryPingPreview { + schema_version: number; + sent_at: string; install_id: string; version: string; version_raw?: string; @@ -17,6 +19,15 @@ export interface TelemetryPingPreview { os: string; arch: string; event: string; + deployment_method: string; + known_install_age_bucket: string; + activation_stage: string; + time_to_first_monitored_resource_bucket: string; + estate_size_bucket: string; + auth_configured: boolean; + configured_connections: number; + monitoring_active: boolean; + outcome_observed_30d: boolean; pve_nodes: number; pbs_instances: number; pmg_instances: number; @@ -55,6 +66,12 @@ export interface TelemetryPingPreview { update_successes_30d: number; update_failures_30d: number; update_last_failure_category?: string; + alerts_fired_30d: number; + alerts_acknowledged_30d: number; + alerts_resolved_30d: number; + notification_attempts_7d: number; + notification_deliveries_7d: number; + notification_failures_7d: number; pulse_intelligence_loop_configured: boolean; pulse_intelligence_loop_active_30d: boolean; pulse_intelligence_complete_operations_loop_30d: boolean; @@ -111,6 +128,11 @@ export interface TelemetryPingPreview { pulse_intelligence_approved_action_decisions_30d: number; pulse_intelligence_approved_action_attempts_30d: number; pulse_intelligence_approved_action_successes_30d: number; + pulse_intelligence_approved_action_failures_pre_dispatch_30d: number; + pulse_intelligence_approved_action_failures_execution_30d: number; + pulse_intelligence_approved_action_failures_unverified_30d: number; + pulse_intelligence_approved_action_stuck_executing_30d: number; + pulse_intelligence_approved_action_last_failure_reason_30d?: string; } export interface TelemetryPreviewResponse { diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index caf065953..1d1269400 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -706,16 +706,16 @@ describe('settings architecture guardrails', () => { it('keeps telemetry disclosure aligned with the security privacy contract', () => { expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( - 'aggregate self-hosted adoption', + 'coarse deployment and lifecycle buckets', ); expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( - 'aggregate self-hosted adoption counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters', + 'aggregate resource and outcome counts, coarse feature flags, and content-free Patrol, Assistant, and capability-API 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, names, email addresses, or IP addresses', + 'identifiers, URLs, paths, locale, browser events, 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'); diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts index 9f774f9a1..9b960f404 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts @@ -13,6 +13,8 @@ const flushAsync = async () => { const buildTelemetryPreviewPayload = ( overrides: Partial = {}, ): TelemetryPingPreview => ({ + schema_version: 2, + sent_at: '2026-07-23T08:30:00Z', install_id: 'preview-install-id', version: '6.0.0', version_channel: 'stable', @@ -22,6 +24,15 @@ const buildTelemetryPreviewPayload = ( os: 'linux', arch: 'amd64', event: 'heartbeat', + deployment_method: 'docker_compose', + known_install_age_bucket: '1_7d', + activation_stage: 'monitoring', + time_to_first_monitored_resource_bucket: 'under_15m', + estate_size_bucket: '1_10', + auth_configured: true, + configured_connections: 1, + monitoring_active: true, + outcome_observed_30d: false, pve_nodes: 1, pbs_instances: 0, pmg_instances: 0, @@ -60,6 +71,12 @@ const buildTelemetryPreviewPayload = ( update_successes_30d: 0, update_failures_30d: 0, update_last_failure_category: undefined, + alerts_fired_30d: 0, + alerts_acknowledged_30d: 0, + alerts_resolved_30d: 0, + notification_attempts_7d: 0, + notification_deliveries_7d: 0, + notification_failures_7d: 0, pulse_intelligence_loop_configured: false, pulse_intelligence_loop_active_30d: false, pulse_intelligence_complete_operations_loop_30d: false, @@ -116,6 +133,11 @@ const buildTelemetryPreviewPayload = ( pulse_intelligence_approved_action_decisions_30d: 0, pulse_intelligence_approved_action_attempts_30d: 0, pulse_intelligence_approved_action_successes_30d: 0, + pulse_intelligence_approved_action_failures_pre_dispatch_30d: 0, + pulse_intelligence_approved_action_failures_execution_30d: 0, + pulse_intelligence_approved_action_failures_unverified_30d: 0, + pulse_intelligence_approved_action_stuck_executing_30d: 0, + pulse_intelligence_approved_action_last_failure_reason_30d: undefined, ...overrides, }); diff --git a/frontend-modern/src/i18n/__tests__/i18n.test.ts b/frontend-modern/src/i18n/__tests__/i18n.test.ts index 18e51e45d..205e45fcb 100644 --- a/frontend-modern/src/i18n/__tests__/i18n.test.ts +++ b/frontend-modern/src/i18n/__tests__/i18n.test.ts @@ -192,6 +192,11 @@ describe('i18n foundation', () => { }); it('keeps machine-facing identifiers unchanged in first-wave settings general catalog copy', () => { + const telemetryPrivacyTerms = { + de: ['Lebenszyklus', 'Ergebniszahlen', 'URLs', 'Pfade', 'Browser-Ereignisse'], + es: ['ciclo de vida', 'resultados', 'URLs', 'rutas', 'eventos del navegador'], + } as const; + for (const locale of FIRST_LOCALIZATION_LOCALES) { const telemetryDescription = I18N_MESSAGES[locale]['settings.general.telemetry.description']; @@ -200,6 +205,9 @@ describe('i18n foundation', () => { expect(telemetryDescription).toContain('IP'); expect(telemetryDescription).toContain('90'); expect(telemetryDescription).not.toMatch(/anonymous/i); + for (const term of telemetryPrivacyTerms[locale]) { + expect(telemetryDescription, `${locale}:${term}`).toContain(term); + } expect(I18N_MESSAGES[locale]['settings.general.telemetry.copyJson']).toContain('JSON'); expect( t( diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index a56fadbdd..22e7e5818 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -332,7 +332,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 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.', + 'Helfen Sie, Pulse zu verbessern, indem Sie ausgehende Nutzungsdaten teilen: eine rotierende pseudonyme Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, grobe Kategorien fuer Bereitstellungsart und Lebenszyklus, aggregierte Ressourcen- und Ergebniszahlen, grobe Funktionsflags sowie inhaltsfreie Nutzungszaehler fuer Patrol, Assistant und Capability-APIs. Der Payload enthaelt keine Hostnamen, Zugangsdaten, Infrastrukturkennungen, URLs, Pfade, Gebietsschema, Browser-Ereignisse, 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', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index 85fa8440e..9acf24d9d 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -324,7 +324,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: 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.', + '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, categorías generales de método de despliegue y ciclo de vida, conteos agregados de recursos y resultados, flags generales de funciones y contadores sin contenido de uso de Patrol, Assistant y API de capacidades. El payload no incluye hostnames, credenciales, identificadores de infraestructura, URLs, rutas, configuración regional, eventos del navegador, 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', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 7294d3bc1..e6ccf5969 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -317,7 +317,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 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.', + 'Help improve Pulse by sharing outbound usage data: a rotating pseudonymous install ID, normalized release identity, runtime platform, coarse deployment and lifecycle buckets, aggregate resource and outcome counts, coarse feature flags, and content-free Patrol, Assistant, and capability-API usage counters. The payload does not include hostnames, credentials, infrastructure identifiers, URLs, paths, locale, browser events, 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', diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 0cf13b382..e16a8c793 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -62,9 +62,12 @@ describe('systemSettings store', () => { it('keeps the telemetry disclosure in user-facing product language', () => { const telemetryDescription = EN_MESSAGES['settings.general.telemetry.description']; + expect(telemetryDescription).toContain('coarse deployment and lifecycle buckets'); + expect(telemetryDescription).toContain('aggregate resource and outcome counts'); expect(telemetryDescription).toContain( - 'coarse Patrol, Assistant, and external-agent usage counters', + 'content-free Patrol, Assistant, and capability-API usage counters', ); + expect(telemetryDescription).toContain('URLs, paths, locale, browser events'); expect(telemetryDescription).not.toContain('Pulse Intelligence loop adoption'); expect(telemetryDescription).not.toContain('activation loop'); expect(telemetryDescription).not.toContain('operations loop'); diff --git a/install.sh b/install.sh index f31df4cfc..7c24d8c3c 100755 --- a/install.sh +++ b/install.sh @@ -4224,6 +4224,7 @@ StandardOutput=journal StandardError=journal Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment="PULSE_DATA_DIR=$CONFIG_DIR" +Environment="PULSE_DEPLOYMENT_METHOD=systemd" EnvironmentFile=-$CONFIG_DIR/.env EOF diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 94ebd0373..24bf6640b 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -701,12 +701,12 @@ func TestInstallTelemetrySnapshotCountsStayOnMonitoringBoundary(t *testing.T) { source := string(data) for _, snippet := range []string{ - "AgentHosts int", - "DockerContainers int", - "KubernetesPods int", - "TrueNASSystems int", - "VMwareDatastores int", - "AvailabilityTargets int", + "AgentHosts", + "DockerContainers", + "KubernetesPods", + "TrueNASSystems", + "VMwareDatastores", + "AvailabilityTargets", "resources, _ := monitor.UnifiedResourceSnapshot()", "accumulateInstallSnapshotUnifiedResourceCounts(counts, resources)", "case unifiedresources.ResourceTypeNetworkShare:", diff --git a/internal/monitoring/reload.go b/internal/monitoring/reload.go index 1cef457ef..b26527792 100644 --- a/internal/monitoring/reload.go +++ b/internal/monitoring/reload.go @@ -7,7 +7,9 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rs/zerolog/log" @@ -16,30 +18,36 @@ import ( // InstallSnapshotCounts holds install-wide resource and alert counts aggregated // across tenant monitors. type InstallSnapshotCounts struct { - PVENodes int - PBSInstances int - PMGInstances int - VMs int - Containers int - AgentHosts int - DockerHosts int - DockerContainers int - KubernetesClusters int - KubernetesNodes int - KubernetesPods int - KubernetesDeployments int - StoragePools int - PhysicalDisks int - CephClusters int - NetworkShares int - TrueNASSystems int - TrueNASVMs int - TrueNASApps int - VMwareHosts int - VMwareVMs int - VMwareDatastores int - AvailabilityTargets int - ActiveAlerts int + PVENodes int + PBSInstances int + PMGInstances int + VMs int + Containers int + AgentHosts int + DockerHosts int + DockerContainers int + KubernetesClusters int + KubernetesNodes int + KubernetesPods int + KubernetesDeployments int + StoragePools int + PhysicalDisks int + CephClusters int + NetworkShares int + TrueNASSystems int + TrueNASVMs int + TrueNASApps int + VMwareHosts int + VMwareVMs int + VMwareDatastores int + AvailabilityTargets int + ActiveAlerts int + AlertsFired30d int + AlertsAcknowledged30d int + AlertsResolved30d int + NotificationAttempts7d int + NotificationDeliveries7d int + NotificationFailures7d int } // ReloadableMonitor wraps a Monitor with reload capability @@ -248,6 +256,7 @@ func (rm *ReloadableMonitor) AggregateInstallSnapshotCounts() InstallSnapshotCou } var counts InstallSnapshotCounts + now := time.Now().UTC() for _, orgID := range orgIDs { monitor, err := mtMonitor.GetMonitor(orgID) if err != nil || monitor == nil { @@ -255,10 +264,49 @@ func (rm *ReloadableMonitor) AggregateInstallSnapshotCounts() InstallSnapshotCou continue } accumulateInstallSnapshotCounts(&counts, monitor) + accumulateInstallOutcomeCounts(&counts, monitor, now) } return counts } +func accumulateInstallOutcomeCounts(counts *InstallSnapshotCounts, monitor *Monitor, now time.Time) { + if counts == nil || monitor == nil { + return + } + if now.IsZero() { + now = time.Now().UTC() + } + alertCutoff := now.Add(-30 * 24 * time.Hour) + if alertManager := monitor.GetAlertManager(); alertManager != nil { + accumulateAlertOutcomeCounts(counts, alertManager.GetAlertHistorySince(alertCutoff, 0), alertCutoff) + } + if notificationManager := monitor.GetNotificationManager(); notificationManager != nil { + stats, err := notificationManager.GetTelemetryStats(now.Add(-7 * 24 * time.Hour)) + if err != nil { + log.Debug().Err(err).Msg("Telemetry snapshot could not read notification delivery aggregates") + return + } + counts.NotificationAttempts7d += stats.Attempts + counts.NotificationDeliveries7d += stats.Deliveries + counts.NotificationFailures7d += stats.Failures + } +} + +func accumulateAlertOutcomeCounts(counts *InstallSnapshotCounts, history []alerts.Alert, cutoff time.Time) { + if counts == nil { + return + } + for _, alert := range history { + counts.AlertsFired30d++ + if alert.AckTime != nil && !alert.AckTime.Before(cutoff) { + counts.AlertsAcknowledged30d++ + } + if alert.OperationalRecord != nil && alert.OperationalRecord.State == operationaltrust.OperationalResolved { + counts.AlertsResolved30d++ + } + } +} + func accumulateInstallSnapshotCounts(counts *InstallSnapshotCounts, monitor *Monitor) { if counts == nil || monitor == nil { return diff --git a/internal/monitoring/reload_test.go b/internal/monitoring/reload_test.go index 2dd04b222..c2572550d 100644 --- a/internal/monitoring/reload_test.go +++ b/internal/monitoring/reload_test.go @@ -5,8 +5,10 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -166,6 +168,26 @@ func TestAccumulateInstallSnapshotUnifiedResourceCountsUsesCoarseV6AdoptionSigna assert.Equal(t, 1, counts.AvailabilityTargets) } +func TestAccumulateAlertOutcomeCountsUsesOnlyContentFreeLifecycleTotals(t *testing.T) { + now := time.Now().UTC() + recentAck := now.Add(-time.Hour) + oldAck := now.Add(-40 * 24 * time.Hour) + counts := InstallSnapshotCounts{} + accumulateAlertOutcomeCounts(&counts, []alerts.Alert{ + { + AckTime: &recentAck, + OperationalRecord: &operationaltrust.OperationalRecord{ + State: operationaltrust.OperationalResolved, + }, + }, + {AckTime: &oldAck}, + }, now.Add(-30*24*time.Hour)) + + assert.Equal(t, 2, counts.AlertsFired30d) + assert.Equal(t, 1, counts.AlertsAcknowledged30d) + assert.Equal(t, 1, counts.AlertsResolved30d) +} + func testTelemetryMonitor( nodes []models.Node, vms []models.VM, diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 4f0ff9748..d27095e87 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -3426,6 +3426,19 @@ func (n *NotificationManager) GetQueueStats() (map[string]int, error) { return queue.GetQueueStats() } +// GetTelemetryStats returns only aggregate delivery outcomes from the +// persistent queue. It never exposes destinations, alert content, or IDs. +func (n *NotificationManager) GetTelemetryStats(since time.Time) (TelemetryStats, error) { + n.mu.RLock() + queue := n.queue + n.mu.RUnlock() + + if queue == nil { + return TelemetryStats{}, nil + } + return queue.GetTelemetryStats(since) +} + // SendTestNotification sends a test notification func (n *NotificationManager) SendTestNotification(method string) error { testAlert := buildNotificationTestAlert() diff --git a/internal/notifications/queue.go b/internal/notifications/queue.go index e299daff7..8034eaa21 100644 --- a/internal/notifications/queue.go +++ b/internal/notifications/queue.go @@ -1238,6 +1238,43 @@ func (nq *NotificationQueue) GetQueueStats() (map[string]int, error) { return stats, nil } +// TelemetryStats is a content-free delivery outcome aggregate. It deliberately +// excludes notification, destination, tenant, alert, and resource identities. +type TelemetryStats struct { + Attempts int + Deliveries int + Failures int +} + +// GetTelemetryStats returns aggregate delivery outcomes from locally retained +// per-attempt audit rows recorded on or after since. Callers must not interpret +// a window longer than the queue's completed-row retention as complete. +func (nq *NotificationQueue) GetTelemetryStats(since time.Time) (TelemetryStats, error) { + if nq == nil { + return TelemetryStats{}, nil + } + if since.IsZero() { + since = time.Now().Add(-7 * 24 * time.Hour) + } + + nq.mu.RLock() + defer nq.mu.RUnlock() + + var stats TelemetryStats + err := nq.db.QueryRow(` + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) + FROM notification_audit + WHERE timestamp >= ? + `, since.UTC().Unix()).Scan(&stats.Attempts, &stats.Deliveries, &stats.Failures) + if err != nil { + return TelemetryStats{}, fmt.Errorf("read notification telemetry aggregates: %w", err) + } + return stats, nil +} + // processQueue runs in background to process pending notifications func (nq *NotificationQueue) processQueue() { defer nq.wg.Done() diff --git a/internal/notifications/queue_test.go b/internal/notifications/queue_test.go index 89e6ab335..309aa9408 100644 --- a/internal/notifications/queue_test.go +++ b/internal/notifications/queue_test.go @@ -1354,6 +1354,61 @@ func TestGetQueueStats(t *testing.T) { }) } +func TestGetTelemetryStatsReturnsOnlyWindowedOutcomeCounts(t *testing.T) { + nq, err := NewNotificationQueue(t.TempDir()) + if err != nil { + t.Fatalf("NewNotificationQueue: %v", err) + } + defer func() { _ = nq.Stop() }() + + now := time.Now().UTC() + entries := []*QueuedNotification{ + {ID: "sent", Type: "email", Status: QueueStatusPending, MaxAttempts: 3, Config: []byte(`{}`), CreatedAt: now.Add(-time.Hour)}, + {ID: "failed", Type: "webhook", Status: QueueStatusPending, MaxAttempts: 3, Config: []byte(`{}`), CreatedAt: now.Add(-2 * time.Hour)}, + {ID: "old", Type: "email", Status: QueueStatusPending, MaxAttempts: 3, Config: []byte(`{}`), CreatedAt: now.Add(-8 * 24 * time.Hour)}, + } + for _, entry := range entries { + if err := nq.Enqueue(entry); err != nil { + t.Fatalf("enqueue %s: %v", entry.ID, err) + } + } + entries[0].Attempts = 1 + entries[0].Status = QueueStatusPending + if err := nq.RecordAudit(entries[0], false, "first attempt failed"); err != nil { + t.Fatalf("record sent retry: %v", err) + } + entries[0].Attempts = 2 + entries[0].Status = QueueStatusSent + if err := nq.RecordAudit(entries[0], true, ""); err != nil { + t.Fatalf("record sent delivery: %v", err) + } + entries[1].Attempts = 1 + entries[1].Status = QueueStatusFailed + if err := nq.RecordAudit(entries[1], false, "final attempt failed"); err != nil { + t.Fatalf("record failed delivery: %v", err) + } + entries[2].Attempts = 1 + entries[2].Status = QueueStatusSent + if err := nq.RecordAudit(entries[2], true, ""); err != nil { + t.Fatalf("record old delivery: %v", err) + } + if _, err := nq.db.Exec( + `UPDATE notification_audit SET timestamp = ? WHERE notification_id = ?`, + now.Add(-8*24*time.Hour).Unix(), + "old", + ); err != nil { + t.Fatalf("age old audit row: %v", err) + } + + stats, err := nq.GetTelemetryStats(now.Add(-7 * 24 * time.Hour)) + if err != nil { + t.Fatalf("GetTelemetryStats: %v", err) + } + if stats != (TelemetryStats{Attempts: 3, Deliveries: 1, Failures: 2}) { + t.Fatalf("telemetry stats = %#v", stats) + } +} + func TestPerformCleanup(t *testing.T) { t.Run("cleanup removes old completed entries", func(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 7ab2541f2..58cacd132 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -6,12 +6,19 @@ // // # What is sent (the full list — nothing else) // -// Identity: +// Contract and identity: +// - Payload schema version and the UTC time this payload was built // - A rotating install ID (UUID, generated locally and rotated periodically, not tied to any account) // - Pulse version identity (normalized version plus raw build string when it differs) // - Platform: "docker" or "binary" +// - Coarse deployment method from a fixed list, never an image name or path // - OS and architecture (e.g. "linux/amd64") // +// Lifecycle and audience posture (closed buckets, booleans, and counts only): +// - Known install age, highest activation stage, time to first monitored resource, and estate-size buckets +// - Whether authentication is configured, number of configured connections, and whether monitoring is active +// - Whether a core outcome was observed in the current aggregate windows +// // Scale (counts only, no names): // - Number of PVE nodes, PBS instances, PMG instances // - Number of VMs, LXC containers @@ -28,6 +35,8 @@ // - Whether multi-tenant mode is enabled // - Whether a paid license is active // - Whether any API tokens are configured +// - Aggregate alert fired/acknowledged/resolved counts over 30 days +// - Aggregate notification attempt/delivery/failure counts over seven days // - Coarse update funnel counters and last failure category over the current install-ID rotation window // - Patrol, Assistant, and external-agent usage counters over the current install-ID rotation window: // configured/active/governed-action/approved-execution/resolved-loop state, @@ -65,6 +74,8 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" "math/rand" "net/http" "os" @@ -105,6 +116,11 @@ const ( // installIDFile is the filename persisted in the data directory. installIDFile = ".install_id" + // lifecycleStateFile stores only local milestone timestamps and the highest + // coarse activation stage observed. It contains no user or infrastructure + // identifiers and is intentionally independent from the rotating install ID. + lifecycleStateFile = ".telemetry_lifecycle" + // installIDRotationWindow limits how long the same pseudonymous identifier // can be reused before it is rotated locally. installIDRotationWindow = 30 * 24 * time.Hour @@ -114,6 +130,10 @@ const ( // intentionally matches the install-ID rotation window so counters cannot // be linked to one stable pseudonymous identifier indefinitely. PulseIntelligenceTelemetryWindow = installIDRotationWindow + + // TelemetrySchemaVersion identifies the exact outbound payload contract. + // Increment this when fields or their semantics change. + TelemetrySchemaVersion = 2 ) type installIDRecord struct { @@ -121,10 +141,18 @@ type installIDRecord struct { IssuedAt time.Time `json:"issued_at"` } +type lifecycleRecord struct { + FirstObservedAt time.Time `json:"first_observed_at"` + FirstMonitoredResourceAt *time.Time `json:"first_monitored_resource_at,omitempty"` + HighestObservedActivation string `json:"highest_observed_activation"` +} + // Ping is the payload sent to the telemetry endpoint. // Every field is documented here so users can audit exactly what leaves their server. type Ping struct { // Identity + SchemaVersion int `json:"schema_version"` // Versioned payload contract + SentAt string `json:"sent_at"` // UTC send/preview time; no client clock history InstallID string `json:"install_id"` // Rotating UUID, not tied to any account Version string `json:"version"` // Normalized Pulse version (e.g. "6.0.0-rc.1") VersionRaw string `json:"version_raw,omitempty"` // Original version/build string when it differs @@ -136,6 +164,19 @@ type Ping struct { OS string `json:"os"` // runtime.GOOS (e.g. "linux") Arch string `json:"arch"` // runtime.GOARCH (e.g. "amd64") Event string `json:"event"` // "startup" or "heartbeat" + DeploymentMethod string `json:"deployment_method"` // Closed coarse install method, never a path or image name + + // Coarse lifecycle and audience posture. These are closed buckets and + // aggregate states only; no user, account, locale, or resource identity is + // included. + KnownInstallAgeBucket string `json:"known_install_age_bucket"` + ActivationStage string `json:"activation_stage"` + TimeToFirstMonitoredResourceBucket string `json:"time_to_first_monitored_resource_bucket"` + EstateSizeBucket string `json:"estate_size_bucket"` + AuthConfigured bool `json:"auth_configured"` + ConfiguredConnections int `json:"configured_connections"` + MonitoringActive bool `json:"monitoring_active"` + OutcomeObserved30d bool `json:"outcome_observed_30d"` // Scale (counts only — no names, IPs, or identifiers) PVENodes int `json:"pve_nodes"` @@ -180,6 +221,15 @@ type Ping struct { // Last coarse update failure category; never raw error text. UpdateLastFailureCategory string `json:"update_last_failure_category,omitempty"` + // Core product outcomes. Alert history is retained locally for 30 days; + // notification delivery rows are locally retention-bounded to seven days. + AlertsFired30d int `json:"alerts_fired_30d"` + AlertsAcknowledged30d int `json:"alerts_acknowledged_30d"` + AlertsResolved30d int `json:"alerts_resolved_30d"` + NotificationAttempts7d int `json:"notification_attempts_7d"` + NotificationDeliveries7d int `json:"notification_deliveries_7d"` + NotificationFailures7d int `json:"notification_failures_7d"` + // Pulse Intelligence usage (30-day counts/booleans — no prompts, commands, outputs, resource IDs, or token values) PulseIntelligenceLoopConfigured bool `json:"pulse_intelligence_loop_configured"` PulseIntelligenceLoopActive30d bool `json:"pulse_intelligence_loop_active_30d"` @@ -290,6 +340,14 @@ type Snapshot struct { UpdateSuccesses30d int UpdateFailures30d int UpdateLastFailureCategory string + AuthConfigured bool + ConfiguredConnections int + AlertsFired30d int + AlertsAcknowledged30d int + AlertsResolved30d int + NotificationAttempts7d int + NotificationDeliveries7d int + NotificationFailures7d int PulseIntelligenceLoopConfigured bool PulseIntelligenceLoopActive30d bool PulseIntelligenceCompleteOperationsLoop30d bool @@ -613,11 +671,15 @@ type SnapshotFunc func() Snapshot // Config holds the static configuration for the telemetry runner. type Config struct { - Version string - DataDir string - IsDocker bool - Enabled bool // From cfg.TelemetryEnabled (system settings or env var) - GetSnapshot SnapshotFunc + Version string + DataDir string + IsDocker bool + // DeploymentMethod may be one of docker_compose, docker_run, + // container_other, systemd, binary_other, or other. Empty/invalid values + // fall back to container_other or binary_other without exporting raw input. + DeploymentMethod string + Enabled bool // From cfg.TelemetryEnabled (system settings or env var) + GetSnapshot SnapshotFunc } // runner holds the state for the background heartbeat goroutine. @@ -643,14 +705,11 @@ func Start(ctx context.Context, cfg Config) { return } - installID := getOrCreateInstallID(cfg.DataDir) - if installID == "" { + if getOrCreateInstallID(cfg.DataDir) == "" { log.Warn().Msg("Could not determine install ID; telemetry will not run") return } - base := basePing(cfg, installID) - ctx, cancel := context.WithCancel(ctx) r := &runner{cancel: cancel} @@ -662,8 +721,8 @@ func Start(ctx context.Context, cfg Config) { mu.Unlock() log.Info(). - Str("platform", base.Platform). - 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") + Str("platform", platformName(cfg.IsDocker)). + Msg("Outbound usage telemetry enabled — sends a rotating pseudonymous install ID, version identity, coarse lifecycle buckets, aggregate resource/outcome counts, feature flags, and content-free Patrol, Assistant, and capability-API usage counters") r.wg.Add(1) go func() { @@ -679,7 +738,7 @@ func Start(ctx context.Context, cfg Config) { } // Send startup ping with current snapshot. - sendEvent(ctx, base, cfg.GetSnapshot, "startup") + sendEvent(ctx, cfg, "startup") // Daily heartbeat with jitter. for { @@ -689,7 +748,7 @@ func Start(ctx context.Context, cfg Config) { timer.Stop() return case <-timer.C: - sendEvent(ctx, base, cfg.GetSnapshot, "heartbeat") + sendEvent(ctx, cfg, "heartbeat") } } }() @@ -710,14 +769,7 @@ func Stop() { // BuildPreview returns the current heartbeat payload without sending it. func BuildPreview(cfg Config) (Ping, error) { - installID := getOrCreateInstallID(cfg.DataDir) - if installID == "" { - return Ping{}, errInstallIDUnavailable - } - - ping := applySnapshot(basePing(cfg, installID), cfg.GetSnapshot) - ping.Event = "heartbeat" - return ping, nil + return buildPingAt(cfg, "heartbeat", time.Now().UTC()) } // ResetInstallID rotates the locally stored telemetry install ID immediately @@ -745,6 +797,7 @@ func jitteredHeartbeat() time.Duration { func basePing(cfg Config, installID string) Ping { versionIdentity := updates.DescribeUsageDataVersion(cfg.Version) return Ping{ + SchemaVersion: TelemetrySchemaVersion, InstallID: installID, Version: versionIdentity.Version, VersionRaw: versionIdentity.RawVersion, @@ -755,9 +808,25 @@ func basePing(cfg Config, installID string) Ping { Platform: platformName(cfg.IsDocker), OS: runtime.GOOS, Arch: runtime.GOARCH, + DeploymentMethod: deploymentMethod(cfg), } } +func deploymentMethod(cfg Config) string { + raw := strings.ToLower(strings.TrimSpace(cfg.DeploymentMethod)) + if raw == "" { + raw = strings.ToLower(strings.TrimSpace(os.Getenv("PULSE_DEPLOYMENT_METHOD"))) + } + switch raw { + case "docker_compose", "docker_run", "container_other", "systemd", "binary_other", "other": + return raw + } + if cfg.IsDocker { + return "container_other" + } + return "binary_other" +} + func platformName(isDocker bool) string { if isDocker { return "docker" @@ -810,6 +879,14 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping { ping.UpdateSuccesses30d = s.UpdateSuccesses30d ping.UpdateFailures30d = s.UpdateFailures30d ping.UpdateLastFailureCategory = s.UpdateLastFailureCategory + ping.AuthConfigured = s.AuthConfigured + ping.ConfiguredConnections = s.ConfiguredConnections + ping.AlertsFired30d = s.AlertsFired30d + ping.AlertsAcknowledged30d = s.AlertsAcknowledged30d + ping.AlertsResolved30d = s.AlertsResolved30d + ping.NotificationAttempts7d = s.NotificationAttempts7d + ping.NotificationDeliveries7d = s.NotificationDeliveries7d // gitleaks:allow -- schema field name, not a credential + ping.NotificationFailures7d = s.NotificationFailures7d ping.PulseIntelligenceLoopConfigured = s.PulseIntelligenceLoopConfigured ping.PulseIntelligenceLoopActive30d = s.PulseIntelligenceLoopActive30d ping.PulseIntelligenceCompleteOperationsLoop30d = s.PulseIntelligenceCompleteOperationsLoop30d @@ -959,27 +1036,237 @@ func shouldKeepInstallIDRecord(record installIDRecord, now time.Time) bool { return now.Sub(issuedAt) < installIDRotationWindow } +var lifecycleMu sync.Mutex + +func applyLifecycle(ping *Ping, dataDir string, now time.Time) { + if ping == nil { + return + } + now = now.UTC() + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + + record := readLifecycleRecord(dataDir) + if record.FirstObservedAt.IsZero() || record.FirstObservedAt.After(now) { + record.FirstObservedAt = now + } + + currentStage := activationStage(*ping) + if activationStageRank(currentStage) > activationStageRank(record.HighestObservedActivation) { + record.HighestObservedActivation = currentStage + } + if record.HighestObservedActivation == "" { + record.HighestObservedActivation = "started" + } + + ping.MonitoringActive = monitoredResourceCount(*ping) > 0 + ping.OutcomeObserved30d = ping.ActiveAlerts > 0 || + ping.AlertsFired30d > 0 || + ping.AlertsAcknowledged30d > 0 || + ping.AlertsResolved30d > 0 || + ping.NotificationDeliveries7d > 0 + if ping.MonitoringActive && record.FirstMonitoredResourceAt == nil { + observedAt := now + record.FirstMonitoredResourceAt = &observedAt + } + + ping.KnownInstallAgeBucket = durationBucket(now.Sub(record.FirstObservedAt), []durationBoundary{ + {24 * time.Hour, "under_1d"}, + {7 * 24 * time.Hour, "1_7d"}, + {30 * 24 * time.Hour, "8_30d"}, + {90 * 24 * time.Hour, "31_90d"}, + {365 * 24 * time.Hour, "91_365d"}, + }, "over_365d") + ping.ActivationStage = record.HighestObservedActivation + ping.EstateSizeBucket = estateSizeBucket(monitoredResourceCount(*ping)) + ping.TimeToFirstMonitoredResourceBucket = "not_observed" + if record.FirstMonitoredResourceAt != nil { + if record.FirstMonitoredResourceAt.Equal(record.FirstObservedAt) { + ping.TimeToFirstMonitoredResourceBucket = "present_at_first_observation" + } else { + elapsed := record.FirstMonitoredResourceAt.Sub(record.FirstObservedAt) + if elapsed < 0 { + elapsed = 0 + } + ping.TimeToFirstMonitoredResourceBucket = durationBucket(elapsed, []durationBoundary{ + {15 * time.Minute, "under_15m"}, + {time.Hour, "15m_1h"}, + {6 * time.Hour, "1_6h"}, + {24 * time.Hour, "6_24h"}, + {3 * 24 * time.Hour, "1_3d"}, + {7 * 24 * time.Hour, "4_7d"}, + {30 * 24 * time.Hour, "8_30d"}, + }, "over_30d") + } + } + + if err := writeLifecycleRecord(dataDir, record); err != nil { + log.Debug().Err(err).Msg("Could not persist coarse telemetry lifecycle milestones") + } +} + +type durationBoundary struct { + upper time.Duration + label string +} + +func durationBucket(value time.Duration, boundaries []durationBoundary, overflow string) string { + for _, boundary := range boundaries { + if value < boundary.upper { + return boundary.label + } + } + return overflow +} + +func activationStage(ping Ping) string { + switch { + case ping.ActiveAlerts > 0 || ping.AlertsFired30d > 0 || ping.AlertsResolved30d > 0 || ping.NotificationDeliveries7d > 0: + return "outcome_observed" + case monitoredResourceCount(ping) > 0: + return "monitoring" + case ping.ConfiguredConnections > 0: + return "connected" + case ping.AuthConfigured: + return "secured" + default: + return "started" + } +} + +func activationStageRank(stage string) int { + switch stage { + case "secured": + return 2 + case "connected": + return 3 + case "monitoring": + return 4 + case "outcome_observed": + return 5 + default: + return 1 + } +} + +func validActivationStage(stage string) bool { + switch stage { + case "started", "secured", "connected", "monitoring", "outcome_observed": + return true + default: + return false + } +} + +func monitoredResourceCount(ping Ping) int { + return ping.PVENodes + ping.PBSInstances + ping.PMGInstances + ping.VMs + + ping.Containers + ping.AgentHosts + ping.DockerHosts + ping.DockerContainers + + ping.KubernetesClusters + ping.KubernetesNodes + ping.KubernetesPods + + ping.KubernetesDeployments + ping.StoragePools + ping.PhysicalDisks + + ping.CephClusters + ping.NetworkShares + ping.TrueNASSystems + ping.TrueNASVMs + + ping.TrueNASApps + ping.VMwareHosts + ping.VMwareVMs + ping.VMwareDatastores + + ping.AvailabilityTargets +} + +func estateSizeBucket(resources int) string { + switch { + case resources <= 0: + return "empty" + case resources <= 10: + return "1_10" + case resources <= 50: + return "11_50" + case resources <= 200: + return "51_200" + case resources <= 1000: + return "201_1000" + default: + return "over_1000" + } +} + +func readLifecycleRecord(dataDir string) lifecycleRecord { + data, err := os.ReadFile(filepath.Join(dataDir, lifecycleStateFile)) + if err != nil { + return lifecycleRecord{} + } + var record lifecycleRecord + if err := json.Unmarshal(data, &record); err != nil { + return lifecycleRecord{} + } + if !validActivationStage(record.HighestObservedActivation) { + record.HighestObservedActivation = "" + } + return record +} + +func writeLifecycleRecord(dataDir string, record lifecycleRecord) error { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return err + } + encoded, err := json.Marshal(record) + if err != nil { + return err + } + tmp, err := os.CreateTemp(dataDir, ".telemetry_lifecycle-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(append(encoded, '\n')); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, filepath.Join(dataDir, lifecycleStateFile)) +} + // sendEvent builds and sends one ping for the given event unless mock mode is // active. A mock-mode snapshot describes the synthetic fixture fleet, not a // real installation, so it must never reach the telemetry endpoint. The check // runs per event (not once at Start) because mock mode can be toggled at // runtime. -func sendEvent(ctx context.Context, base Ping, fn SnapshotFunc, event string) { +func sendEvent(ctx context.Context, cfg Config, event string) { if mock.IsMockEnabled() { log.Debug().Str("event", event).Msg("Suppressing outbound telemetry ping while mock mode is enabled") return } - ping := applySnapshot(base, fn) - ping.Event = event - send(ctx, ping) + ping, err := buildPingAt(cfg, event, time.Now().UTC()) + if err != nil { + log.Debug().Err(err).Str("event", event).Msg("Telemetry ping could not be built") + return + } + if err := send(ctx, ping); err != nil { + log.Debug().Err(err).Msg("Telemetry ping failed (will retry at next heartbeat)") + } } -// send posts a ping to the telemetry endpoint. Failures are silently ignored -// — telemetry must never interfere with normal operation. -func send(ctx context.Context, ping Ping) { +func buildPingAt(cfg Config, event string, now time.Time) (Ping, error) { + now = now.UTC() + installID := getOrCreateInstallIDAt(cfg.DataDir, now) + if installID == "" { + return Ping{}, errInstallIDUnavailable + } + ping := applySnapshot(basePing(cfg, installID), cfg.GetSnapshot) + ping.Event = event + ping.SentAt = now.Format(time.RFC3339) + applyLifecycle(&ping, cfg.DataDir, now) + return ping, nil +} + +// send posts a ping to the telemetry endpoint. Errors are observable in debug +// logs but never affect normal Pulse operation. +func send(ctx context.Context, ping Ping) error { body, err := json.Marshal(ping) if err != nil { - return + return err } reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) @@ -987,14 +1274,18 @@ func send(ctx context.Context, ping Ping) { req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, pingEndpoint, bytes.NewReader(body)) if err != nil { - return + return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - log.Debug().Err(err).Msg("Telemetry ping failed (will retry at next heartbeat)") - return + return err } - resp.Body.Close() + defer resp.Body.Close() + _, _ = io.CopyN(io.Discard, resp.Body, 4096) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("telemetry endpoint returned HTTP %d", resp.StatusCode) + } + return nil } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 46ca3d4ae..80ca06e9c 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -609,18 +609,15 @@ func TestApplyUpdateTelemetrySnapshotDoesNotExposeRawFailureText(t *testing.T) { } } -func TestPulseIntelligenceTelemetryFieldsAreDisclosed(t *testing.T) { +func TestAllTelemetryFieldsAreDisclosed(t *testing.T) { pingType := reflect.TypeOf(Ping{}) fieldLabels := make([]string, 0) for i := 0; i < pingType.NumField(); i++ { jsonName := strings.Split(pingType.Field(i).Tag.Get("json"), ",")[0] - if !strings.HasPrefix(jsonName, "pulse_intelligence_") { - continue - } fieldLabels = append(fieldLabels, normalizedTelemetryDisclosureLabel(jsonName)) } if len(fieldLabels) == 0 { - t.Fatal("expected Pulse Intelligence telemetry fields on Ping") + t.Fatal("expected telemetry fields on Ping") } for _, relativePath := range []string{ @@ -634,7 +631,7 @@ func TestPulseIntelligenceTelemetryFieldsAreDisclosed(t *testing.T) { doc := normalizedTelemetryDisclosureTableText(string(raw)) for _, label := range fieldLabels { if !strings.Contains(doc, label) { - t.Errorf("%s must disclose Pulse Intelligence telemetry field %q", relativePath, label) + t.Errorf("%s must disclose telemetry field %q", relativePath, label) } } } @@ -656,6 +653,8 @@ func TestTelemetryPrivacyDocsDisclosePseudonymousIdentityAndIPHandling(t *testin "outbound usage telemetry", "enabled by default", "rotating pseudonymous install ID", + "Pulse does not send browser events or an event-level clickstream", + "Lifecycle and outcome signals are deliberately limited to closed buckets, booleans, and aggregate counts", "PULSE_TELEMETRY=false", "The license server uses request IP addresses transiently for abuse/rate limiting", } { @@ -683,8 +682,11 @@ func normalizedTelemetryDisclosureTableText(value string) string { } func normalizedTelemetryDisclosureLabel(jsonName string) string { - label := strings.TrimPrefix(jsonName, "pulse_intelligence_") - return normalizedTelemetryDisclosureText("Pulse Intelligence " + strings.ReplaceAll(label, "_", " ")) + if strings.HasPrefix(jsonName, "pulse_intelligence_") { + label := strings.TrimPrefix(jsonName, "pulse_intelligence_") + return normalizedTelemetryDisclosureText("Pulse Intelligence " + strings.ReplaceAll(label, "_", " ")) + } + return normalizedTelemetryDisclosureText(strings.ReplaceAll(jsonName, "_", " ")) } func normalizedTelemetryDisclosureText(value string) string { @@ -770,6 +772,21 @@ func TestBuildPreview_UsesCurrentHeartbeatPayload(t *testing.T) { if preview.InstallID == "" { t.Fatal("expected preview install ID") } + if preview.SchemaVersion != TelemetrySchemaVersion || preview.SentAt == "" { + t.Fatalf("preview schema identity = version %d sent_at %q", preview.SchemaVersion, preview.SentAt) + } + if preview.DeploymentMethod != "container_other" { + t.Fatalf("preview deployment method = %q, want container_other", preview.DeploymentMethod) + } + if preview.ActivationStage != "outcome_observed" || !preview.MonitoringActive { + t.Fatalf("preview lifecycle = stage %q active %v, want outcome_observed/true", preview.ActivationStage, preview.MonitoringActive) + } + if preview.EstateSizeBucket != "11_50" { + t.Fatalf("preview estate bucket = %q, want 11_50", preview.EstateSizeBucket) + } + if preview.TimeToFirstMonitoredResourceBucket != "present_at_first_observation" { + t.Fatalf("preview time-to-monitoring bucket = %q, want present_at_first_observation", preview.TimeToFirstMonitoredResourceBucket) + } record := decodeInstallIDRecordFile(t, filepath.Join(dir, installIDFile)) if record.InstallID != preview.InstallID { @@ -777,6 +794,106 @@ func TestBuildPreview_UsesCurrentHeartbeatPayload(t *testing.T) { } } +func TestBuildPingAt_RotatesIdentifierDuringLongRunningSession(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + cfg := Config{DataDir: dir} + + first, err := buildPingAt(cfg, "startup", start) + if err != nil { + t.Fatalf("build first ping: %v", err) + } + second, err := buildPingAt(cfg, "heartbeat", start.Add(installIDRotationWindow+time.Hour)) + if err != nil { + t.Fatalf("build second ping: %v", err) + } + if first.InstallID == second.InstallID { + t.Fatalf("expected per-event install ID rotation, got %q twice", first.InstallID) + } +} + +func TestDeploymentMethodRejectsFreeFormValues(t *testing.T) { + t.Setenv("PULSE_DEPLOYMENT_METHOD", "/home/alice/private-install") + if got := deploymentMethod(Config{IsDocker: true}); got != "container_other" { + t.Fatalf("docker invalid deployment value = %q, want container_other", got) + } + if got := deploymentMethod(Config{}); got != "binary_other" { + t.Fatalf("binary invalid deployment value = %q, want binary_other", got) + } + + t.Setenv("PULSE_DEPLOYMENT_METHOD", "systemd") + if got := deploymentMethod(Config{}); got != "systemd" { + t.Fatalf("closed deployment value = %q, want systemd", got) + } +} + +func TestBuildPingAt_PersistsOnlyCoarseLifecycleMilestones(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + snapshot := Snapshot{AuthConfigured: true} + cfg := Config{ + DataDir: dir, + GetSnapshot: func() Snapshot { + return snapshot + }, + } + + first, err := buildPingAt(cfg, "heartbeat", start) + if err != nil { + t.Fatalf("build first ping: %v", err) + } + if first.ActivationStage != "secured" || first.TimeToFirstMonitoredResourceBucket != "not_observed" { + t.Fatalf("first lifecycle = %#v", first) + } + + snapshot = Snapshot{ + AuthConfigured: true, + ConfiguredConnections: 1, + PVENodes: 1, + VMs: 4, + AlertsFired30d: 1, + AlertsResolved30d: 1, + } + second, err := buildPingAt(cfg, "heartbeat", start.Add(2*time.Hour)) + if err != nil { + t.Fatalf("build second ping: %v", err) + } + if second.ActivationStage != "outcome_observed" || !second.MonitoringActive || !second.OutcomeObserved30d { + t.Fatalf("second lifecycle = %#v", second) + } + if second.TimeToFirstMonitoredResourceBucket != "1_6h" { + t.Fatalf("time-to-monitoring bucket = %q, want 1_6h", second.TimeToFirstMonitoredResourceBucket) + } + + snapshot = Snapshot{} + third, err := buildPingAt(cfg, "heartbeat", start.Add(8*24*time.Hour)) + if err != nil { + t.Fatalf("build third ping: %v", err) + } + if third.ActivationStage != "outcome_observed" || third.MonitoringActive { + t.Fatalf("historical/current lifecycle split = stage %q active %v", third.ActivationStage, third.MonitoringActive) + } + if third.KnownInstallAgeBucket != "8_30d" { + t.Fatalf("known install age bucket = %q, want 8_30d", third.KnownInstallAgeBucket) + } + + data, err := os.ReadFile(filepath.Join(dir, lifecycleStateFile)) + if err != nil { + t.Fatalf("read lifecycle state: %v", err) + } + var stored map[string]any + if err := json.Unmarshal(data, &stored); err != nil { + t.Fatalf("decode lifecycle state: %v", err) + } + for key := range stored { + switch key { + case "first_observed_at", "first_monitored_resource_at", "highest_observed_activation": + default: + t.Fatalf("unexpected lifecycle state field %q", key) + } + } +} + func TestSend_Success(t *testing.T) { var received atomic.Int32 var lastPing Ping @@ -879,6 +996,22 @@ func TestSend_UsesReducedCommercialSignals(t *testing.T) { } } +func TestSend_ReturnsNonSuccessStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rejected", http.StatusUnprocessableEntity) + })) + defer ts.Close() + + origEndpoint := pingEndpoint + pingEndpoint = ts.URL + defer func() { pingEndpoint = origEndpoint }() + + err := send(context.Background(), Ping{InstallID: uuid.New().String()}) + if err == nil || !strings.Contains(err.Error(), "HTTP 422") { + t.Fatalf("send error = %v, want HTTP 422", err) + } +} + func TestJitteredHeartbeat_WithinBounds(t *testing.T) { min := heartbeatInterval - maxHeartbeatJitter max := heartbeatInterval + maxHeartbeatJitter @@ -915,8 +1048,9 @@ func TestSendEvent_SuppressedWhileMockModeEnabled(t *testing.T) { testutil.SetMockMode(t, true) - sendEvent(context.Background(), Ping{InstallID: uuid.New().String()}, nil, "startup") - sendEvent(context.Background(), Ping{InstallID: uuid.New().String()}, nil, "heartbeat") + cfg := Config{DataDir: t.TempDir()} + sendEvent(context.Background(), cfg, "startup") + sendEvent(context.Background(), cfg, "heartbeat") if got := received.Load(); got != 0 { t.Fatalf("expected no telemetry pings while mock mode is enabled, got %d", got) @@ -940,7 +1074,7 @@ func TestSendEvent_SendsWhenMockModeDisabled(t *testing.T) { testutil.SetMockMode(t, false) - sendEvent(context.Background(), Ping{InstallID: uuid.New().String()}, nil, "heartbeat") + sendEvent(context.Background(), Config{DataDir: t.TempDir()}, "heartbeat") if got := received.Load(); got != 1 { t.Fatalf("expected 1 telemetry ping with mock mode disabled, got %d", got) diff --git a/pkg/server/server.go b/pkg/server/server.go index de4764ff2..159c63c42 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -476,8 +476,19 @@ func Run(ctx context.Context, version string) error { } snap := telemetry.Snapshot{ - MultiTenant: currentCfg.MultiTenantEnabled, - HasAPITokens: currentCfg.HasAPITokens(), + MultiTenant: currentCfg.MultiTenantEnabled, + HasAPITokens: currentCfg.HasAPITokens(), + AuthConfigured: currentCfg.AuthUser != "" || currentCfg.AuthPass != "" || currentCfg.HasAPITokens() || currentCfg.ProxyAuthSecret != "", + ConfiguredConnections: len(currentCfg.PVEInstances) + len(currentCfg.PBSInstances) + len(currentCfg.PMGInstances), + } + if truenas, err := telemetryPersistence.LoadTrueNASConfig(); err == nil { + snap.ConfiguredConnections += len(truenas) + } + if vmware, err := telemetryPersistence.LoadVMwareConfig(); err == nil { + snap.ConfiguredConnections += len(vmware) + } + if targets, err := telemetryPersistence.LoadAvailabilityTargets(); err == nil { + snap.ConfiguredConnections += len(targets) } // Resource counts come from the tenant-aware monitor aggregate, not the @@ -507,6 +518,12 @@ func Run(ctx context.Context, version string) error { snap.VMwareDatastores = counts.VMwareDatastores snap.AvailabilityTargets = counts.AvailabilityTargets snap.ActiveAlerts = counts.ActiveAlerts + snap.AlertsFired30d = counts.AlertsFired30d + snap.AlertsAcknowledged30d = counts.AlertsAcknowledged30d + snap.AlertsResolved30d = counts.AlertsResolved30d + snap.NotificationAttempts7d = counts.NotificationAttempts7d + snap.NotificationDeliveries7d = counts.NotificationDeliveries7d + snap.NotificationFailures7d = counts.NotificationFailures7d snap.DiscoveryEnabled = currentCfg.DiscoveryEnabled // Feature flags from persisted config (using pre-created persistence). @@ -523,6 +540,7 @@ func Run(ctx context.Context, version string) error { // SSO/OIDC status. if ssoCfg, err := telemetryPersistence.LoadSSOConfig(); err == nil && ssoCfg != nil { snap.SSOEnabled = ssoCfg.HasEnabledProviders() + snap.AuthConfigured = snap.AuthConfigured || snap.SSOEnabled } if emailCfg, err := telemetryPersistence.LoadEmailConfig(); err == nil && emailCfg != nil && emailCfg.Enabled { snap.NotificationsEnabled = true diff --git a/scripts/check_telemetry_schema_parity.py b/scripts/check_telemetry_schema_parity.py new file mode 100644 index 000000000..a0805e11d --- /dev/null +++ b/scripts/check_telemetry_schema_parity.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Verify the public telemetry payload and private receiver stay in lockstep.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +FIELD_RE = re.compile( + r'^\s*[A-Za-z0-9_]+\s+([A-Za-z0-9_]+)\s+`json:"([^",]+)(?:,[^"]*)?"`', + re.MULTILINE, +) +TYPESCRIPT_FIELD_RE = re.compile( + r"^\s*([a-z0-9_]+)\??:\s*(string|number|boolean);", + re.MULTILINE, +) +TYPESCRIPT_TO_GO = {"string": "string", "number": "int", "boolean": "bool"} +LEGACY_RECEIVER_ONLY_FIELDS = {"license_tier", "api_tokens"} + + +def struct_body(source: str, marker: str) -> str: + start = source.find(marker) + if start < 0: + raise ValueError(f"missing struct marker {marker!r}") + opening = source.find("{", start) + if opening < 0: + raise ValueError(f"missing opening brace after {marker!r}") + + depth = 0 + for index in range(opening, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[opening + 1 : index] + raise ValueError(f"unterminated struct after {marker!r}") + + +def json_fields(source: str, marker: str) -> dict[str, str]: + fields: dict[str, str] = {} + for go_type, json_name in FIELD_RE.findall(struct_body(source, marker)): + if json_name in fields: + raise ValueError(f"duplicate JSON field {json_name!r} after {marker!r}") + fields[json_name] = go_type + if not fields: + raise ValueError(f"no JSON fields found after {marker!r}") + return fields + + +def typescript_fields(source: str, marker: str) -> dict[str, str]: + fields: dict[str, str] = {} + for json_name, typescript_type in TYPESCRIPT_FIELD_RE.findall(struct_body(source, marker)): + if json_name in fields: + raise ValueError(f"duplicate TypeScript field {json_name!r} after {marker!r}") + fields[json_name] = TYPESCRIPT_TO_GO[typescript_type] + if not fields: + raise ValueError(f"no TypeScript fields found after {marker!r}") + return fields + + +def parity_errors( + public_source: str, + receiver_source: str, + frontend_source: str | None = None, +) -> list[str]: + public = json_fields(public_source, "type Ping struct") + receiver = json_fields(receiver_source, "var ping struct") + errors: list[str] = [] + + missing = sorted(set(public) - set(receiver)) + if missing: + errors.append("receiver missing public fields: " + ", ".join(missing)) + + unexpected = sorted(set(receiver) - set(public) - LEGACY_RECEIVER_ONLY_FIELDS) + if unexpected: + errors.append("receiver-only fields outside the legacy allowlist: " + ", ".join(unexpected)) + + mismatched = sorted( + name + for name in set(public) & set(receiver) + if public[name] != receiver[name] + ) + if mismatched: + errors.append( + "field type mismatches: " + + ", ".join( + f"{name} (public {public[name]}, receiver {receiver[name]})" + for name in mismatched + ) + ) + + if public.get("schema_version") != "int": + errors.append("public payload must contain integer schema_version") + + if frontend_source is not None: + frontend = typescript_fields(frontend_source, "export interface TelemetryPingPreview") + frontend_missing = sorted(set(public) - set(frontend)) + if frontend_missing: + errors.append("frontend preview missing public fields: " + ", ".join(frontend_missing)) + + frontend_unexpected = sorted(set(frontend) - set(public)) + if frontend_unexpected: + errors.append("frontend preview fields absent from public payload: " + ", ".join(frontend_unexpected)) + + frontend_mismatched = sorted( + name + for name in set(public) & set(frontend) + if public[name] != frontend[name] + ) + if frontend_mismatched: + errors.append( + "frontend field type mismatches: " + + ", ".join( + f"{name} (public {public[name]}, frontend {frontend[name]})" + for name in frontend_mismatched + ) + ) + return errors + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--pulse-repo", + type=Path, + default=Path(__file__).resolve().parents[1], + ) + parser.add_argument( + "--pulse-pro-repo", + type=Path, + default=Path(__file__).resolve().parents[2] / "pulse-pro", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + public_path = args.pulse_repo / "internal" / "telemetry" / "telemetry.go" + frontend_path = args.pulse_repo / "frontend-modern" / "src" / "api" / "settings.ts" + receiver_path = args.pulse_pro_repo / "license-server" / "main.go" + if not public_path.is_file(): + raise SystemExit(f"public telemetry source not found: {public_path}") + if not receiver_path.is_file(): + raise SystemExit(f"private telemetry receiver source not found: {receiver_path}") + if not frontend_path.is_file(): + raise SystemExit(f"frontend telemetry preview source not found: {frontend_path}") + + errors = parity_errors( + public_path.read_text(), + receiver_path.read_text(), + frontend_path.read_text(), + ) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("telemetry schema parity: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 73f1126ff..49ed3d06a 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1372,6 +1372,17 @@ func TestDockerfileStagesShippedDocsForEmbeddedFrontendBuild(t *testing.T) { } } +func TestDockerfileStampsTelemetryDeploymentMethod(t *testing.T) { + dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile")) + if err != nil { + t.Fatalf("read Dockerfile: %v", err) + } + + if !strings.Contains(string(dockerfileBytes), `ENV PULSE_DEPLOYMENT_METHOD=container_other`) { + t.Fatal("Dockerfile must stamp the closed fallback deployment method for container images") + } +} + func TestReleaseUpdateKeyFingerprintUsesCanonicalRawPublicKeyHash(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 299401085..f5367725b 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -501,6 +501,25 @@ func TestRootInstallShowsBootstrapTokenCommandInsteadOfEncryptedFile(t *testing. } } +func TestCanonicalServerDeploymentMethodsAreStampedForTelemetry(t *testing.T) { + repoRoot := filepath.Join("..", "..") + required := map[string]string{ + "Dockerfile": "ENV PULSE_DEPLOYMENT_METHOD=container_other", + "docker-compose.yml": "PULSE_DEPLOYMENT_METHOD=docker_compose", + "install.sh": `Environment="PULSE_DEPLOYMENT_METHOD=systemd"`, + "README.md": "PULSE_DEPLOYMENT_METHOD=docker_run", + } + for relativePath, marker := range required { + content, err := os.ReadFile(filepath.Join(repoRoot, relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + if !strings.Contains(string(content), marker) { + t.Errorf("%s must stamp coarse deployment method %q", relativePath, marker) + } + } +} + func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) { rootInstall, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) if err != nil { diff --git a/scripts/telemetry_adoption_report.py b/scripts/telemetry_adoption_report.py index 70c4fcaec..118dc3990 100644 --- a/scripts/telemetry_adoption_report.py +++ b/scripts/telemetry_adoption_report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Summarize Pulse anonymous telemetry for operator-facing adoption reads. +"""Summarize Pulse pseudonymous telemetry for operator-facing adoption reads. This script intentionally normalizes version strings before aggregation so manual builds, dev builds, and accidental `v` prefixes do not pollute @@ -12,6 +12,7 @@ import argparse from collections import Counter from dataclasses import dataclass from datetime import datetime, timedelta, timezone +import gzip import json import re import sqlite3 @@ -66,6 +67,27 @@ FEATURE_BOOL_FIELDS = ( ("paid_license", "Paid license"), ("has_api_tokens", "Has API tokens"), ) +USER_BASE_CATEGORY_FIELDS = ( + ("deployment_method", "Deployment method"), + ("known_install_age_bucket", "Known install age"), + ("activation_stage", "Highest observed activation stage"), + ("time_to_first_monitored_resource_bucket", "Time to first monitored resource"), + ("estate_size_bucket", "Estate size"), +) +USER_BASE_BOOL_FIELDS = ( + ("auth_configured", "Authentication configured"), + ("monitoring_active", "Monitoring currently active"), + ("outcome_observed_30d", "Operational outcome observed"), +) +USER_BASE_COUNT_FIELDS = ( + ("configured_connections", "Configured connections"), + ("alerts_fired_30d", "Alerts fired (30d)"), + ("alerts_acknowledged_30d", "Alerts acknowledged (30d)"), + ("alerts_resolved_30d", "Alerts resolved (30d)"), + ("notification_attempts_7d", "Notification attempts (7d)"), + ("notification_deliveries_7d", "Notification deliveries (7d)"), + ("notification_failures_7d", "Notification failures (7d)"), +) PULSE_INTELLIGENCE_ASSISTANT_LOOP_BOOL_FIELDS = ( "pulse_intelligence_assistant_operations_loop_30d", "pulse_intelligence_assistant_approved_execution_loop_30d", @@ -157,18 +179,18 @@ PULSE_INTELLIGENCE_BOOL_FIELDS = ( "pulse_intelligence_assistant_resolved_operations_loop_30d", "Assistant resolved operations loop 30d", ), - ("pulse_intelligence_external_agent_operations_loop_30d", "External-agent operations loop 30d"), + ("pulse_intelligence_external_agent_operations_loop_30d", "Token-authenticated capability API operations loop 30d"), ( "pulse_intelligence_external_agent_approved_execution_loop_30d", - "External-agent approved execution loop 30d", + "Token-authenticated capability API approved execution loop 30d", ), ( "pulse_intelligence_external_agent_approved_action_success_loop_30d", - "External-agent approved action success loop 30d", + "Token-authenticated capability API approved action success loop 30d", ), ( "pulse_intelligence_external_agent_resolved_operations_loop_30d", - "External-agent resolved operations loop 30d", + "Token-authenticated capability API resolved operations loop 30d", ), ("pulse_intelligence_mcp_adapter_operations_loop_30d", "Pulse MCP adapter operations loop 30d"), ( @@ -183,34 +205,34 @@ PULSE_INTELLIGENCE_BOOL_FIELDS = ( "pulse_intelligence_mcp_adapter_resolved_operations_loop_30d", "Pulse MCP adapter resolved operations loop 30d", ), - ("pulse_intelligence_external_agent_enabled", "External agent enabled"), - ("pulse_intelligence_external_agent_used_30d", "External agent used 30d"), + ("pulse_intelligence_external_agent_enabled", "Capability API operations-loop token configured"), + ("pulse_intelligence_external_agent_used_30d", "Token-authenticated capability API used 30d"), ("pulse_intelligence_mcp_adapter_used_30d", "Pulse MCP adapter used 30d"), ) PULSE_INTELLIGENCE_EXTERNAL_AGENT_CAPABILITY_COUNT_FIELDS = ( ( "pulse_intelligence_external_agent_context_requests_30d", - "External agent context requests 30d", + "Token-authenticated capability API context requests 30d", ), ( "pulse_intelligence_external_agent_event_stream_requests_30d", - "External agent event-stream requests 30d", + "Token-authenticated capability API event-stream requests 30d", ), ( "pulse_intelligence_external_agent_provisioning_requests_30d", - "External agent provisioning requests 30d", + "Token-authenticated capability API provisioning requests 30d", ), ( "pulse_intelligence_external_agent_operator_state_requests_30d", - "External agent operator-state requests 30d", + "Token-authenticated capability API operator-state requests 30d", ), ( "pulse_intelligence_external_agent_finding_requests_30d", - "External agent finding requests 30d", + "Token-authenticated capability API finding requests 30d", ), ( "pulse_intelligence_external_agent_action_requests_30d", - "External agent action requests 30d", + "Token-authenticated capability API action requests 30d", ), ) PULSE_INTELLIGENCE_EXTERNAL_AGENT_CAPABILITY_COUNT_FIELD_NAMES = tuple( @@ -359,7 +381,7 @@ PULSE_INTELLIGENCE_OUTCOME_COHORTS = ( ), ( "external_agent_operations_loop_30d", - "External-agent operations loop 30d", + "Capability API/MCP adapter operations loop 30d", ( "pulse_intelligence_external_agent_operations_loop_30d", "pulse_intelligence_mcp_adapter_operations_loop_30d", @@ -368,7 +390,7 @@ PULSE_INTELLIGENCE_OUTCOME_COHORTS = ( ), ( "external_agent_approved_execution_loop_30d", - "External-agent approved execution loop 30d", + "Capability API/MCP adapter approved execution loop 30d", ( "pulse_intelligence_external_agent_approved_execution_loop_30d", "pulse_intelligence_mcp_adapter_approved_execution_loop_30d", @@ -377,7 +399,7 @@ PULSE_INTELLIGENCE_OUTCOME_COHORTS = ( ), ( "external_agent_approved_action_success_loop_30d", - "External-agent approved action success loop 30d", + "Capability API/MCP adapter approved action success loop 30d", ( "pulse_intelligence_external_agent_approved_action_success_loop_30d", "pulse_intelligence_mcp_adapter_approved_action_success_loop_30d", @@ -386,7 +408,7 @@ PULSE_INTELLIGENCE_OUTCOME_COHORTS = ( ), ( "external_agent_resolved_operations_loop_30d", - "External-agent resolved operations loop 30d", + "Capability API/MCP adapter resolved operations loop 30d", ( "pulse_intelligence_external_agent_resolved_operations_loop_30d", "pulse_intelligence_mcp_adapter_resolved_operations_loop_30d", @@ -492,7 +514,7 @@ PULSE_INTELLIGENCE_OUTCOME_COHORTS = ( ), ( "external_agent_used_30d", - "External agent/MCP used 30d", + "Capability API/MCP adapter used 30d", PULSE_INTELLIGENCE_EXTERNAL_AGENT_ACTIVITY_BOOL_FIELD_NAMES, PULSE_INTELLIGENCE_EXTERNAL_AGENT_CAPABILITY_COUNT_FIELD_NAMES, ), @@ -797,22 +819,22 @@ PULSE_INTELLIGENCE_OPERATIONS_FUNNEL_STAGES = ( ), ( "external_agent_operations_loop", - "External-agent operations loop", + "Capability API/MCP adapter operations loop", ("external_agent_operations_loop",), ), ( "external_agent_approved_execution_loop", - "External-agent approved execution loop", + "Capability API/MCP adapter approved execution loop", ("external_agent_approved_execution_loop",), ), ( "external_agent_approved_success_loop", - "External-agent approved action success loop", + "Capability API/MCP adapter approved action success loop", ("external_agent_approved_success_loop",), ), ( "external_agent_resolved_operations_loop", - "External-agent resolved operations loop", + "Capability API/MCP adapter resolved operations loop", ("external_agent_resolved_operations_loop",), ), ( @@ -1142,10 +1164,10 @@ def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]: """ SELECT * FROM telemetry_pings - WHERE julianday(received_at) >= julianday('now') - ? + WHERE received_at >= datetime('now', ?) ORDER BY received_at DESC """, - (since_days,), + (f"-{since_days} days",), ).fetchall() ] return {"db_stats": db_stats, "rows": rows} @@ -1157,6 +1179,7 @@ def fetch_rows_remote(ssh_host: str, db_path: str, since_days: int) -> dict[str, # Streams JSON-lines (db_stats header, then one row per line) so the remote # process never holds the full result set — the license droplet has 1GB RAM. remote_script = """ +import gzip import json import sqlite3 import sys @@ -1174,25 +1197,34 @@ db_stats_sql = ( rows_sql = ( "SELECT * " "FROM telemetry_pings " - "WHERE julianday(received_at) >= julianday('now') - ? " + "WHERE received_at >= datetime('now', ?) " "ORDER BY received_at DESC" ) +output = gzip.GzipFile(fileobj=sys.stdout.buffer, mode="wb", compresslevel=6) + +def emit(value): + output.write(json.dumps(value, separators=(",", ":")).encode("utf-8") + b"\n") + try: db_stats = dict(conn.execute(db_stats_sql).fetchone()) - print(json.dumps({"db_stats": db_stats})) - for row in conn.execute(rows_sql, (since_days,)): - print(json.dumps(dict(row))) + emit({"db_stats": db_stats}) + for row in conn.execute(rows_sql, (f"-{since_days} days",)): + emit(dict(row)) finally: conn.close() + output.close() """ result = subprocess.run( ["ssh", ssh_host, "python3", "-", db_path, str(since_days)], - input=remote_script, - text=True, + input=remote_script.encode("utf-8"), capture_output=True, check=True, ) - lines = (line for line in result.stdout.splitlines() if line.strip()) + lines = ( + line + for line in gzip.decompress(result.stdout).decode("utf-8").splitlines() + if line.strip() + ) try: header = json.loads(next(lines)) except StopIteration: @@ -1761,6 +1793,64 @@ def telemetry_signal_specs() -> list[dict[str, str]]: return specs +def summarize_user_base_signals( + latest_by_install: dict[str, dict[str, Any]], + *, + now: datetime | None = None, + window: timedelta = timedelta(days=7), +) -> dict[str, Any]: + current_time = now or datetime.now(timezone.utc) + active_rows = [ + row + for row in latest_by_install.values() + if current_time - parse_received_at(str(row["received_at"])) <= window + ] + + schema_versions: Counter[str] = Counter() + categories: dict[str, Counter[str]] = { + field: Counter() for field, _ in USER_BASE_CATEGORY_FIELDS + } + boolean_signals = { + field: {"field": field, "label": label, "installs": 0} + for field, label in USER_BASE_BOOL_FIELDS + } + count_signals = { + field: {"field": field, "label": label, "installs": 0, "total": 0} + for field, label in USER_BASE_COUNT_FIELDS + } + + for row in active_rows: + schema_version = parse_optional_nonnegative_int(row.get("schema_version")) + schema_versions[str(schema_version or "legacy")] += 1 + for field, _ in USER_BASE_CATEGORY_FIELDS: + value = str(row.get(field) or "legacy_unknown").strip() or "legacy_unknown" + categories[field][value] += 1 + for field, _ in USER_BASE_BOOL_FIELDS: + if parse_optional_bool(row.get(field)): + boolean_signals[field]["installs"] += 1 + for field, _ in USER_BASE_COUNT_FIELDS: + value = parse_optional_nonnegative_int(row.get(field)) + if value > 0: + count_signals[field]["installs"] += 1 + count_signals[field]["total"] += value + + return { + "window": "7d", + "active_installs": len(active_rows), + "schema_versions": counter_entries(schema_versions, "version"), + "category_signals": [ + { + "field": field, + "label": label, + "buckets": counter_entries(categories[field], "bucket"), + } + for field, label in USER_BASE_CATEGORY_FIELDS + ], + "boolean_signals": list(boolean_signals.values()), + "count_signals": list(count_signals.values()), + } + + def summarize_target_version_coverage( latest_by_install: dict[str, dict[str, Any]], published_versions: set[str], @@ -1850,6 +1940,10 @@ def summarize_rows( "installs": len(mock_fleet_installs), }, "latest_install_windows": latest_install_windows, + "user_base_signals_7d": summarize_user_base_signals( + latest_by_install, + now=current_time, + ), "deep_signal_sources_7d": summarize_deep_signal_sources( latest_by_install, published_versions, @@ -1994,6 +2088,40 @@ def format_text(summary: dict[str, Any], repo: str, since_days: int) -> str: else: lines.append(" - none") + user_base = summary.get("user_base_signals_7d") + if user_base: + lines.extend( + [ + "", + "User-base lifecycle and outcomes (7d):", + f"- active installs: {user_base['active_installs']}", + "- payload schema coverage:", + ] + ) + lines.extend( + f" - {entry['version']}: {entry['installs']}" + for entry in user_base.get("schema_versions", []) + ) + lines.append("- lifecycle and audience buckets:") + for signal in user_base.get("category_signals", []): + buckets = ", ".join( + f"{entry['bucket']} {entry['installs']}" + for entry in signal.get("buckets", []) + ) + lines.append(f" - {signal['label']}: {buckets or 'none'}") + lines.append("- current/observed posture:") + for signal in user_base.get("boolean_signals", []): + lines.append(f" - {signal['label']}: {signal['installs']} installs") + lines.append("- aggregate outcomes:") + for signal in user_base.get("count_signals", []): + lines.append( + f" - {signal['label']}: {signal['total']} across {signal['installs']} installs" + ) + lines.append( + "- interpretation: known install age begins when schema v2 lifecycle tracking is first initialized; " + "older upgraded installs are therefore lower bounds" + ) + pulse_loop = summary.get("pulse_intelligence_value_loop_7d") if pulse_loop: lines.extend( diff --git a/scripts/tests/test_telemetry_adoption_report.py b/scripts/tests/test_telemetry_adoption_report.py index 8dba19302..02a208215 100644 --- a/scripts/tests/test_telemetry_adoption_report.py +++ b/scripts/tests/test_telemetry_adoption_report.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path +import gzip import json import subprocess import sys @@ -36,15 +37,26 @@ class TelemetryAdoptionReportTest(unittest.TestCase): {"install_id": "b", "received_at": "2026-07-16 00:00:00"}, ] stdout = "\n".join([json.dumps({"db_stats": db_stats}), *(json.dumps(row) for row in rows), ""]) - completed = subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout, stderr="") + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=gzip.compress(stdout.encode("utf-8")), + stderr=b"", + ) with mock.patch.object(report.subprocess, "run", return_value=completed) as run_mock: result = report.fetch_rows_remote("pulse-license", "/opt/licenses.sqlite", 30) self.assertEqual(result, {"db_stats": db_stats, "rows": rows}) - remote_script = run_mock.call_args.kwargs["input"] + remote_script = run_mock.call_args.kwargs["input"].decode("utf-8") self.assertNotIn("fetchall", remote_script) + self.assertIn("received_at >= datetime('now', ?)", remote_script) def test_fetch_rows_remote_rejects_empty_response(self) -> None: - completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="\n", stderr="") + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=gzip.compress(b"\n"), + stderr=b"", + ) with mock.patch.object(report.subprocess, "run", return_value=completed): with self.assertRaisesRegex(RuntimeError, "empty response"): report.fetch_rows_remote("pulse-license", "/opt/licenses.sqlite", 30) @@ -513,7 +525,10 @@ class TelemetryAdoptionReportTest(unittest.TestCase): entry["key"]: entry for entry in summary["pulse_intelligence_outcome_cohorts"]["cohorts"] } - self.assertEqual(cohorts["external_agent_used_30d"]["label"], "External agent/MCP used 30d") + self.assertEqual( + cohorts["external_agent_used_30d"]["label"], + "Capability API/MCP adapter used 30d", + ) self.assertEqual(cohorts["external_agent_used_30d"]["installs"], 1) self.assertEqual(cohorts["external_agent_used_30d"]["paid_latest"], 1) self.assertEqual(cohorts["external_agent_used_30d"]["observed_signal_free_starts"], 1) @@ -1322,6 +1337,103 @@ class TelemetryAdoptionReportTest(unittest.TestCase): {"enabled": False, "rows": 0, "installs": 0}, ) + def test_summarize_user_base_signals_uses_latest_active_install_rows(self) -> None: + now = datetime(2026, 7, 23, 12, tzinfo=timezone.utc) + summary = report.summarize_user_base_signals( + { + "active-v2": { + "received_at": "2026-07-23 10:00:00", + "schema_version": 2, + "deployment_method": "docker_compose", + "known_install_age_bucket": "1_7d", + "activation_stage": "outcome_observed", + "time_to_first_monitored_resource_bucket": "under_15m", + "estate_size_bucket": "11_50", + "auth_configured": 1, + "monitoring_active": 1, + "outcome_observed_30d": 1, + "configured_connections": 3, + "alerts_fired_30d": 4, + "notification_deliveries_7d": 2, + }, + "active-legacy": { + "received_at": "2026-07-22 10:00:00", + }, + "stale": { + "received_at": "2026-07-01 10:00:00", + "schema_version": 2, + "configured_connections": 99, + }, + }, + now=now, + ) + + self.assertEqual(summary["active_installs"], 2) + self.assertEqual( + summary["schema_versions"], + [{"version": "2", "installs": 1}, {"version": "legacy", "installs": 1}], + ) + deployment = next( + item for item in summary["category_signals"] if item["field"] == "deployment_method" + ) + self.assertEqual( + deployment["buckets"], + [{"bucket": "docker_compose", "installs": 1}, {"bucket": "legacy_unknown", "installs": 1}], + ) + configured = next( + item for item in summary["count_signals"] if item["field"] == "configured_connections" + ) + self.assertEqual(configured, { + "field": "configured_connections", + "label": "Configured connections", + "installs": 1, + "total": 3, + }) + + def test_format_text_includes_user_base_privacy_bounded_signals(self) -> None: + rendered = report.format_text( + { + "db_stats": {}, + "latest_install_windows": { + label: { + "active_installs": 0, + "published_versions": [], + "non_release_versions": [], + "platforms": [], + "adoption_counts": [], + "feature_enabled_installs": [], + } + for label, _ in report.DEFAULT_LATEST_INSTALL_WINDOWS + }, + "user_base_signals_7d": { + "active_installs": 2, + "schema_versions": [{"version": "2", "installs": 2}], + "category_signals": [{ + "field": "activation_stage", + "label": "Highest observed activation stage", + "buckets": [{"bucket": "monitoring", "installs": 2}], + }], + "boolean_signals": [{ + "field": "monitoring_active", + "label": "Monitoring currently active", + "installs": 2, + }], + "count_signals": [{ + "field": "alerts_resolved_30d", + "label": "Alerts resolved (30d)", + "installs": 1, + "total": 4, + }], + }, + }, + "rcourtman/Pulse", + 7, + ) + self.assertIn("User-base lifecycle and outcomes (7d):", rendered) + self.assertIn("Highest observed activation stage: monitoring 2", rendered) + self.assertIn("Alerts resolved (30d): 4 across 1 installs", rendered) + self.assertIn("older upgraded installs are therefore lower bounds", rendered) + def test_format_text_includes_latest_install_windows(self) -> None: summary = { "db_stats": { diff --git a/scripts/tests/test_telemetry_schema_parity.py b/scripts/tests/test_telemetry_schema_parity.py new file mode 100644 index 000000000..bc53d3bdc --- /dev/null +++ b/scripts/tests/test_telemetry_schema_parity.py @@ -0,0 +1,69 @@ +import importlib.util +from pathlib import Path +import unittest + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "check_telemetry_schema_parity.py" +SPEC = importlib.util.spec_from_file_location("check_telemetry_schema_parity", SCRIPT_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class TelemetrySchemaParityTest(unittest.TestCase): + def test_matching_contract(self): + public = ''' +type Ping struct { + SchemaVersion int `json:"schema_version"` + Active bool `json:"active"` +} +''' + receiver = ''' +var ping struct { + SchemaVersion int `json:"schema_version"` + Active bool `json:"active"` + LicenseTier string `json:"license_tier"` + APITokens int `json:"api_tokens"` +} +''' + frontend = ''' +export interface TelemetryPingPreview { + schema_version: number; + active: boolean; +} +''' + self.assertEqual(MODULE.parity_errors(public, receiver, frontend), []) + + def test_missing_and_mismatched_fields_fail(self): + public = ''' +type Ping struct { + SchemaVersion int `json:"schema_version"` + Active bool `json:"active"` + Count int `json:"count"` +} +''' + receiver = ''' +var ping struct { + SchemaVersion string `json:"schema_version"` + Active int `json:"active"` + Extra bool `json:"extra"` +} +''' + frontend = ''' +export interface TelemetryPingPreview { + schema_version: string; + active: boolean; + unexpected: number; +} +''' + errors = MODULE.parity_errors(public, receiver, frontend) + self.assertTrue(any("count" in error for error in errors)) + self.assertTrue(any("extra" in error for error in errors)) + self.assertTrue(any("schema_version" in error and "active" in error for error in errors)) + self.assertTrue(any("frontend preview missing" in error and "count" in error for error in errors)) + self.assertTrue(any("frontend preview fields absent" in error and "unexpected" in error for error in errors)) + self.assertTrue(any("frontend field type mismatches" in error and "schema_version" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()