Add privacy-safe telemetry lifecycle and outcome signals

This commit is contained in:
rcourtman
2026-07-23 01:10:38 +01:00
parent fe1e8dec24
commit dbca44937b
39 changed files with 1470 additions and 133 deletions
+1
View File
@@ -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 && \
+1
View File
@@ -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
```
+1
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+26 -1
View File
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -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
+26 -1
View File
@@ -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.
@@ -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', () => {
+22
View File
@@ -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 {
@@ -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');
@@ -13,6 +13,8 @@ const flushAsync = async () => {
const buildTelemetryPreviewPayload = (
overrides: Partial<TelemetryPingPreview> = {},
): 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,
});
@@ -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(
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
@@ -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');
+1
View File
@@ -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
@@ -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:",
+72 -24
View File
@@ -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
+22
View File
@@ -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,
+13
View File
@@ -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()
+37
View File
@@ -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()
+55
View File
@@ -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()
+325 -34
View File
@@ -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
}
+145 -11
View File
@@ -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)
+20 -2
View File
@@ -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
+167
View File
@@ -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())
@@ -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 {
@@ -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 {
+159 -31
View File
@@ -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(
+116 -4
View File
@@ -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": {
@@ -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()