Report update channel and last update check outcome in telemetry (#2285)

A failed update check never reaches the update history, because nothing
was applied, so telemetry could not tell an install whose check is broken
from one that was offered an update and ignored it. The 6.4.3-rc.1 installs
looked healthy while their check failed on every call (#2282).

The update manager now records a closed outcome for every check on the
install's effective channel (up_to_date, available, no_release,
rate_limited, network_error, metadata_error, skipped, error), tagging
lookup failures without changing their error text, and ignores previews of
another channel from Settings. Telemetry schema v18 exports update_channel,
update_check_outcome and update_available. No version, URL or error text
leaves the install. The Settings preview type, privacy disclosure, adoption
report and subsystem contracts are updated to match; the Pulse Pro receiver
change lands separately and the parity check passes against it.
This commit is contained in:
courtmanr@gmail.com 2026-09-27 23:41:42 +01:00
parent b1e75fb9e4
commit 81e92bff95
21 changed files with 522 additions and 36 deletions

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 | `17` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `18` | 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 |
@ -153,6 +153,9 @@ Every field is listed below with the reason it exists. Nothing else is included
| Update successes 30d | `1` | Count successful update attempts in the current 30-day telemetry window |
| Update failures 30d | `1` | Count failed or rolled-back update attempts in the current 30-day telemetry window without sending raw errors, logs, URLs, or command output |
| Update last failure category | `download` | Send only a coarse category for the latest update failure, such as `download`, `signature`, `checksum`, `disk_space`, `extract`, `backup`, `apply`, `restart`, `rolled_back`, or `unknown` |
| Update channel | `stable` or `rc` | Report which update channel the install follows, so a preview build on the stable channel can be told apart from one on the preview channel |
| Update check outcome | `up_to_date`, `available`, `no_release`, `rate_limited`, `network_error`, `metadata_error`, `skipped`, `error`, or `not_checked` | Classify the most recent update check on that channel into one fixed category without sending versions, release URLs, or error text, so a check that cannot complete is distinguishable from an offered update that was not applied |
| Update available | `true`/`false` | Report whether that last check offered a newer release, without sending which release |
| Service health observed | `true`/`false` | Distinguish a release that performed the bounded local UI/API self-check from an older release with no signal |
| Service health healthy | `true`/`false` | Report whether Pulse's locally bound listener served a healthy API response, UI document, and every referenced local frontend asset without sending an address, URL, response, or error text |
| Service health failure category | `listener`, `startup`, `runtime`, `api_connectivity`, `api_status`, `ui_status`, `frontend_assets`, or `unknown` | Classify a failed local self-check or startup path into one fixed category without sending the listener address, request URL, HTTP body, asset name, IP address, or raw error |
@ -307,7 +310,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 17. Schema v16 adds four
The current telemetry contract is schema version 18. 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
@ -326,6 +329,13 @@ models stay on the install. The outcome counts partition the findings already
counted as investigated; no finding, resource, session, or action identity is
added.
Schema v18 adds three update-discovery fields. The update channel is the
channel the install follows (stable or preview), and the check outcome is one
fixed category for the most recent update check on that channel, with a single
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.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@ -378,6 +388,7 @@ Every change to the payload bumps the schema version, is listed here with its da
| Schema | Date | Change |
|--------|------|--------|
| 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 |
| 15 | 2026-08-29 | Notification destination HTTP 5xx failures separated from rejected HTTP 4xx responses |

View file

@ -1040,6 +1040,12 @@ usage ledger, and the findings store; it does not read, register, authorize,
configure, or report a Pulse agent, and no agent token, inventory,
registration state, host command, or command-channel readiness may feed it or
be inferred from it.
Schema v18 update-discovery telemetry assembled through `internal/api/` (the
effective Pulse server update channel, the closed outcome of the last server
update check, and whether it offered a newer release) is the same kind of
adjacent analytics. It reads only the server update manager's last check; it
does not describe agent binaries, agent auto-update, or agent version skew,
and agent lifecycle surfaces must not consume it as agent update state.
Scheduled-report route and background-worker wiring in `internal/api/router.go`
and the reporting handlers is API/reporting ownership, not agent lifecycle.
The scheduler may enumerate tenant organization IDs so each workspace can run

View file

@ -10328,6 +10328,21 @@ its investigation status when no outcome is recorded. No provider ID, model
name, endpoint, account identity, exact token count, finding ID, resource ID,
or session ID may be added to any of these fields.
### Update channel and last update check outcome are a closed contract at schema v18
Schema v18 adds `update_channel` (`stable`, `rc`, or `unknown`) and
`update_check_outcome` (`not_checked`, `up_to_date`, `available`,
`no_release`, `rate_limited`, `network_error`, `metadata_error`, `skipped`, or
`error`) as always-present closed strings, plus the boolean `update_available`.
The Go sender, the Settings `TelemetryPingPreview` interface, and the Pulse Pro
receiver keep the same field names and types, and
`scripts/check_telemetry_schema_parity.py` remains the executable proof. The
receiver stores an omitted string from a pre-v18 sender as `unknown`, which
the sender itself never emits for the outcome. `update_available` may only be
true alongside an `available` outcome, on both sides. No version string,
release tag, URL, response body, or error text may be added to any of these
fields.
### Per-tenant resource stores are released on offboarding and shutdown
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the

View file

@ -4044,6 +4044,16 @@ bound without the fallback, unable to discover any update (#1881, #2282).
Malformed metadata stays a hard error and only the typed over-limit condition
may fall through to the feed. Proof:
`internal/updates/issue2282_release_metadata_stream_test.go`.
The update check must also leave a content-free record of its own outcome.
`internal/updates/check_observation.go` stores the closed outcome, the
effective channel, and whether an update was offered for every check on the
install's effective channel, and ignores explicit previews of another channel.
Lookup failures carry their category (`network_error`, `metadata_error`,
`rate_limited`) without changing the error text operators see, and usage
telemetry exports only that record at schema v18. An update discovery failure
that records nothing is a regression, because a failed check never reaches the
update history and is otherwise invisible in the fleet (#2285). Proof:
`internal/updates/issue2285_update_check_observation_test.go`.
Those same workflows must also fetch and dispatch the governed release branch
derived from release-control metadata instead of hardcoding `pulse/v6`,
`pulse/v6-release`, `main`, or any later branch literal inline; when a stable

View file

@ -2501,7 +2501,7 @@ actor, and every audit row read stay on the install.
### Telemetry ingestion matches the released sender while storage stays compatible
The active outbound contract is schema v17. Schema v8 added content-free
The active outbound contract is schema v18. 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`
@ -2537,6 +2537,18 @@ as investigated, one bucket per finding, and add no finding, resource,
session, or action identity. The receiver canonicalizes every one of the four
strings to its released vocabulary or `unknown` and clamps the counts like
every other counter.
Schema v18 adds `update_channel`, `update_check_outcome`, and
`update_available` so the fleet can tell an install whose update check cannot
complete from one that was offered an update and did not apply it. A failed
check never reaches the update-history counters because nothing was applied,
which let a 6.4.3-rc.1 check that failed on every call look healthy (#2285).
The channel is the install's effective update channel. The outcome is one
closed category recorded by `internal/updates` for the most recent check on
that channel only, so a Settings preview of the other channel never leaks into
it. The sender and receiver both force `update_available` false unless the
outcome is `available`. No version, release tag, URL, response, or error text
leaves the install, and the receiver canonicalizes both strings to the released
vocabulary or `unknown`.
Patrol run and new-finding volumes use bounded local UTC-day tallies in
`internal/config/persistence.go`, preserving aggregate activity after the
operator-facing run history is trimmed. Finding volume has its own persistence

View file

@ -1123,6 +1123,11 @@ recovery scope, or a storage/recovery-owned secret source.
ledger, and the findings store. They do not represent backup history,
recovery points, retention policy, restore evidence, or storage provider
state, and storage/recovery surfaces must not consume them as such.
The schema v18 update-discovery fields assembled through `internal/api/`
(effective update channel, last update check outcome, and whether it
offered a newer release) describe only Pulse server release discovery.
They are not backup, retained-update-backup, rollback, or restore evidence,
and storage/recovery surfaces must not consume them as such.
Commercial migration startup behavior in
`internal/api/licensing_handlers.go` and `internal/api/licensing_bridge.go`
remains adjacent cloud-paid/API state. Synthetic mock-license suppression

View file

@ -1,17 +1,17 @@
{
"version": 1,
"base_sha": "cd43b6c692383865592c17a0853d33a229ae46d6",
"verified_at": "2026-09-27T00:26:36Z",
"base_sha": "b1e75fb9e4693c5706b2242760d6277e5291e7f9",
"verified_at": "2026-09-27T22:55:00Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx"],
"changed_paths": ["frontend-modern/src/api/settings.ts"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestPhysicalDisks.tsx": "1a0b15141ee41e45dc884406bc4689828b034704c80f914fc0d2baa93d0b6edd"
"frontend-modern/src/api/settings.ts": "4172b94c268be6a0ea000e73d155e540f11776e1c59325250e13716722be5aeb"
},
"routes": ["/browser-tests/guest-storage-2263.html"],
"routes": ["/settings/system-general", "/docs/PRIVACY"],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1024,
"height": 768
},
{
"width": 390,
@ -19,21 +19,18 @@
}
],
"states": [
"Linked Proxmox guest with one physical disk and RAID array: disk row collapsed, SMART detail module not requested",
"Keyboard-expanded disk while the SMART module request is held: visible Loading SMART details status, summary retains focus",
"Loaded SMART overview: temperature, reallocated sectors, pending sectors and CRC errors visible; module requested once",
"SMART History tab selected with empty fixture chart data: chart collection placeholders and range control visible",
"Pointer-collapsed and reopened disk: overview restored without reloading the SMART module",
"Guest without a linked agent: no physical-disk card and no child-resource request",
"Linked guest child-resource request failing with HTTP 503: unavailable message visible, no misleading disk card",
"Desktop and phone screenshots inspected for placement, clipping, stacking, scrolling and horizontal overflow"
"Local build of this branch signed in with a test account: General settings telemetry card rendered with the Preview payload control",
"Telemetry payload preview opened: schema_version 18 with update_channel stable, update_check_outcome skipped (source build, recorded by the background update checker) and update_available false, all three keys present",
"Preview at 390px: payload block visible and scrolls within its own box with no page-level horizontal overflow",
"Shipped privacy document: schema version 18 disclosure, Update channel, Update check outcome and Update available rows, and the dated schema 18 changelog row present",
"Privacy document at 390px: new rows render in the existing table, which scrolls within its container like the neighbouring rows, with no page-level horizontal overflow",
"Desktop and phone screenshots inspected for placement, clipping and overflow"
],
"interactions": [
"Focus disk summary and press Enter; hold then release its lazy module request, verifying loading status, focus retention and loaded SMART attributes",
"Click History and Overview tabs, verifying selection and the chart/range layout at desktop and phone widths",
"Click the summary to close and reopen detail, verifying no second lazy-module request",
"Switch to No agent and Unavailable query fixture states; verify exact parent-filtered API requests and absence of unscoped disk fetches"
],
"command": "pulse-worker-browser tmp/browser-proof/frontend-modern/browser-tests/guest-storage-2263.cjs (Playwright 1.56.1, Chromium 141.0.7390.37)",
"notes": "The browser ran an owned git-archive source copy with final GuestPhysicalDisks.tsx bytes and a bounded #2263 Vite fixture; this is not installed acceptance or a reporter field retest. Passed log and twelve inspected screenshots: /var/lib/pulse-maintainer/worker-outputs/product-intelligence-zf_afcvy/guest-storage-browser-formatted.log and browser-proof/."
"Signed in through the login form",
"Navigated to /settings/system-general and clicked Preview payload",
"Parsed the rendered payload JSON and checked the schema 18 fields and closed values",
"Resized to 390x844 and rechecked the preview and page overflow",
"Navigated to /docs/PRIVACY and scrolled the Update check outcome row into view at desktop and 390px widths"
]
}

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 | `17` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `18` | 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 |
@ -153,6 +153,9 @@ Every field is listed below with the reason it exists. Nothing else is included
| Update successes 30d | `1` | Count successful update attempts in the current 30-day telemetry window |
| Update failures 30d | `1` | Count failed or rolled-back update attempts in the current 30-day telemetry window without sending raw errors, logs, URLs, or command output |
| Update last failure category | `download` | Send only a coarse category for the latest update failure, such as `download`, `signature`, `checksum`, `disk_space`, `extract`, `backup`, `apply`, `restart`, `rolled_back`, or `unknown` |
| Update channel | `stable` or `rc` | Report which update channel the install follows, so a preview build on the stable channel can be told apart from one on the preview channel |
| Update check outcome | `up_to_date`, `available`, `no_release`, `rate_limited`, `network_error`, `metadata_error`, `skipped`, `error`, or `not_checked` | Classify the most recent update check on that channel into one fixed category without sending versions, release URLs, or error text, so a check that cannot complete is distinguishable from an offered update that was not applied |
| Update available | `true`/`false` | Report whether that last check offered a newer release, without sending which release |
| Service health observed | `true`/`false` | Distinguish a release that performed the bounded local UI/API self-check from an older release with no signal |
| Service health healthy | `true`/`false` | Report whether Pulse's locally bound listener served a healthy API response, UI document, and every referenced local frontend asset without sending an address, URL, response, or error text |
| Service health failure category | `listener`, `startup`, `runtime`, `api_connectivity`, `api_status`, `ui_status`, `frontend_assets`, or `unknown` | Classify a failed local self-check or startup path into one fixed category without sending the listener address, request URL, HTTP body, asset name, IP address, or raw error |
@ -307,7 +310,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 17. Schema v16 adds four
The current telemetry contract is schema version 18. 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
@ -326,6 +329,13 @@ models stay on the install. The outcome counts partition the findings already
counted as investigated; no finding, resource, session, or action identity is
added.
Schema v18 adds three update-discovery fields. The update channel is the
channel the install follows (stable or preview), and the check outcome is one
fixed category for the most recent update check on that channel, with a single
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.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@ -378,6 +388,7 @@ Every change to the payload bumps the schema version, is listed here with its da
| Schema | Date | Change |
|--------|------|--------|
| 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 |
| 15 | 2026-08-29 | Notification destination HTTP 5xx failures separated from rejected HTTP 4xx responses |

View file

@ -76,6 +76,9 @@ const mockTelemetryPreviewPayload = {
update_successes_30d: 0,
update_failures_30d: 0,
update_last_failure_category: undefined,
update_channel: 'stable',
update_check_outcome: 'up_to_date',
update_available: false,
service_health_observed: true,
service_health_healthy: true,
service_health_failure_category: undefined,

View file

@ -77,6 +77,9 @@ export interface TelemetryPingPreview {
update_successes_30d: number;
update_failures_30d: number;
update_last_failure_category?: string;
update_channel: string;
update_check_outcome: string;
update_available: boolean;
service_health_observed: boolean;
service_health_healthy: boolean;
service_health_failure_category?: string;

View file

@ -88,6 +88,9 @@ const buildTelemetryPreviewPayload = (
workload_history_range_change_sessions_30d: 0,
workload_history_details_selection_sessions_30d: 0,
update_last_failure_category: undefined,
update_channel: 'stable',
update_check_outcome: 'up_to_date',
update_available: false,
service_health_observed: true,
service_health_healthy: true,
service_health_failure_category: undefined,

View file

@ -71,6 +71,9 @@ func (r *Router) ApplyUpdateTelemetrySnapshot(s *telemetry.Snapshot, now time.Ti
return
}
telemetry.ApplyUpdateTelemetrySnapshot(s, r.updateHistory, now)
if r.updateManager != nil {
telemetry.ApplyUpdateCheckTelemetrySnapshot(s, r.updateManager.LastUpdateCheck())
}
}
// GetPulseIntelligenceActionTelemetry returns count-only action-governance

View file

@ -0,0 +1,64 @@
package telemetry
import (
"encoding/json"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
)
func TestIssue2285UpdateCheckFieldsAreClosedAndContentFree(t *testing.T) {
for _, tc := range []struct {
name string
check updates.UpdateCheckObservation
wantChannel string
wantOutcome string
wantAvailable bool
}{
{"offered on stable", updates.UpdateCheckObservation{Channel: "stable", Outcome: updates.UpdateCheckOutcomeAvailable, Available: true}, "stable", "available", true},
{"preview up to date", updates.UpdateCheckObservation{Channel: "RC", Outcome: " up_to_date ", Available: false}, "rc", "up_to_date", false},
{"broken check never claims an offer", updates.UpdateCheckObservation{Channel: "stable", Outcome: updates.UpdateCheckOutcomeMetadataError, Available: true}, "stable", "metadata_error", false},
{"never checked", updates.UpdateCheckObservation{}, "unknown", "not_checked", false},
{"unknown values collapse", updates.UpdateCheckObservation{Channel: "nightly", Outcome: "failed to fetch https://example.invalid/releases"}, "unknown", "error", false},
} {
t.Run(tc.name, func(t *testing.T) {
var snap Snapshot
ApplyUpdateCheckTelemetrySnapshot(&snap, tc.check)
ping := BuildPingForSnapshot(snap)
if ping.UpdateChannel != tc.wantChannel || ping.UpdateCheckOutcome != tc.wantOutcome || ping.UpdateAvailable != tc.wantAvailable {
t.Fatalf("ping update discovery = (%q, %q, %v), want (%q, %q, %v)",
ping.UpdateChannel, ping.UpdateCheckOutcome, ping.UpdateAvailable,
tc.wantChannel, tc.wantOutcome, tc.wantAvailable)
}
})
}
// A snapshot that skipped the update wiring must still send closed values.
ping := BuildPingForSnapshot(Snapshot{UpdateAvailable: true, UpdateCheckOutcome: "", UpdateChannel: ""})
if ping.UpdateChannel != "unknown" || ping.UpdateCheckOutcome != "not_checked" || ping.UpdateAvailable {
t.Fatalf("unwired snapshot ping = (%q, %q, %v), want (unknown, not_checked, false)",
ping.UpdateChannel, ping.UpdateCheckOutcome, ping.UpdateAvailable)
}
}
func TestIssue2285UpdateCheckFieldsSerializeWithStableNames(t *testing.T) {
var snap Snapshot
ApplyUpdateCheckTelemetrySnapshot(&snap, updates.UpdateCheckObservation{
Channel: "rc",
Outcome: updates.UpdateCheckOutcomeAvailable,
Available: true,
CheckedAt: time.Now(),
})
raw, err := json.Marshal(BuildPingForSnapshot(snap))
if err != nil {
t.Fatalf("marshal ping: %v", err)
}
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("unmarshal ping: %v", err)
}
if fields["update_channel"] != "rc" || fields["update_check_outcome"] != "available" || fields["update_available"] != true {
t.Fatalf("serialized update discovery = (%v, %v, %v)", fields["update_channel"], fields["update_check_outcome"], fields["update_available"])
}
}

View file

@ -9,8 +9,8 @@ import (
)
func TestSchemaV17FieldNamesArePinned(t *testing.T) {
if TelemetrySchemaVersion != 17 {
t.Fatalf("TelemetrySchemaVersion = %d, want 17", TelemetrySchemaVersion)
if TelemetrySchemaVersion < 17 {
t.Fatalf("TelemetrySchemaVersion = %d, want at least 17", TelemetrySchemaVersion)
}
want := map[string]string{
"AIProviderClass": "ai_provider_class",

View file

@ -220,7 +220,12 @@ const (
// install, or see how the investigations that produced no plan ended. No
// provider ID, model name, endpoint, account identity, exact token count,
// finding ID, or resource ID is exported.
TelemetrySchemaVersion = 17
// Schema v18 adds update_channel (stable or rc), update_check_outcome (a
// closed category for the last check on that channel) and update_available.
// 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
)
type installIDRecord struct {
@ -351,6 +356,12 @@ type Ping struct {
UpdateFailures30d int `json:"update_failures_30d"`
// Last coarse update failure category; never raw error text.
UpdateLastFailureCategory string `json:"update_last_failure_category,omitempty"`
// Schema v18 update discovery: the channel the install follows and the
// closed outcome of its last check on that channel. A failed check never
// reaches the update counters above because nothing was applied.
UpdateChannel string `json:"update_channel"`
UpdateCheckOutcome string `json:"update_check_outcome"`
UpdateAvailable bool `json:"update_available"`
// Local release-service observation. The probe checks the local API, UI
// document, and referenced frontend assets. Only booleans, a fixed failure
@ -593,6 +604,9 @@ type Snapshot struct {
UpdateSuccesses30d int
UpdateFailures30d int
UpdateLastFailureCategory string
UpdateChannel string
UpdateCheckOutcome string
UpdateAvailable bool
NodeTestAttempts30d int
NodeTestFailures30d int
WorkloadHistoryPreviewSessions30d int
@ -845,6 +859,48 @@ func ApplyUpdateTelemetrySnapshot(s *Snapshot, history *updates.UpdateHistory, n
}
}
// ApplyUpdateCheckTelemetrySnapshot adds the effective update channel and the
// closed outcome of the last check on it. Only fixed categories leave the
// instance; versions, URLs, and error text never do.
func ApplyUpdateCheckTelemetrySnapshot(s *Snapshot, check updates.UpdateCheckObservation) {
if s == nil {
return
}
s.UpdateChannel = normalizeUpdateChannelForTelemetry(check.Channel)
s.UpdateCheckOutcome = normalizeUpdateCheckOutcomeForTelemetry(check.Outcome)
s.UpdateAvailable = check.Available && s.UpdateCheckOutcome == updates.UpdateCheckOutcomeAvailable
}
func normalizeUpdateChannelForTelemetry(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case "stable":
return "stable"
case "rc":
return "rc"
default:
return "unknown"
}
}
func normalizeUpdateCheckOutcomeForTelemetry(outcome string) string {
switch outcome = strings.ToLower(strings.TrimSpace(outcome)); outcome {
case updates.UpdateCheckOutcomeNotChecked,
updates.UpdateCheckOutcomeUpToDate,
updates.UpdateCheckOutcomeAvailable,
updates.UpdateCheckOutcomeNoRelease,
updates.UpdateCheckOutcomeRateLimited,
updates.UpdateCheckOutcomeNetworkError,
updates.UpdateCheckOutcomeMetadataError,
updates.UpdateCheckOutcomeSkipped,
updates.UpdateCheckOutcomeError:
return outcome
case "":
return updates.UpdateCheckOutcomeNotChecked
default:
return updates.UpdateCheckOutcomeError
}
}
func classifyUpdateFailureCategory(entry updates.UpdateHistoryEntry) string {
switch entry.Status {
case updates.StatusRolledBack:
@ -1286,6 +1342,9 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
ping.UpdateSuccesses30d = s.UpdateSuccesses30d
ping.UpdateFailures30d = s.UpdateFailures30d
ping.UpdateLastFailureCategory = s.UpdateLastFailureCategory
ping.UpdateChannel = normalizeUpdateChannelForTelemetry(s.UpdateChannel)
ping.UpdateCheckOutcome = normalizeUpdateCheckOutcomeForTelemetry(s.UpdateCheckOutcome)
ping.UpdateAvailable = s.UpdateAvailable && ping.UpdateCheckOutcome == updates.UpdateCheckOutcomeAvailable
ping.NodeTestAttempts30d = s.NodeTestAttempts30d
ping.NodeTestFailures30d = s.NodeTestFailures30d
ping.WorkloadHistoryPreviewSessions30d = s.WorkloadHistoryPreviewSessions30d

View file

@ -0,0 +1,96 @@
package updates
import (
"errors"
"time"
)
// Update check outcomes are a closed vocabulary shared with usage telemetry.
// A failed check never reaches the update history (nothing was applied), so
// without this record an install whose check is broken is indistinguishable
// from one that was offered an update and ignored it (#2285).
const (
UpdateCheckOutcomeNotChecked = "not_checked"
UpdateCheckOutcomeUpToDate = "up_to_date"
UpdateCheckOutcomeAvailable = "available"
UpdateCheckOutcomeNoRelease = "no_release"
UpdateCheckOutcomeRateLimited = "rate_limited"
UpdateCheckOutcomeNetworkError = "network_error"
UpdateCheckOutcomeMetadataError = "metadata_error"
UpdateCheckOutcomeSkipped = "skipped"
UpdateCheckOutcomeError = "error"
)
// UpdateCheckObservation is the content-free result of the most recent update
// check on the install's effective channel. It carries no version strings,
// URLs, or error text.
type UpdateCheckObservation struct {
Channel string
Outcome string
Available bool
CheckedAt time.Time
}
// updateCheckError tags a release lookup failure with its outcome category
// while leaving the wrapped error message unchanged.
type updateCheckError struct {
outcome string
err error
}
func (e *updateCheckError) Error() string { return e.err.Error() }
func (e *updateCheckError) Unwrap() error { return e.err }
func withUpdateCheckOutcome(outcome string, err error) error {
if err == nil {
return nil
}
return &updateCheckError{outcome: outcome, err: err}
}
func updateCheckErrorOutcome(err error) string {
if errors.Is(err, errGitHubRateLimited) {
return UpdateCheckOutcomeRateLimited
}
var tagged *updateCheckError
if errors.As(err, &tagged) {
return tagged.outcome
}
return UpdateCheckOutcomeError
}
// recordUpdateCheck stores the outcome of a check on the effective channel.
// Checks that preview another channel (an explicit UI override) are ignored so
// the observation always describes what the install itself would be offered.
func (m *Manager) recordUpdateCheck(channel string, effective bool, outcome string, available bool) {
if !effective {
return
}
m.statusMu.Lock()
m.lastCheck = UpdateCheckObservation{
Channel: channel,
Outcome: outcome,
Available: available,
CheckedAt: time.Now().UTC(),
}
m.statusMu.Unlock()
}
// LastUpdateCheck returns the most recent effective-channel check result, with
// the currently effective channel and a not_checked outcome before any check.
func (m *Manager) LastUpdateCheck() UpdateCheckObservation {
if m == nil {
return UpdateCheckObservation{Outcome: UpdateCheckOutcomeNotChecked}
}
currentInfo, _ := GetCurrentVersion()
channel := m.resolveChannel("", currentInfo)
m.statusMu.RLock()
observation := m.lastCheck
m.statusMu.RUnlock()
if observation.Outcome == "" || observation.Channel != channel {
return UpdateCheckObservation{Channel: channel, Outcome: UpdateCheckOutcomeNotChecked}
}
return observation
}

View file

@ -0,0 +1,144 @@
package updates
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// issue2285Server serves a fixed status and body for the release-list path.
func issue2285Server(t *testing.T, status int, body string) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
t.Setenv("PULSE_UPDATE_SERVER", server.URL)
}
func issue2285Releases(t *testing.T, releases ...ReleaseInfo) string {
t.Helper()
body, err := json.Marshal(releases)
if err != nil {
t.Fatalf("marshal releases: %v", err)
}
return string(body)
}
func TestIssue2285LastUpdateCheckRecordsEffectiveChannelOutcome(t *testing.T) {
setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond)
stable := ReleaseInfo{TagName: "v6.4.5", PublishedAt: time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)}
preview := ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true, PublishedAt: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)}
for _, tc := range []struct {
name string
current string
channel string
status int
body string
wantOutcome string
wantAvailable bool
wantErr bool
}{
{name: "update offered", current: "6.4.1", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true},
{name: "already current", current: "6.4.5", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeUpToDate},
{name: "preview channel offered newer prerelease", current: "6.4.5", channel: "rc", status: http.StatusOK, body: issue2285Releases(t, stable, preview), wantOutcome: UpdateCheckOutcomeAvailable, wantAvailable: true},
{name: "no stable release for channel", current: "6.4.5-rc.3", channel: "stable", status: http.StatusOK, body: issue2285Releases(t, preview), wantOutcome: UpdateCheckOutcomeNoRelease},
{name: "malformed metadata", current: "6.4.1", channel: "stable", status: http.StatusOK, body: `{"message":"not a list"}`, wantOutcome: UpdateCheckOutcomeMetadataError, wantErr: true},
{name: "server error", current: "6.4.1", channel: "stable", status: http.StatusBadGateway, body: `bad gateway`, wantOutcome: UpdateCheckOutcomeNetworkError, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
withBuildVersion(t, tc.current)
issue2285Server(t, tc.status, tc.body)
manager := NewManager(&config.Config{UpdateChannel: tc.channel})
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeNotChecked || got.Channel != tc.channel || got.Available {
t.Fatalf("before any check LastUpdateCheck = %+v, want not_checked on %s", got, tc.channel)
}
_, err := manager.CheckForUpdates(context.Background())
if (err != nil) != tc.wantErr {
t.Fatalf("CheckForUpdates error = %v, wantErr %v", err, tc.wantErr)
}
got := manager.LastUpdateCheck()
if got.Outcome != tc.wantOutcome || got.Available != tc.wantAvailable || got.Channel != tc.channel || got.CheckedAt.IsZero() {
t.Fatalf("LastUpdateCheck = %+v, want outcome %s available %v on %s", got, tc.wantOutcome, tc.wantAvailable, tc.channel)
}
})
}
}
func TestIssue2285PreviewOfOtherChannelDoesNotReplaceObservation(t *testing.T) {
setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond)
withBuildVersion(t, "6.4.5")
issue2285Server(t, http.StatusOK, issue2285Releases(t,
ReleaseInfo{TagName: "v6.4.5"},
ReleaseInfo{TagName: "v6.4.6-rc.1", Prerelease: true},
))
manager := NewManager(&config.Config{UpdateChannel: "stable"})
if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable"); err != nil {
t.Fatalf("explicit stable check: %v", err)
}
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeUpToDate || got.Available {
t.Fatalf("explicit check on the saved channel = %+v, want up_to_date", got)
}
// The settings UI can preview the preview channel before saving it. That
// offer is not what this install is being offered, so it must not leak
// into the observation telemetry reports.
if _, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc"); err != nil {
t.Fatalf("preview-channel check: %v", err)
}
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeUpToDate || got.Available || got.Channel != "stable" {
t.Fatalf("after previewing rc LastUpdateCheck = %+v, want stable up_to_date unchanged", got)
}
}
func TestIssue2285SourceBuildReportsSkipped(t *testing.T) {
withBuildVersion(t, "6.4.0")
markerPath := "BUILD_FROM_SOURCE"
if err := os.WriteFile(markerPath, []byte("1"), 0o644); err != nil {
t.Fatalf("write %s: %v", markerPath, err)
}
t.Cleanup(func() { _ = os.Remove(markerPath) })
manager := NewManager(&config.Config{UpdateChannel: "stable"})
if _, err := manager.CheckForUpdates(context.Background()); err != nil {
t.Fatalf("CheckForUpdates: %v", err)
}
if got := manager.LastUpdateCheck(); got.Outcome != UpdateCheckOutcomeSkipped || got.Available {
t.Fatalf("source build LastUpdateCheck = %+v, want skipped", got)
}
}
func TestIssue2285UpdateCheckErrorOutcomeClassification(t *testing.T) {
for _, tc := range []struct {
err error
want string
}{
{withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, context.DeadlineExceeded), UpdateCheckOutcomeNetworkError},
{withUpdateCheckOutcome(UpdateCheckOutcomeMetadataError, errGitHubRateLimited), UpdateCheckOutcomeRateLimited},
{errGitHubRateLimited, UpdateCheckOutcomeRateLimited},
{context.Canceled, UpdateCheckOutcomeError},
} {
if got := updateCheckErrorOutcome(tc.err); got != tc.want {
t.Fatalf("updateCheckErrorOutcome(%v) = %q, want %q", tc.err, got, tc.want)
}
}
if withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, nil) != nil {
t.Fatal("withUpdateCheckOutcome(nil) must stay nil")
}
tagged := withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, context.DeadlineExceeded)
if tagged.Error() != context.DeadlineExceeded.Error() {
t.Fatalf("tagged error message = %q, want the wrapped message unchanged", tagged.Error())
}
}

View file

@ -223,6 +223,7 @@ type Manager struct {
closeOnce sync.Once
heartbeatWg sync.WaitGroup
closed bool
lastCheck UpdateCheckObservation // most recent effective-channel check
// proCredentialSource lazily supplies download-broker credentials for the
// compiled Pro binary (SetProUpdateCredentialSource, wired at startup).
// Nil on the community binary.
@ -363,6 +364,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
// Get current version first to auto-detect channel if needed
currentInfo, err := GetCurrentVersion()
if err != nil {
m.recordUpdateCheck(m.resolveChannel("", nil), strings.TrimSpace(channel) == "", UpdateCheckOutcomeError, false)
m.updateStatus("error", 0, "Failed to get current version")
return nil, fmt.Errorf("failed to get current version: %w", err)
}
@ -370,6 +372,9 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
// Track whether an explicit channel override was provided.
explicitChannelProvided := strings.TrimSpace(channel) != ""
channel = m.resolveChannel(channel, currentInfo)
// Only checks on the install's own channel describe what it is offered;
// an explicit override that happens to match still counts.
effectiveChannel := !explicitChannelProvided || channel == m.resolveChannel("", currentInfo)
// Don't use cache when channel is explicitly provided (UI might have changed it)
// But DO use cache for auto-detected or default channels
@ -402,6 +407,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
m.recordUpdateCheck(channel, effectiveChannel, UpdateCheckOutcomeSkipped, false)
m.updateStatus("idle", 0, "Updates not available for source builds")
return info, nil
}
@ -409,6 +415,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
// Parse current version first
currentVer, err := ParseVersion(currentInfo.Version)
if err != nil {
m.recordUpdateCheck(channel, effectiveChannel, UpdateCheckOutcomeError, false)
m.updateStatus("error", 0, "Invalid current version")
return nil, fmt.Errorf("failed to parse current version: %w", err)
}
@ -419,9 +426,11 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
if edition.IsPro() {
info, proErr := m.checkProUpdates(ctx, channel, currentInfo, currentVer)
if proErr != nil {
m.recordUpdateCheck(channel, effectiveChannel, updateCheckErrorOutcome(proErr), false)
m.updateStatus("error", 0, "Failed to check for Pulse Pro updates", proErr)
return nil, proErr
}
m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available)
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
@ -443,6 +452,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
if err != nil {
if errors.Is(err, errGitHubRateLimited) {
log.Warn().Err(err).Str("channel", channel).Msg("GitHub rate limit encountered while checking for updates")
m.recordUpdateCheck(channel, effectiveChannel, UpdateCheckOutcomeRateLimited, false)
if options.Force {
m.updateStatus("error", 0, "Fresh update check unavailable", err)
@ -473,6 +483,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
// Check if this is a "no releases found" error - handle gracefully
if strings.Contains(err.Error(), "no releases found") {
// No releases available for this channel - return "no update available"
m.recordUpdateCheck(channel, effectiveChannel, UpdateCheckOutcomeNoRelease, false)
info := &UpdateInfo{
Available: false,
CurrentVersion: currentInfo.Version,
@ -488,6 +499,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
return info, nil
}
// For other errors, return the error
m.recordUpdateCheck(channel, effectiveChannel, updateCheckErrorOutcome(err), false)
m.updateStatus("error", 0, "Failed to check for updates", err)
return nil, err
}
@ -495,6 +507,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
latestVer, err := ParseVersion(release.TagName)
if err != nil {
parseErr := fmt.Errorf("failed to parse latest version: %w", err)
m.recordUpdateCheck(channel, effectiveChannel, UpdateCheckOutcomeMetadataError, false)
m.updateStatus("error", 0, "Invalid latest version", parseErr)
return nil, parseErr
}
@ -537,6 +550,7 @@ func (m *Manager) CheckForUpdatesWithOptions(ctx context.Context, options Update
}
info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major)
m.recordUpdateCheck(channel, effectiveChannel, availabilityOutcome(info.Available), info.Available)
// Cache the result (only if using saved channel)
if useCache {
@ -898,7 +912,7 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
"User-Agent": "Pulse-Update-Checker",
}, "fetch GitHub releases")
if err != nil {
return nil, fmt.Errorf("failed to fetch releases: %w", err)
return nil, withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, fmt.Errorf("failed to fetch releases: %w", err))
}
defer resp.Body.Close()
@ -909,7 +923,7 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
detail = resp.Status
}
if strings.TrimSpace(os.Getenv("PULSE_UPDATE_SERVER")) != "" {
return nil, fmt.Errorf("update server returned status %d: %s", resp.StatusCode, detail)
return nil, withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, fmt.Errorf("update server returned status %d: %s", resp.StatusCode, detail))
}
log.Warn().
@ -933,7 +947,7 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
if detail == "" {
detail = resp.Status
}
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail)
return nil, withUpdateCheckOutcome(UpdateCheckOutcomeNetworkError, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail))
}
releases, err := decodeReleaseList(resp)
@ -956,9 +970,9 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
Msg("Got release info from Atom fallback")
return feedRelease, nil
}
return nil, fmt.Errorf("failed to decode releases: %w; release feed fallback failed: %v", err, feedErr)
return nil, withUpdateCheckOutcome(UpdateCheckOutcomeMetadataError, fmt.Errorf("failed to decode releases: %w; release feed fallback failed: %v", err, feedErr))
}
return nil, fmt.Errorf("failed to decode releases: %w", err)
return nil, withUpdateCheckOutcome(UpdateCheckOutcomeMetadataError, fmt.Errorf("failed to decode releases: %w", err))
}
// Find latest release based on channel, selecting by version rather than
@ -1034,6 +1048,13 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string
return nil, fmt.Errorf("no releases found for channel %s", channel)
}
func availabilityOutcome(available bool) string {
if available {
return UpdateCheckOutcomeAvailable
}
return UpdateCheckOutcomeUpToDate
}
func (m *Manager) resolveChannel(requested string, currentInfo *VersionInfo) string {
if canonical, ok := config.CanonicalUpdateChannel(requested); ok {
return canonical

View file

@ -93,8 +93,11 @@ USER_BASE_CATEGORY_FIELDS = (
("time_to_first_monitored_resource_bucket", "Time to first monitored resource"),
("estate_size_bucket", "Estate size"),
("update_last_failure_category", "Last update failure category"),
("update_channel", "Configured update channel (schema v18+)"),
("update_check_outcome", "Last update check outcome (schema v18+)"),
)
USER_BASE_BOOL_FIELDS = (
("update_available", "Last update check offered a newer release"),
("auth_configured", "Authentication configured"),
("monitoring_active", "Monitoring currently active"),
("outcome_observed_30d", "Operational outcome observed"),

View file

@ -195,6 +195,7 @@ class TelemetryAdoptionReportTest(unittest.TestCase):
self.assertTrue(recent_count_fields <= projected)
self.assertIn("alert_ai_enabled", projected)
self.assertIn("update_last_failure_category", projected)
self.assertTrue({"update_channel", "update_check_outcome", "update_available"} <= projected)
self.assertTrue(set(report.SERVICE_HEALTH_ROW_FIELDS) <= projected)
self.assertNotIn("business_estate", projected)

View file

@ -10,7 +10,7 @@ import {
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 17;
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 18;
type WorkerFixtures = {
authStorageStatePath: string;
@ -66,6 +66,9 @@ async function readTelemetryPreview(page: Page) {
active_alerts_warning: number;
alerts_resolution_under_15m_30d: number;
alert_active_state_persistence_degraded_tenants: number;
update_channel: string;
update_check_outcome: string;
update_available: boolean;
};
}
@ -137,6 +140,22 @@ test.describe("Telemetry disclosure", () => {
expect(
initialPreview.alert_active_state_persistence_degraded_tenants,
).toBeGreaterThanOrEqual(0);
// Schema v18 update discovery is always present and closed (#2285).
expect(["stable", "rc", "unknown"]).toContain(
initialPreview.update_channel,
);
expect([
"not_checked",
"up_to_date",
"available",
"no_release",
"rate_limited",
"network_error",
"metadata_error",
"skipped",
"error",
]).toContain(initialPreview.update_check_outcome);
expect(typeof initialPreview.update_available).toBe("boolean");
await page.setViewportSize({ width: 390, height: 844 });
await expect(