Count successful history reads beyond seven days (#2996)

Measure longer-history use before changing free and paid features.
History and chart APIs count successfully written GET responses only
when they return a metric point older than seven days. Empty, denied,
failed and mock responses do not count. Cached responses and automatic
refreshes do count.

Only bounded daily totals stay locally. Schema v19 exports their rolling
sum with no request, resource, metric, tenant or customer content. The
payload preview and privacy disclosure match. The adoption report
separates schema coverage from zero use and uses each reporting ID's
latest count.

Validation: affected Go packages, receiver telemetry tests, 44 report
tests, schema parity, docs mirror and governance checks. Playwright
exercised /settings/system-general and /docs/PRIVACY, including the
visible counter at 1280x800 and 390x844, refresh and ID rotation.
Receiver deployment and upgraded-client adoption are still needed before
interpreting live usage.
This commit is contained in:
rcourtman
2026-10-16 19:42:27 +01:00
committed by GitHub
31 changed files with 960 additions and 27 deletions
+20 -2
View File
@@ -40,7 +40,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Field | Example | Purpose |
|-------|---------|---------|
| Schema version | `18` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `19` | 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 |
@@ -259,6 +259,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content |
| Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details |
| Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors |
| Successful history reads over 7d 30d (`successful_history_reads_over_7d_30d`) | `5` | Count successful GET responses for a history range longer than seven days that actually return a numeric metric point older than seven days. Includes successful cache hits, but excludes empty/recent-only results, denied requests, failed writes, cancelled requests, HEAD requests and synthetic data. Only a rolling count leaves the install, with no selected range, timestamp, metric value, resource, tenant, user or request identity |
| Workload history preview sessions 30d | `18` | Count browser sessions that successfully displayed a populated workload history preview, deduplicated locally once per session without sending a guest, user, route, timing, or browser identifier |
| Workload history scrub sessions 30d | `9` | Count browser sessions that inspected a point on a workload history graph, deduplicated locally once per session without sending coordinates, values, guests, or browser identity |
| Workload history range change sessions 30d | `6` | Count browser sessions that changed the workload history range, deduplicated locally once per session without sending the selected range or browser identity |
@@ -311,7 +312,7 @@ occurrence exists, leaving no trustworthy fired-alert denominator. Detected
flapping episodes are not reported because their diagnostic event path may be
dropped under pressure. Configuration adoption is reported instead.
The current telemetry contract is schema version 18. Schema v16 adds four
The current telemetry contract is schema version 19. Schema v16 adds four
workload-history adoption counts. The browser
sends only one closed milestone name to the local Pulse server and deduplicates
each milestone once per browser session. Pulse stores bounded UTC-day counts
@@ -337,6 +338,22 @@ flag for whether it offered a newer release. They exist because a failed check
never shows up in the update counters, which only see updates that were
applied. No version, release URL, response, or error text is sent.
Schema v19 adds `successful_history_reads_over_7d_30d`. The server counts
successfully written metric-history and chart responses containing a metric
point older than seven days. Selecting a long range alone does not count.
Empty results, denied requests, failed writes and mock data do not count.
Pulse retains bounded UTC-day totals locally and sends only their rolling
30-day sum, including the UTC day containing the cutoff. The day buckets
are not exported. Requests and auto-refreshes count as reads, not people
or proof of a human viewing a chart.
No customer identity, request URL, selected range, metric timestamp, metric
value or infrastructure identifier is retained in this tally or sent. Older
schemas cannot report this signal. Measurement starts after upgrading, so
historical use cannot be reconstructed and an initial zero does not represent
a full 30-day observation. Telemetry opt-out, installation-ID rotation,
account-linking restrictions and 90-day receiver retention are unchanged.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@@ -389,6 +406,7 @@ Every change to the payload bumps the schema version, is listed here with its da
| Schema | Date | Change |
|--------|------|--------|
| 19 | 2026-10-16 | Content-free rolling count of successful metric-history responses that return data older than seven days |
| 18 | 2026-09-27 | Effective update channel, closed outcome of the last update check on that channel, and whether it offered a newer release |
| 17 | 2026-09-02 | Closed Patrol provider class, effective Patrol autonomy level, coarse 30-day Patrol token buckets, and per-outcome investigation counts |
| 16 | 2026-08-30 | Four content-free workload-history adoption counters, each counted at most once per browser session |
+31 -1
View File
@@ -5929,7 +5929,7 @@
"status": "target-met",
"completion": {
"state": "complete",
"summary": "Security, identity, and privacy is at the current governed floor: privacy disclosures, telemetry controls, RBAC boundaries, and shared token/auth settings surfaces now route through explicit subsystem ownership with exercised proof across the remaining shared token and auth boundaries.",
"summary": "Security, identity, and privacy remains at the governed floor. Schema v19 adds only a content-free successful extended-history HTTP read count with private daily persistence, exact Preview disclosure, compatible receiver intake and explicit report coverage. Source and isolated browser proofs pass. Receiver deployment and client release adoption remain separate operational boundaries.",
"tracking": []
},
"blockers": [],
@@ -5988,6 +5988,11 @@
"path": "internal/api/api_token_org_scope_integration_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/chartapi/history_activity_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/org_handlers_test.go",
@@ -6013,15 +6018,40 @@
"path": "internal/api/system_settings_telemetry_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/telemetry_history_reads_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/config/history_read_activity_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/telemetry/telemetry.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "scripts/tests/test_telemetry_adoption_report.py",
"kind": "file"
},
{
"repo": "pulse",
"path": "SECURITY.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "tests/integration/tests/19-telemetry-disclosure.spec.ts",
"kind": "file"
},
{
"repo": "pulse-pro",
"path": "license-server/telemetry_test.go",
"kind": "file"
}
]
},
@@ -9879,3 +9879,12 @@ controls establish no native guest restart, thaw or prior-workload restoration.
existing update pipeline. They add no agent enrollment, command, install or
reporting path, and agent continuity stays judged by the same update readiness
verdict the apply route already computes for its target version.
### Extended history observation preserves existing read authority
The shared chart/history response writer now observes successful extended
history delivery for the security-privacy schema-v19 aggregate. It adds no
query, resource, tenant, credential or action authority. The observer receives
only a successful-read marker after serialization/write, and existing API
entitlement checks remain before that boundary. Read semantics remain owned
by api-contracts and telemetry retention/disclosure by security-privacy.
@@ -12262,3 +12262,17 @@ The accepted upstream `diagnostics_token_visibility_test.go` controls and API
reference route checks cover caller filtering and the documented authority.
Final-source checks must retain this upstream behavior beside the reviewed
reporting/help repairs; source proof is not installed credential recovery.
### Successful extended history HTTP reads
`chartapi.Service.WriteHistoryJSON` and `WriteHistoryPayload` share one
post-write observation boundary for metric history and the main, workload,
infrastructure and storage chart handlers, including cached bytes. Existing
response bodies, range/entitlement checks and failure behavior remain the API
contract. Only GET responses requested beyond seven days that contain a real
returned numeric metric point strictly older than seven days qualify. Long
empty windows, metadata timestamps, failed encoding/writes, cancellation,
HEAD and mock responses cannot increment the tally. Mock and live cache keys
are separated. The optional observer receives no request or content.
`history_activity_test.go` and `telemetry_history_reads_test.go` exercise this
boundary. Schema v19 count semantics and retention belong to security-privacy.
@@ -3692,3 +3692,15 @@ Mobile settings gate and Alerts push panel carry no upgrade prompt. The
`relay` feature is labelled "Pulse Relay (Mobile Connection)" because it never
provided remote access to the web UI. Pulse Mobile retires on 31 March 2027;
until then licensed instances keep pairing and push unchanged.
### Extended history measurement does not establish customer identity
The license server accepts schema-v19 `successful_history_reads_over_7d_30d`
as a nonnegative aggregate integer, migrates storage without rewriting older
rows, and resets pre-v19 supplied values to zero. The public reporter treats
older schemas or missing receiver columns as unmeasured. Current coarse
`paid_license` posture is not a paying-subscription census and must not be
joined to billing accounts. This adds no price, entitlement, refund or billing
behavior. Receiver intake and migration tests pin the additive schema boundary.
Deploying the compatible receiver and release adoption remain separate from
source verification. Count and disclosure semantics belong to security-privacy.
@@ -15,6 +15,16 @@
```
## Purpose
### Schema-v19 release disclosure follows receiver compatibility
`docs/releases/TELEMETRY_SCHEMA_V19.md` carries the extended-history telemetry notice for
inclusion in the first release containing schema v19. Source landing neither
deploys the compatible license-server receiver nor releases upgraded clients.
The reporter exposes missing receiver columns and pre-v19 clients as unknown
coverage, never as evidence of zero use. Existing telemetry opt-out, ID rotation
and retention are unchanged. Payload details belong to security-privacy.
### TrueNAS SCALE removal retires every owned middleware boot task before recovery erasure
@@ -3259,6 +3259,7 @@
"internal/api/alerting/notifications_test.go",
"internal/api/audit_handlers_test.go",
"internal/api/availability_handlers_test.go",
"internal/api/chartapi/history_activity_test.go",
"internal/api/contract_test.go",
"internal/api/docker_agents_report_size_test.go",
"internal/api/docker_container_action_executor_test.go",
@@ -3270,6 +3271,7 @@
"internal/api/metadata_handlers_test.go",
"internal/api/patrol_autopilot_test.go",
"internal/api/runtime_inventory_sources_test.go",
"internal/api/telemetry_history_reads_test.go",
"internal/api/truenas_disk_history_integration_test.go",
"pulse-enterprise:test/extensions_contract_test.go"
]
@@ -4747,6 +4749,19 @@
"scripts/release_control/release_note_visuals_test.py"
]
},
{
"id": "telemetry-schema-v19-release-disclosure",
"label": "telemetry schema v19 release disclosure proof",
"match_prefixes": [],
"match_files": [
"docs/releases/TELEMETRY_SCHEMA_V19.md"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/telemetry/telemetry_test.go"
]
},
{
"id": "release-promotion-metadata-runtime",
"label": "release promotion and artifact dispatch proof",
@@ -7618,6 +7633,7 @@
"internal/cloudcp/auth/magiclink.go",
"internal/cloudcp/auth/magiclink_store.go",
"internal/config/config.go",
"internal/config/history_read_activity.go",
"internal/config/watcher.go",
"internal/crypto/crypto.go",
"internal/logging/logging.go",
@@ -7636,6 +7652,7 @@
"pkg/extensions/audit_admin.go",
"pkg/server/server.go",
"pkg/server/service_health.go",
"pkg/server/telemetry_licensed_features.go",
"pkg/server/telemetry_pulse_intelligence.go",
"pkg/tlsutil/fingerprint.go",
"scripts/telemetry_adoption_report.py",
@@ -7667,10 +7684,12 @@
"match_files": [
"docs/PRIVACY.md",
"frontend-modern/public/docs/PRIVACY.md",
"internal/config/history_read_activity.go",
"internal/telemetry/service_health.go",
"internal/telemetry/telemetry.go",
"pkg/server/server.go",
"pkg/server/service_health.go",
"pkg/server/telemetry_licensed_features.go",
"pkg/server/telemetry_pulse_intelligence.go",
"scripts/telemetry_adoption_report.py",
"SECURITY.md"
@@ -7680,11 +7699,15 @@
"exact_files": [
"frontend-modern/src/stores/__tests__/systemSettings.test.ts",
"internal/api/system_settings_telemetry_test.go",
"internal/api/telemetry_history_reads_test.go",
"internal/config/history_read_activity_test.go",
"internal/telemetry/service_health_test.go",
"internal/telemetry/telemetry_test.go",
"pkg/server/service_health_test.go",
"pkg/server/telemetry_licensed_features_test.go",
"pkg/server/telemetry_pulse_intelligence_test.go",
"scripts/tests/test_telemetry_adoption_report.py"
"scripts/tests/test_telemetry_adoption_report.py",
"tests/integration/tests/19-telemetry-disclosure.spec.ts"
]
},
{
@@ -2843,9 +2843,40 @@ actor, and every audit row read stay on the install.
`TestWithAuditReadActivity_RecordIsContentFree` and
`TestRecordAuditReadActivity_RejectsUnknownActivity` pin both properties.
### Extended history demand remains a content-free successful-read count
Schema v19 adds only `successful_history_reads_over_7d_30d`. Authenticated
metric-history and chart HTTP handlers observe successfully written GET
responses only when the requested duration exceeds seven days and an actual
returned metric point is strictly older than seven days. Empty/denied/failed,
cancelled and mock responses are excluded. Cache hits count as reads, as do
automatic refreshes. Background queries and range selection alone do not count.
The router observer receives no request, tenant, actor, resource, range or
metric value. Root persistence retains bounded UTC-day counts with private
permissions and atomic replacement. Restart preserves them. The snapshot
exports only their rolling 30-day sum including the cutoff UTC day, never the
day buckets. Opt-out, rotating pseudonymous ID, account-join prohibition and
receiver retention are unchanged. This is observed HTTP delivery, not proof
that a person saw a chart or would pay for it.
The receiver migrates the integer column, clamps negatives, and discards this
field from pre-v19 payloads. The report contributes only each reporting ID's
latest rolling count, splits coarse nonfree/free/unknown posture, excludes
nonproduction and inactive latest states, and exposes v19 coverage separately
from unknown old/missing measurements. Neither an initial zero nor an older
schema establishes 30 days of nonuse. No billing identity join is introduced.
Proof: `history_read_activity_test.go`, `chartapi/history_activity_test.go`,
`telemetry_history_reads_test.go`, telemetry Preview/schema-disclosure tests,
server snapshot tests, receiver migration/intake tests, report coverage tests,
and `tests/integration/tests/19-telemetry-disclosure.spec.ts`. Canonical and
shipped privacy documents must remain byte-identical. Pending release notes
carry the schema notice for the first release that includes this field.
### Telemetry ingestion matches the released sender while storage stays compatible
The active outbound contract is schema v18. Schema v8 added content-free
The active outbound contract is schema v19. Schema v8 added content-free
approved-action refusal counters for target change, prerequisite failure, and
invalid typed contract so agent-side pre-mutation failures no longer collapse
into `other`. Schema v9 completes that split with a content-free `uncoded`
@@ -6932,3 +6932,12 @@ update pipeline's existing backup of the installed binary before replacing it
and its existing history-entry rollback. The data directory, metrics and
configuration stores, backups, snapshots and recovery points are untouched,
and no new backup, retention or restore path is added.
### Extended history observation preserves existing read authority
The shared chart/history response writer now observes successful extended
history delivery for the security-privacy schema-v19 aggregate. It adds no
query, resource, tenant, credential or action authority. The observer receives
only a successful-read marker after serialization/write, and existing API
entitlement checks remain before that boundary. Read semantics remain owned
by api-contracts and telemetry retention/disclosure by security-privacy.
+14
View File
@@ -0,0 +1,14 @@
# Pending release notes
## Telemetry
When telemetry is enabled, schema v19 reports `successful_history_reads_over_7d_30d`,
an aggregate count of successful
history reads that return metric points older than seven days. This helps
measure actual longer-history use before changing free and paid features.
It sends no resource names, metric values, timestamps, selected ranges or
customer identifiers. Existing opt-out and retention settings are unchanged.
See [Privacy](../PRIVACY.md) for the full field contract.
Include this notice in the first release carrying schema v19. Deployment of
the compatible telemetry receiver must precede interpreting this signal.
@@ -0,0 +1,41 @@
{
"version": 1,
"base_sha": "6eafe916da44c4a4f232870d2924a78d417a4f0b",
"verified_at": "2026-10-16T18:03:51Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/settings.ts"
],
"content_sha256": {
"frontend-modern/src/api/settings.ts": "77f8775e16371b52f0e55cb5107d3e68732b6616ce2dcdd2fb12f5b587a9f973"
},
"routes": [
"/settings/system-general",
"/docs/PRIVACY"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 1280,
"height": 720
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private task-owned current embedded build on pulse-dev port17857 with mock fixtures and outbound telemetry disabled. Preview closed then open, schema19 exact field present at zero. Mock data does not create qualifying history reads.",
"Desktop and narrow Preview pixels inspected with successful_history_reads_over_7d_30d visible. JSON remains horizontally scrollable and document has no horizontal overflow at390px.",
"Shipped privacy document opens separately and discloses the field, successful-point criterion, excluded responses, daily aggregation, coverage limits and unchanged privacy controls."
],
"interactions": [
"Focused Preview payload and activated it by keyboard. Refreshed real local Preview and verified count remains zero.",
"Opened Full details in a new tab, scrolled to schema19 explanation, inspected pixels and closed the tab.",
"Resized to390x844, accepted Reset ID confirmation, verified rotating identity changes while count remains zero, scrolled actual preview container to the new field and inspected pixels.",
"Both Playwright disclosure tests passed after the final test helper change. Managed backend was stopped by the runner."
]
}
+20 -2
View File
@@ -40,7 +40,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Field | Example | Purpose |
|-------|---------|---------|
| Schema version | `18` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `19` | 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 |
@@ -259,6 +259,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content |
| Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details |
| Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors |
| Successful history reads over 7d 30d (`successful_history_reads_over_7d_30d`) | `5` | Count successful GET responses for a history range longer than seven days that actually return a numeric metric point older than seven days. Includes successful cache hits, but excludes empty/recent-only results, denied requests, failed writes, cancelled requests, HEAD requests and synthetic data. Only a rolling count leaves the install, with no selected range, timestamp, metric value, resource, tenant, user or request identity |
| Workload history preview sessions 30d | `18` | Count browser sessions that successfully displayed a populated workload history preview, deduplicated locally once per session without sending a guest, user, route, timing, or browser identifier |
| Workload history scrub sessions 30d | `9` | Count browser sessions that inspected a point on a workload history graph, deduplicated locally once per session without sending coordinates, values, guests, or browser identity |
| Workload history range change sessions 30d | `6` | Count browser sessions that changed the workload history range, deduplicated locally once per session without sending the selected range or browser identity |
@@ -311,7 +312,7 @@ occurrence exists, leaving no trustworthy fired-alert denominator. Detected
flapping episodes are not reported because their diagnostic event path may be
dropped under pressure. Configuration adoption is reported instead.
The current telemetry contract is schema version 18. Schema v16 adds four
The current telemetry contract is schema version 19. Schema v16 adds four
workload-history adoption counts. The browser
sends only one closed milestone name to the local Pulse server and deduplicates
each milestone once per browser session. Pulse stores bounded UTC-day counts
@@ -337,6 +338,22 @@ flag for whether it offered a newer release. They exist because a failed check
never shows up in the update counters, which only see updates that were
applied. No version, release URL, response, or error text is sent.
Schema v19 adds `successful_history_reads_over_7d_30d`. The server counts
successfully written metric-history and chart responses containing a metric
point older than seven days. Selecting a long range alone does not count.
Empty results, denied requests, failed writes and mock data do not count.
Pulse retains bounded UTC-day totals locally and sends only their rolling
30-day sum, including the UTC day containing the cutoff. The day buckets
are not exported. Requests and auto-refreshes count as reads, not people
or proof of a human viewing a chart.
No customer identity, request URL, selected range, metric timestamp, metric
value or infrastructure identifier is retained in this tally or sent. Older
schemas cannot report this signal. Measurement starts after upgrading, so
historical use cannot be reconstructed and an initial zero does not represent
a full 30-day observation. Telemetry opt-out, installation-ID rotation,
account-linking restrictions and 90-day receiver retention are unchanged.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@@ -389,6 +406,7 @@ Every change to the payload bumps the schema version, is listed here with its da
| Schema | Date | Change |
|--------|------|--------|
| 19 | 2026-10-16 | Content-free rolling count of successful metric-history responses that return data older than seven days |
| 18 | 2026-09-27 | Effective update channel, closed outcome of the last update check on that channel, and whether it offered a newer release |
| 17 | 2026-09-02 | Closed Patrol provider class, effective Patrol autonomy level, coarse 30-day Patrol token buckets, and per-outcome investigation counts |
| 16 | 2026-08-30 | Four content-free workload-history adoption counters, each counted at most once per browser session |
@@ -7,7 +7,8 @@ vi.mock('@/utils/apiClient', () => ({
}));
const mockTelemetryPreviewPayload = {
schema_version: 4,
schema_version: 19,
successful_history_reads_over_7d_30d: 7,
sent_at: '2026-07-23T08:30:00Z',
install_id: 'preview-install-id',
version: '6.0.0',
@@ -313,6 +314,7 @@ describe('SettingsAPI', () => {
const result = await SettingsAPI.getTelemetryPreview();
expect(apiFetchJSON).toHaveBeenCalledWith('/api/system/settings/telemetry-preview');
expect(result.payload.successful_history_reads_over_7d_30d).toBe(7);
expect(result).toEqual(mockPreview);
});
});
+1
View File
@@ -89,6 +89,7 @@ export interface TelemetryPingPreview {
service_health_previous_healthy: boolean;
node_test_attempts_30d: number;
node_test_failures_30d: number;
successful_history_reads_over_7d_30d: number;
workload_history_preview_sessions_30d: number;
workload_history_scrub_sessions_30d: number;
workload_history_range_change_sessions_30d: number;
@@ -13,7 +13,8 @@ const flushAsync = async () => {
const buildTelemetryPreviewPayload = (
overrides: Partial<TelemetryPingPreview> = {},
): TelemetryPingPreview => ({
schema_version: 4,
schema_version: 19,
successful_history_reads_over_7d_30d: 0,
sent_at: '2026-07-23T08:30:00Z',
install_id: 'preview-install-id',
version: '6.0.0',
+125
View File
@@ -0,0 +1,125 @@
package chartapi
import (
"bytes"
"encoding/json"
"io"
"net/http"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
)
// ExtendedHistoryReadObserver receives only the fact that a qualifying response
// was written. No request, tenant, resource, metric, range or actor is passed.
type ExtendedHistoryReadObserver interface {
RecordSuccessfulExtendedHistoryRead()
}
func historyDataModeCacheKey(key string) string {
if mock.IsMockEnabled() {
return key + "|mock"
}
return key
}
// WriteHistoryJSON preserves the JSON encoder's newline while sharing the same
// successful-response observation boundary as cached chart payloads.
func (r *Service) WriteHistoryJSON(w http.ResponseWriter, req *http.Request, value any, duration time.Duration) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
return r.WriteHistoryPayload(w, req, append(payload, '\n'), duration)
}
// WriteHistoryPayload counts successful GET responses, including cache hits,
// only when an actual returned metric is older than seven days. Requesting a
// long range, empty results, HEAD, cancellation, mock data and failed writes
// cannot create usage. HTTP completion does not prove a person viewed a chart.
func (r *Service) WriteHistoryPayload(w http.ResponseWriter, req *http.Request, payload []byte, duration time.Duration) error {
observedAt := time.Now().UTC()
n, err := w.Write(payload)
if err != nil {
return err
}
if n != len(payload) {
return io.ErrShortWrite
}
if req.Method != http.MethodGet || req.Context().Err() != nil || duration <= 7*24*time.Hour || mock.IsMockEnabled() {
return nil
}
observer, ok := r.resolver.(ExtendedHistoryReadObserver)
if !ok {
return nil
}
if containsExtendedHistoryPayload(payload, observedAt.Add(-7*24*time.Hour).UnixMilli()) {
observer.RecordSuccessfulExtendedHistoryRead()
}
return nil
}
func containsExtendedHistoryPayload(payload []byte, cutoff int64) bool {
if !json.Valid(payload) {
return false
}
decoder := json.NewDecoder(bytes.NewReader(payload))
if token, _ := decoder.Token(); token != json.Delim('{') {
return false
}
for decoder.More() {
key, _ := decoder.Token()
switch key {
case "points", "metrics", "data", "nodeData", "storageData", "dockerData", "dockerHostData", "agentData", "pools", "disks":
if found, _ := scanExtendedHistoryPoint(decoder, cutoff); found {
return true
}
default:
var discard json.RawMessage
if decoder.Decode(&discard) != nil {
return false
}
}
}
return false
}
// Scan only the metric containers in the known response contracts. Streaming
// avoids retaining a second copy of a large decoded chart and stops at the
// first qualifying point. Metadata and requested window bounds cannot count.
func scanExtendedHistoryPoint(decoder *json.Decoder, cutoff int64) (bool, any) {
token, err := decoder.Token()
if err != nil {
return false, nil
}
switch token {
case json.Delim('{'):
var timestamp float64
var hasValue bool
for decoder.More() {
key, _ := decoder.Token()
found, scalar := scanExtendedHistoryPoint(decoder, cutoff)
if found {
return true, nil
}
if number, ok := scalar.(float64); ok {
if key == "timestamp" {
timestamp = number
} else if key == "value" {
hasValue = true
}
}
}
_, _ = decoder.Token()
return hasValue && timestamp > 0 && timestamp < float64(cutoff), nil
case json.Delim('['):
for decoder.More() {
if found, _ := scanExtendedHistoryPoint(decoder, cutoff); found {
return true, nil
}
}
_, _ = decoder.Token()
return false, nil
}
return false, token
}
@@ -0,0 +1,137 @@
package chartapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/testutil"
)
type historyReadResolver struct {
monitor *monitoring.Monitor
reads int
}
func (r *historyReadResolver) MonitorForContext(context.Context) *monitoring.Monitor {
return r.monitor
}
func (r *historyReadResolver) RecordSuccessfulExtendedHistoryRead() { r.reads++ }
type failedHistoryWriter struct{ short bool }
func (failedHistoryWriter) Header() http.Header { return http.Header{} }
func (failedHistoryWriter) WriteHeader(int) {}
func (w failedHistoryWriter) Write(p []byte) (int, error) {
if w.short {
return len(p) - 1, nil
}
return 0, errors.New("closed connection")
}
func TestExtendedHistoryReadRequiresActualOldMetric(t *testing.T) {
cutoff := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC).UnixMilli()
for _, tc := range []struct {
name, body string
want bool
}{
{"old metric", fmt.Sprintf(`{"points":[{"timestamp":%d,"value":0}]}`, cutoff-1), true},
{"exact boundary", fmt.Sprintf(`{"points":[{"timestamp":%d,"value":1}]}`, cutoff), false},
{"recent only", fmt.Sprintf(`{"points":[{"timestamp":%d,"value":1}]}`, cutoff+1), false},
{"empty long window", `{"range":"90d","start":1,"stats":{"oldestDataTimestamp":1},"points":[]}`, false},
{"metadata is not a metric", `{"stats":{"timestamp":1,"value":2},"points":[]}`, false},
{"zero timestamp", `{"points":[{"timestamp":0,"value":2}]}`, false},
{"invalid value", `{"points":[{"timestamp":1,"value":null}]}`, false},
{"nested named resource", `{"data":{"value":{"timestamp":[{"timestamp":1,"value":2}]}}}`, true},
{"invalid JSON", `{"points":[{"timestamp":1,"value":2}]}invalid`, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := containsExtendedHistoryPayload([]byte(tc.body), cutoff); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
func TestExtendedHistoryReadIgnoresUnsuccessfulOrSyntheticResponses(t *testing.T) {
testutil.SetMockMode(t, false)
body := []byte(fmt.Sprintf(`{"points":[{"timestamp":%d,"value":1}]}`, time.Now().Add(-8*24*time.Hour).UnixMilli()))
for _, tc := range []struct {
name, method string
duration time.Duration
cancel, short, fail bool
want int
}{
{"successful GET", http.MethodGet, 30 * 24 * time.Hour, false, false, false, 1},
{"HEAD", http.MethodHead, 30 * 24 * time.Hour, false, false, false, 0},
{"seven day request", http.MethodGet, 7 * 24 * time.Hour, false, false, false, 0},
{"cancelled", http.MethodGet, 30 * 24 * time.Hour, true, false, false, 0},
{"short write", http.MethodGet, 30 * 24 * time.Hour, false, true, false, 0},
{"failed write", http.MethodGet, 30 * 24 * time.Hour, false, false, true, 0},
} {
t.Run(tc.name, func(t *testing.T) {
resolver := &historyReadResolver{}
service := NewService(resolver)
req := httptest.NewRequest(tc.method, "/history", nil)
if tc.cancel {
ctx, cancel := context.WithCancel(req.Context())
cancel()
req = req.WithContext(ctx)
}
var writer http.ResponseWriter = httptest.NewRecorder()
if tc.short || tc.fail {
writer = failedHistoryWriter{short: tc.short}
}
_ = service.WriteHistoryPayload(writer, req, body, tc.duration)
if resolver.reads != tc.want {
t.Fatalf("reads = %d, want %d", resolver.reads, tc.want)
}
})
}
resolver := &historyReadResolver{}
service := NewService(resolver)
req := httptest.NewRequest(http.MethodGet, "/history", nil)
if err := service.WriteHistoryJSON(httptest.NewRecorder(), req, map[string]any{"value": math.NaN()}, 30*24*time.Hour); err == nil {
t.Fatal("invalid JSON must fail")
}
}
func TestExtendedHistoryReadRejectsMockPayload(t *testing.T) {
testutil.SetMockMode(t, true)
resolver := &historyReadResolver{}
service := NewService(resolver)
req := httptest.NewRequest(http.MethodGet, "/history", nil)
body := []byte(`{"points":[{"timestamp":1,"value":2}]}`)
_ = service.WriteHistoryPayload(httptest.NewRecorder(), req, body, 30*24*time.Hour)
if resolver.reads != 0 {
t.Fatalf("failed encoding/mock reads counted: %d", resolver.reads)
}
}
func TestExtendedHistoryReadCountsCachedChartResponses(t *testing.T) {
testutil.SetMockMode(t, false)
monitor, state, _ := newTestMonitor(t)
syncTestResourceStore(t, monitor, state)
resolver := &historyReadResolver{monitor: monitor}
service := NewService(resolver)
body, _ := json.Marshal(map[string]any{"data": map[string]any{"resource": map[string]any{"cpu": []MetricPoint{{Timestamp: time.Now().Add(-8 * 24 * time.Hour).UnixMilli(), Value: 1}}}}})
now := time.Now()
service.chartPayloads.put(workloadChartsCachePrefix+"default|30d||", body, now.Add(time.Minute), now)
for i := 0; i < 2; i++ {
rec := httptest.NewRecorder()
service.HandleWorkloadCharts(rec, httptest.NewRequest(http.MethodGet, "/api/charts/workloads?range=30d", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
}
if resolver.reads != 2 {
t.Fatalf("successful cached responses = %d, want 2", resolver.reads)
}
}
+8 -8
View File
@@ -435,7 +435,7 @@ func (r *Service) HandleCharts(w http.ResponseWriter, req *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
if err := r.WriteHistoryJSON(w, req, response, duration); err != nil {
log.Error().Err(err).Msg("Failed to encode chart data response")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -1328,11 +1328,11 @@ func (r *Service) HandleWorkloadCharts(w http.ResponseWriter, req *http.Request)
}
cacheKey := orgID + "|" + timeRange + "|" + selectedNodeID + "|" + maxPointsRaw
cacheKey = workloadChartsCachePrefix + cacheKey
cacheKey = historyDataModeCacheKey(workloadChartsCachePrefix + cacheKey)
now := time.Now()
if body, ok := r.chartPayloads.get(cacheKey, now); ok {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(body); err != nil {
if err := r.WriteHistoryPayload(w, req, body, duration); err != nil {
log.Error().Err(err).Msg("Failed to write cached workload chart data response")
}
return
@@ -1360,7 +1360,7 @@ func (r *Service) HandleWorkloadCharts(w http.ResponseWriter, req *http.Request)
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(v.([]byte)); err != nil {
if err := r.WriteHistoryPayload(w, req, v.([]byte), duration); err != nil {
log.Error().Err(err).Msg("Failed to write workload chart data response")
}
}
@@ -1764,10 +1764,10 @@ func (r *Service) HandleInfrastructureCharts(w http.ResponseWriter, req *http.Re
}
now := time.Now()
cacheKey := infrastructureChartsCacheKey(req, timeRange, requestedMetricNames)
cacheKey := historyDataModeCacheKey(infrastructureChartsCacheKey(req, timeRange, requestedMetricNames))
if payload, ok := r.cachedInfrastructureChartsPayload(cacheKey, now); ok {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(payload); err != nil {
if err := r.WriteHistoryPayload(w, req, payload, duration); err != nil {
log.Error().Err(err).Msg("Failed to write cached infrastructure chart data response")
}
return
@@ -2030,7 +2030,7 @@ func (r *Service) HandleInfrastructureCharts(w http.ResponseWriter, req *http.Re
payload = append(payload, '\n')
r.cacheInfrastructureChartsPayload(cacheKey, payload, now)
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(payload); err != nil {
if err := r.WriteHistoryPayload(w, req, payload, duration); err != nil {
log.Error().Err(err).Msg("Failed to write infrastructure chart data response")
}
}
@@ -2205,7 +2205,7 @@ func (r *Service) HandleStorageCharts(w http.ResponseWriter, req *http.Request)
resp.Disks = disks
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp.NormalizeCollections()); err != nil {
if err := r.WriteHistoryJSON(w, req, resp.NormalizeCollections(), duration); err != nil {
log.Error().Err(err).Msg("Failed to encode storage chart data")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+5 -5
View File
@@ -7160,14 +7160,14 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
response["points"] = apiPoints
response["source"] = source
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
r.ensureChartService().WriteHistoryJSON(w, req, response, duration)
return
}
} else if apiData, source, ok := fallbackAll(); ok {
response["metrics"] = apiData
response["source"] = source
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
r.ensureChartService().WriteHistoryJSON(w, req, response, duration)
return
}
}
@@ -7223,7 +7223,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
"source": source,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
r.ensureChartService().WriteHistoryJSON(w, req, response, duration)
return
}
} else {
@@ -7243,7 +7243,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
"source": source,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
r.ensureChartService().WriteHistoryJSON(w, req, response, duration)
return
}
}
@@ -7500,7 +7500,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
if err := r.ensureChartService().WriteHistoryJSON(w, req, response, duration); err != nil {
log.Error().Err(err).Msg("Failed to encode metrics history response")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+18
View File
@@ -0,0 +1,18 @@
package api
import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rs/zerolog/log"
)
func (r routerChartMonitorResolver) RecordSuccessfulExtendedHistoryRead() {
if r.router == nil || r.router.persistence == nil || mock.IsMockEnabled() {
return
}
// This install-wide tally deliberately has no tenant or actor dimension.
if err := r.router.persistence.RecordSuccessfulExtendedHistoryRead(time.Now().UTC()); err != nil {
log.Debug().Msg("Unable to retain aggregate extended history read count")
}
}
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/testutil"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func TestMetricsHistoryTelemetryRequiresServedExtendedData(t *testing.T) {
testutil.SetMockMode(t, false)
for _, tc := range []struct {
name, query string
age time.Duration
licensed bool
want int
}{
{"old single metric", "&metric=cpu&range=30d", 8 * 24 * time.Hour, true, 1},
{"old all metrics", "&range=90d", 8 * 24 * time.Hour, true, 1},
{"recent data in long range", "&metric=cpu&range=30d", 24 * time.Hour, true, 0},
{"denied range", "&metric=cpu&range=30d", 8 * 24 * time.Hour, false, 0},
{"empty long range", "&metric=missing&range=30d", 8 * 24 * time.Hour, true, 0},
} {
t.Run(tc.name, func(t *testing.T) {
monitor, _, _ := newTestMonitor(t)
store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
store.WriteBatchSync([]metrics.WriteMetric{{ResourceType: "vm", ResourceID: "private-resource", MetricType: "cpu", Value: 42, Timestamp: time.Now().Add(-tc.age), Tier: metrics.TierDaily}})
setUnexportedField(t, monitor, "metricsStore", store)
persistence := config.NewConfigPersistence(t.TempDir())
handlers := NewLicenseHandlers(config.NewMultiTenantPersistence(t.TempDir()), false)
if tc.licensed {
setMetricsHistoryLicenseTier(t, handlers, pkglicensing.TierPro)
}
router := &Router{monitor: monitor, persistence: persistence, licenseHandlers: handlers}
rec := httptest.NewRecorder()
router.handleMetricsHistory(rec, httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?resourceType=vm&resourceId=private-resource"+tc.query, nil))
if tc.licensed && rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
now := time.Now().UTC()
if got := persistence.CountSuccessfulExtendedHistoryReadsSince(now.AddDate(0, 0, -30), now); got != tc.want {
t.Fatalf("reads = %d, want %d; response %s", got, tc.want, rec.Body.String())
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
const historyReadActivityFile = "history_read_activity.json"
// HistoryReadActivity retains daily counts only. Requests, ranges, metric
// values, resources, actors and individual read timestamps are never stored.
type HistoryReadActivity struct {
DailySuccessfulReads map[string]int `json:"daily_successful_reads_over_7d"`
}
func (c *ConfigPersistence) loadHistoryReadActivityLocked() (*HistoryReadActivity, error) {
activity := &HistoryReadActivity{DailySuccessfulReads: map[string]int{}}
raw, err := c.fs.ReadFile(filepath.Join(c.configDir, historyReadActivityFile))
if os.IsNotExist(err) {
return activity, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, activity); err != nil {
return nil, err
}
if activity.DailySuccessfulReads == nil {
activity.DailySuccessfulReads = map[string]int{}
}
return activity, nil
}
// RecordSuccessfulExtendedHistoryRead folds one successfully written response
// containing a genuine metric older than seven days into its UTC-day count.
func (c *ConfigPersistence) RecordSuccessfulExtendedHistoryRead(now time.Time) error {
if c == nil || c.configDir == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
activity, err := c.loadHistoryReadActivityLocked()
if err != nil {
return fmt.Errorf("load history read activity: %w", err)
}
day := now.UTC().Format("2006-01-02")
cutoff := now.UTC().AddDate(0, 0, -30).Format("2006-01-02")
for retainedDay := range activity.DailySuccessfulReads {
if retainedDay < cutoff || retainedDay > day {
delete(activity.DailySuccessfulReads, retainedDay)
}
}
activity.DailySuccessfulReads[day]++
raw, err := json.Marshal(activity)
if err != nil {
return err
}
path := filepath.Join(c.configDir, historyReadActivityFile)
if err := c.fs.MkdirAll(c.configDir, 0o700); err != nil {
return err
}
if err := c.fs.WriteFile(path+".tmp", raw, 0o600); err != nil {
return err
}
if err := c.fs.Rename(path+".tmp", path); err != nil {
_ = c.fs.Remove(path + ".tmp")
return err
}
return nil
}
func (c *ConfigPersistence) CountSuccessfulExtendedHistoryReadsSince(since, now time.Time) int {
if c == nil || c.configDir == "" {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
activity, err := c.loadHistoryReadActivityLocked()
if err != nil {
return 0
}
first, last := since.UTC().Format("2006-01-02"), now.UTC().Format("2006-01-02")
count := 0
for day, reads := range activity.DailySuccessfulReads {
if day >= first && day <= last && reads > 0 {
count += reads
}
}
return count
}
@@ -0,0 +1,62 @@
package config_test
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestExtendedHistoryReadActivityPersistsOnlyBoundedDailyCounts(t *testing.T) {
dir := t.TempDir()
persistence := config.NewConfigPersistence(dir)
now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
for _, at := range []time.Time{now.AddDate(0, 0, -40), now.AddDate(0, 0, -30), now, now} {
if err := persistence.RecordSuccessfulExtendedHistoryRead(at); err != nil {
t.Fatal(err)
}
}
// Reopening models a restart. No request-level data is needed for continuity.
reopened := config.NewConfigPersistence(dir)
if got := reopened.CountSuccessfulExtendedHistoryReadsSince(now.AddDate(0, 0, -30), now); got != 3 {
t.Fatalf("retained reads = %d, want 3", got)
}
raw, err := os.ReadFile(filepath.Join(dir, "history_read_activity.json"))
if err != nil {
t.Fatal(err)
}
var stored map[string]map[string]int
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatal(err)
}
if len(stored) != 1 || len(stored["daily_successful_reads_over_7d"]) != 2 {
t.Fatalf("unexpected retained fields or days: %s", raw)
}
info, err := os.Stat(filepath.Join(dir, "history_read_activity.json"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("tally mode must be 0600: %v %v", info, err)
}
}
func TestExtendedHistoryReadActivitySerializesConcurrentRequests(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
now := time.Now().UTC()
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := persistence.RecordSuccessfulExtendedHistoryRead(now); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if got := persistence.CountSuccessfulExtendedHistoryReadsSince(now.AddDate(0, 0, -30), now); got != 16 {
t.Fatalf("reads = %d, want 16", got)
}
}
+6 -1
View File
@@ -225,7 +225,9 @@ const (
// A 6.4.3-rc.1 update check failed on every call for weeks while its
// telemetry looked healthy, because the update counters only see applied
// updates. No version, URL, or error text is exported.
TelemetrySchemaVersion = 18
// Schema v19 adds only the rolling count of successfully served metric
// history responses containing data older than seven days.
TelemetrySchemaVersion = 19
)
type installIDRecord struct {
@@ -389,6 +391,7 @@ type Ping struct {
// browser intake is session-deduplicated and contains only the closed
// milestone; guest/user identity, routes, coordinates, and timings never
// enter the tally or payload.
SuccessfulHistoryReadsOver7d30d int `json:"successful_history_reads_over_7d_30d"`
WorkloadHistoryPreviewSessions30d int `json:"workload_history_preview_sessions_30d"`
WorkloadHistoryScrubSessions30d int `json:"workload_history_scrub_sessions_30d"`
WorkloadHistoryRangeChangeSessions30d int `json:"workload_history_range_change_sessions_30d"`
@@ -610,6 +613,7 @@ type Snapshot struct {
UpdateAvailable bool
NodeTestAttempts30d int
NodeTestFailures30d int
SuccessfulHistoryReadsOver7d30d int
WorkloadHistoryPreviewSessions30d int
WorkloadHistoryScrubSessions30d int
WorkloadHistoryRangeChangeSessions30d int
@@ -1354,6 +1358,7 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
ping.UpdateAvailable = s.UpdateAvailable && ping.UpdateCheckOutcome == updates.UpdateCheckOutcomeAvailable
ping.NodeTestAttempts30d = s.NodeTestAttempts30d
ping.NodeTestFailures30d = s.NodeTestFailures30d
ping.SuccessfulHistoryReadsOver7d30d = s.SuccessfulHistoryReadsOver7d30d
ping.WorkloadHistoryPreviewSessions30d = s.WorkloadHistoryPreviewSessions30d
ping.WorkloadHistoryScrubSessions30d = s.WorkloadHistoryScrubSessions30d
ping.WorkloadHistoryRangeChangeSessions30d = s.WorkloadHistoryRangeChangeSessions30d
+37
View File
@@ -721,6 +721,43 @@ func TestAllTelemetryFieldsAreDisclosed(t *testing.T) {
}
}
func TestTelemetryPrivacyReleaseNoticeCoversExtendedHistory(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "releases", "TELEMETRY_SCHEMA_V19.md"))
if err != nil {
t.Fatal(err)
}
notice := string(raw)
for _, required := range []string{"schema v19", "successful_history_reads_over_7d_30d", "customer identifiers", "opt-out"} {
if !strings.Contains(notice, required) {
t.Errorf("release notice must disclose %q", required)
}
}
}
func TestSuccessfulExtendedHistoryReadCounterAppearsInPreview(t *testing.T) {
preview, err := BuildPreview(Config{
Version: "6.6.0", DataDir: t.TempDir(),
GetSnapshot: func() Snapshot { return Snapshot{SuccessfulHistoryReadsOver7d30d: 7} },
})
if err != nil {
t.Fatal(err)
}
if preview.SuccessfulHistoryReadsOver7d30d != 7 {
t.Fatalf("preview reads = %d", preview.SuccessfulHistoryReadsOver7d30d)
}
raw, err := json.Marshal(preview)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
if payload["successful_history_reads_over_7d_30d"] != float64(7) {
t.Fatalf("missing aggregate read count")
}
}
func TestTelemetryPrivacyDocsDisclosePseudonymousIdentityAndIPHandling(t *testing.T) {
anonymousTelemetryClaimPattern := regexp.MustCompile(`(?i)\banonymous\b[^\n]{0,120}\btelemetry\b|\btelemetry\b[^\n]{0,120}\banonymous\b`)
for _, relativePath := range []string{
@@ -35,6 +35,8 @@ func applyLicensedFeatureConfigSnapshot(snap *telemetry.Snapshot, persistence *c
snap.AgentProfiles = len(profiles)
}
snap.SuccessfulHistoryReadsOver7d30d = persistence.CountSuccessfulExtendedHistoryReadsSince(now.Add(-telemetry.PulseIntelligenceTelemetryWindow), now)
// Audit adoption is a read count, not store presence: the SQLite audit
// logger is installed on every install for defense in depth, so only an
// operator reaching a license-gated read surface distinguishes an install
@@ -67,3 +67,16 @@ func TestApplyLicensedFeatureConfigSnapshot_ToleratesNilInputs(t *testing.T) {
applyLicensedFeatureConfigSnapshot(&snap, nil, time.Now().UTC())
applyLicensedFeatureConfigSnapshot(nil, config.NewConfigPersistence(t.TempDir()), time.Now().UTC())
}
func TestApplyLicensedFeatureConfigSnapshotIncludesSuccessfulExtendedHistoryReads(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
now := time.Now().UTC()
if err := persistence.RecordSuccessfulExtendedHistoryRead(now); err != nil {
t.Fatal(err)
}
var snap telemetry.Snapshot
applyLicensedFeatureConfigSnapshot(&snap, persistence, now)
if snap.SuccessfulHistoryReadsOver7d30d != 1 {
t.Fatalf("extended history reads = %d, want 1", snap.SuccessfulHistoryReadsOver7d30d)
}
}
@@ -1374,6 +1374,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/api/alerting/notifications_test.go",
"internal/api/audit_handlers_test.go",
"internal/api/availability_handlers_test.go",
"internal/api/chartapi/history_activity_test.go",
"internal/api/contract_test.go",
"internal/api/docker_agents_report_size_test.go",
"internal/api/docker_container_action_executor_test.go",
@@ -1385,6 +1386,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/api/metadata_handlers_test.go",
"internal/api/patrol_autopilot_test.go",
"internal/api/runtime_inventory_sources_test.go",
"internal/api/telemetry_history_reads_test.go",
"internal/api/truenas_disk_history_integration_test.go",
"pulse-enterprise:test/extensions_contract_test.go",
],
+79 -2
View File
@@ -1073,6 +1073,7 @@ REPORT_ROW_COLUMNS = tuple(
"version_is_published_release",
"platform",
"notification_failures_7d",
"successful_history_reads_over_7d_30d",
*(key for key, _ in ALERT_QUALITY_COUNT_FIELDS),
*(key for key, _ in ADOPTION_COUNT_FIELDS),
*(key for key, _ in FEATURE_BOOL_FIELDS),
@@ -1686,8 +1687,14 @@ def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]:
"""
).fetchone()
)
available_columns = {row[1] for row in conn.execute("PRAGMA table_info(telemetry_pings)")}
missing_columns = sorted(set(REPORT_ROW_COLUMNS) - available_columns)
projection = ", ".join(
name if name in available_columns else "0 AS " + name
for name in REPORT_ROW_COLUMNS
)
rows_sql = (
f"SELECT {REPORT_ROW_PROJECTION} "
f"SELECT {projection} "
"FROM telemetry_pings "
"WHERE received_at >= datetime('now', ?) "
"ORDER BY received_at DESC"
@@ -1699,7 +1706,7 @@ def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]:
(f"-{since_days} days",),
).fetchall()
]
return {"db_stats": db_stats, "rows": rows}
return {"db_stats": db_stats, "rows": rows, "unavailable_columns": missing_columns}
finally:
conn.close()
@@ -2793,6 +2800,53 @@ def summarize_user_base_signals(
}
def summarize_extended_history_usage(
latest_by_install: dict[str, dict[str, Any]],
published_versions: set[str],
*,
now: datetime,
unavailable_columns: Iterable[str] = (),
) -> dict[str, Any]:
"""One latest rolling count per reporting ID, with explicit coverage.
No heartbeat summing or account joins. The license flag is coarse, and
rotating IDs do not identify people or subscriptions.
"""
field = "successful_history_reads_over_7d_30d"
source_available = field not in unavailable_columns
cohorts = {
posture: {"reporting_ids": 0, "covered_ids": 0, "unmeasured_ids": 0,
"ids_with_reads": 0, "successful_reads_30d": 0}
for posture in ("nonfree", "free", "unknown")
}
excluded = 0
for row in latest_by_install.values():
age = now - parse_received_at(str(row["received_at"]))
if age < timedelta(0) or age > timedelta(days=7):
continue
identity = classify_row_version(row, published_versions)
if (is_mock_fleet_row(row) or identity.is_development
or not identity.is_published_release
or not parse_optional_bool(row.get("monitoring_active"))):
excluded += 1
continue
paid = parse_optional_bool(row.get("paid_license"))
cohort = cohorts["nonfree" if paid is True else "free" if paid is False else "unknown"]
cohort["reporting_ids"] += 1
if (not source_available or field not in row
or parse_optional_nonnegative_int(row.get("schema_version")) < 19):
cohort["unmeasured_ids"] += 1
continue
cohort["covered_ids"] += 1
reads = parse_optional_nonnegative_int(row.get(field))
if reads > 0:
cohort["ids_with_reads"] += 1
cohort["successful_reads_30d"] += reads
return {"window": "7d", "minimum_schema_version": 19,
"source_available": source_available, "excluded_ids": excluded,
"cohorts": cohorts}
def analyze_target_release_rows(
rows: Iterable[dict[str, Any]],
published_versions: set[str],
@@ -3321,6 +3375,7 @@ def summarize_rows(
source_window_days: int | None = None,
pulse_intelligence_analysis_facts: Iterable[dict[str, Any]] | None = None,
target_release_analysis_facts: Iterable[dict[str, Any]] | None = None,
unavailable_columns: Iterable[str] = (),
) -> dict[str, Any]:
row_list: list[dict[str, Any]] = []
mock_fleet_rows = 0
@@ -3403,6 +3458,10 @@ def summarize_rows(
"installs": len(mock_fleet_installs),
},
"latest_install_windows": latest_install_windows,
"extended_history_usage_7d": summarize_extended_history_usage(
latest_by_install, published_versions, now=current_time,
unavailable_columns=unavailable_columns,
),
"user_base_signals_7d": summarize_user_base_signals(
latest_by_install,
now=current_time,
@@ -3620,6 +3679,23 @@ def format_text(summary: dict[str, Any], repo: str, since_days: int) -> str:
"schema v3+ counts only terminal failed or dead-letter outcomes"
)
extended_history = summary.get("extended_history_usage_7d")
if extended_history:
lines.extend(["", "Successful history reads beyond seven days (active production reporting IDs, 7d):"])
for posture, cohort in extended_history["cohorts"].items():
lines.append(
f"- {posture}: {cohort['covered_ids']} measured, "
f"{cohort['unmeasured_ids']} unmeasured, {cohort['ids_with_reads']} with reads, "
f"{cohort['successful_reads_30d']} successful reads (rolling 30d)"
)
lines.append(
"- interpretation: schema v19+ only. Older or unavailable measurements are unknown, "
"not zero use. Each reporting ID contributes only its latest rolling count. "
"Automatic refreshes count as reads. Rotating IDs are not people or subscriptions. "
"The nonfree flag does not verify paying customers or willingness to pay. "
"An upgrade starts measurement, so a zero is not a full 30-day observation."
)
pulse_loop = summary.get("pulse_intelligence_value_loop_7d")
if pulse_loop:
lines.extend(
@@ -4001,6 +4077,7 @@ def main(argv: list[str] | None = None) -> int:
baseline_version=args.baseline_version,
include_mock_fleet=args.include_mock_fleet,
source_window_days=args.since_days,
unavailable_columns=source.get("unavailable_columns", []),
pulse_intelligence_analysis_facts=source.get(
"pulse_intelligence_analysis_facts"
),
@@ -24,6 +24,45 @@ import telemetry_adoption_report as report
class TelemetryAdoptionReportTest(unittest.TestCase):
def test_extended_history_reports_coverage_and_latest_rolling_count(self) -> None:
now = datetime(2026, 10, 16, 12, tzinfo=timezone.utc)
base = {"received_at": "2026-10-16 11:00:00", "version": "6.6.0",
"monitoring_active": 1, "schema_version": 19, "paid_license": 1,
"successful_history_reads_over_7d_30d": 4}
rows = [{**base, "install_id": "nonfree"},
{**base, "install_id": "nonfree", "received_at": "2026-10-15 11:00:00", "successful_history_reads_over_7d_30d": 3},
{**base, "install_id": "legacy", "schema_version": 18, "successful_history_reads_over_7d_30d": 99},
{**base, "install_id": "zero", "successful_history_reads_over_7d_30d": 0},
{**base, "install_id": "free", "paid_license": 0, "successful_history_reads_over_7d_30d": 2},
{**base, "install_id": "inactive", "monitoring_active": 0},
{**base, "install_id": "dev", "version_is_development": 1},
{**base, "install_id": "mock", "kubernetes_pods": 120, "vmware_hosts": 7},
{**base, "install_id": "stale", "received_at": "2026-10-01 11:00:00"}]
summary = report.summarize_rows({}, rows, {"6.6.0"}, now=now)
usage = summary["extended_history_usage_7d"]
self.assertEqual(usage["cohorts"]["nonfree"], {
"reporting_ids": 3, "covered_ids": 2, "unmeasured_ids": 1,
"ids_with_reads": 1, "successful_reads_30d": 4})
self.assertEqual(usage["cohorts"]["free"]["successful_reads_30d"], 2)
self.assertEqual(usage["excluded_ids"], 2)
unavailable = report.summarize_rows({}, rows, {"6.6.0"}, now=now,
unavailable_columns=["successful_history_reads_over_7d_30d"])
self.assertEqual(unavailable["extended_history_usage_7d"]["cohorts"]["nonfree"]["covered_ids"], 0)
text = report.format_text(summary, "rcourtman/Pulse", 7)
self.assertIn("2 measured, 1 unmeasured, 1 with reads, 4 successful reads", text)
self.assertIn("not zero use", text)
self.assertIn("does not verify paying customers", text)
def test_local_report_old_receiver_marks_new_history_field_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as directory:
db_path = str(Path(directory) / "telemetry.sqlite")
with sqlite3.connect(db_path) as conn:
conn.execute("CREATE TABLE telemetry_pings (received_at TEXT, install_id TEXT, version TEXT)")
conn.execute("INSERT INTO telemetry_pings VALUES (datetime('now'), 'fixture', '6.6.0')")
source = report.fetch_rows_local(db_path, 7)
self.assertIn("successful_history_reads_over_7d_30d", source["unavailable_columns"])
self.assertEqual(source["rows"][0]["successful_history_reads_over_7d_30d"], 0)
def test_normalize_reported_version_strips_v_prefix(self) -> None:
self.assertEqual(report.normalize_reported_version("v6.0.0-rc.1"), "6.0.0-rc.1")
@@ -10,7 +10,7 @@ import {
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 18;
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 19;
type WorkerFixtures = {
authStorageStatePath: string;
@@ -53,6 +53,10 @@ async function expectPopupDoc(
await popup.waitForLoadState("domcontentloaded");
expect(new URL(popup.url()).pathname).toBe(pathname);
await expect(popup.locator("body")).toContainText(expectedText);
await expect(popup.locator("body")).toContainText("successful_history_reads_over_7d_30d");
await expect(popup.locator("body")).toContainText("Selecting a long range alone does not count");
await popup.getByText("Schema v19 adds", { exact: false }).scrollIntoViewIfNeeded();
await popup.screenshot({ path: test.info().outputPath("privacy-history-counter.png") });
await popup.close();
}
@@ -69,9 +73,29 @@ async function readTelemetryPreview(page: Page) {
update_channel: string;
update_check_outcome: string;
update_available: boolean;
successful_history_reads_over_7d_30d: number;
};
}
async function showExtendedHistoryCount(page: Page) {
await page.locator('pre[aria-label="Telemetry payload preview"]').evaluate((pre) => {
const node = pre.firstChild!;
const offset = (node.textContent ?? "").indexOf("successful_history_reads_over_7d_30d");
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset + "successful_history_reads_over_7d_30d".length);
for (let parent = pre.parentElement; parent; parent = parent.parentElement) {
if (parent.scrollHeight > parent.clientHeight && /auto|scroll/.test(getComputedStyle(parent).overflowY)) {
parent.scrollTop += range.getBoundingClientRect().top - parent.getBoundingClientRect().top - parent.clientHeight / 2;
}
}
const rect = range.getBoundingClientRect();
if (rect.top < 60 || rect.bottom > window.innerHeight - 60) {
throw new Error("Extended history count must be in the visible preview before capture");
}
});
}
test.describe("Telemetry disclosure", () => {
test.setTimeout(180_000);
@@ -125,7 +149,9 @@ test.describe("Telemetry disclosure", () => {
});
await page.waitForURL(/\/settings/, { timeout: 15_000 });
await page.getByRole("button", { name: "Preview payload" }).click();
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByRole("button", { name: "Preview payload" }).focus();
await page.getByRole("button", { name: "Preview payload" }).press("Enter");
const initialPreview = await readTelemetryPreview(page);
expect(initialPreview.event).toBe("heartbeat");
@@ -156,6 +182,12 @@ test.describe("Telemetry disclosure", () => {
"error",
]).toContain(initialPreview.update_check_outcome);
expect(typeof initialPreview.update_available).toBe("boolean");
expect(initialPreview.successful_history_reads_over_7d_30d).toBe(0);
await page.getByRole("button", { name: "Refresh payload" }).click();
expect((await readTelemetryPreview(page)).successful_history_reads_over_7d_30d).toBe(0);
// Fixture data must not create real extended-history usage.
await showExtendedHistoryCount(page);
await page.screenshot({ path: testInfo.outputPath("telemetry-preview-desktop.png") });
await page.setViewportSize({ width: 390, height: 844 });
await expect(
@@ -178,5 +210,8 @@ test.describe("Telemetry disclosure", () => {
return refreshedPreview.install_id;
})
.not.toBe(initialPreview.install_id);
expect((await readTelemetryPreview(page)).successful_history_reads_over_7d_30d).toBe(0);
await showExtendedHistoryCount(page);
await page.screenshot({ path: testInfo.outputPath("telemetry-preview-narrow.png") });
});
});