Merge frozen Pulse upstream for coordination

Incorporate the origin/main state captured at the start of the candidate batch while preserving unpublished canonical integration history. Keep the newer local browser receipt where the two already-verified lines overlap.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-02 13:27:45 +01:00
21 changed files with 1257 additions and 239 deletions
+30 -2
View File
@@ -37,7 +37,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Field | Example | Purpose |
|-------|---------|---------|
| Schema version | `16` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `17` | 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 |
@@ -133,6 +133,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Notification failures server error 7d (schema v15) | `0` | Count terminal failures classified locally as destination HTTP 5xx server errors without sending response content, destination identity, or provider identity |
| Notification failures unknown 7d (schema v5) | `0` | Count terminal failures that do not match another fixed class without sending raw errors |
| Alert AI enabled | `true`/`false` | See whether AI analysis on alert firing is switched on, without sending alert content, resource identifiers, or analysis text |
| AI provider class | `local` | See one closed bucket for how the Patrol (or default) model is reached: `none`, `local` (Ollama or a private-network OpenAI-compatible endpoint), `cloud_byok` (a hosted provider with your own key), `cloud_subscription` (the Codex or Claude CLI subscription route), or `hosted_legacy` (the retired Pulse-hosted route), without sending the provider, model name, endpoint, or account |
| Relay enabled | `true`/`false` | See whether remote-access features are being used |
| SSO enabled | `true`/`false` | See whether single-sign-on support is being used |
| Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used |
@@ -199,6 +200,22 @@ Every field is listed below with the reason it exists. Nothing else is included
| Pulse Intelligence Patrol investigations 30d | `3` | Count findings investigated by Patrol in the current 30-day telemetry window without sending finding IDs, resource IDs, or details |
| Pulse Intelligence Patrol resolved findings 30d | `2` | Count findings resolved or fix-verified in the current 30-day telemetry window without sending finding IDs, resource IDs, fix details, or verification detail |
| Pulse Intelligence Patrol blocked cause | `provider_not_configured` | See one fixed machine cause code when Patrol is enabled but blocked from running, so an install whose Patrol can never work is distinguishable from one that runs and finds nothing, without sending blocked-reason text, provider endpoints, model names, error text, or configuration |
| Pulse Intelligence Patrol autonomy level | `approval` | See the effective Patrol mode after licence and Autopilot acknowledgement gating (`monitor`, `approval`, `assisted`, or `full`) without sending acknowledgement text, actors, or timestamps |
| Pulse Intelligence Patrol input tokens bucket 30d | `5m_20m` | See one coarse bucket (`zero`, `under_1m`, `1m_5m`, `5m_20m`, `20m_plus`) for Patrol prompt-token volume in the current 30-day telemetry window without sending exact token counts, prices, providers, models, prompts, or responses |
| Pulse Intelligence Patrol output tokens bucket 30d | `100k_500k` | See one coarse bucket (`zero`, `under_100k`, `100k_500k`, `500k_2m`, `2m_plus`) for Patrol completion-token volume in the current 30-day telemetry window without sending exact token counts, prices, providers, models, prompts, or responses |
| Pulse Intelligence Patrol investigation outcome fix verified 30d | `1` | Count investigated findings whose latest outcome is a verified fix in the current 30-day telemetry window without sending finding IDs, resource IDs, or fix details |
| Pulse Intelligence Patrol investigation outcome fix queued 30d | `2` | Count investigated findings whose latest outcome is a fix awaiting approval without sending finding IDs, resource IDs, or plan details |
| Pulse Intelligence Patrol investigation outcome fix executed 30d | `0` | Count investigated findings whose latest outcome is a fix that ran and is awaiting verification without sending finding IDs, resource IDs, command text, or output |
| Pulse Intelligence Patrol investigation outcome fix rejected 30d | `1` | Count investigated findings whose latest outcome is an operator-rejected fix without sending finding IDs, resource IDs, actors, or reasons |
| Pulse Intelligence Patrol investigation outcome fix failed 30d | `0` | Count investigated findings whose latest outcome is a fix that failed or failed verification without sending finding IDs, resource IDs, command text, error text, or output |
| Pulse Intelligence Patrol investigation outcome fix verification unknown 30d | `3` | Count investigated findings whose latest fix ran but could not be independently verified without sending finding IDs, resource IDs, or verification detail |
| Pulse Intelligence Patrol investigation outcome resolved 30d | `1` | Count investigated findings whose latest outcome is resolved without sending finding IDs, resource IDs, or details |
| Pulse Intelligence Patrol investigation outcome needs attention 30d | `4` | Count investigated findings whose latest outcome requires operator attention without sending finding IDs, resource IDs, or the investigation text |
| Pulse Intelligence Patrol investigation outcome cannot fix 30d | `2` | Count investigated findings Patrol concluded it cannot fix without sending finding IDs, resource IDs, or the investigation text |
| Pulse Intelligence Patrol investigation outcome timed out 30d | `0` | Count investigated findings whose latest investigation timed out without sending finding IDs, resource IDs, or provider detail |
| Pulse Intelligence Patrol investigation outcome in progress 30d | `1` | Count investigated findings whose investigation is still pending or running at send time without sending finding IDs, resource IDs, or session IDs |
| Pulse Intelligence Patrol investigation outcome failed 30d | `0` | Count investigated findings whose investigation errored out before recording an outcome without sending finding IDs, resource IDs, or error text |
| Pulse Intelligence Patrol investigation outcome other 30d | `0` | Count investigated findings that finished without a typed outcome without sending finding IDs, resource IDs, or details |
| Pulse Intelligence external agent enabled | `true`/`false` | See whether at least one token can use the external Pulse Intelligence agent/MCP surface without sending token counts, names, scopes, or values |
| Pulse Intelligence external agent used 30d | `true`/`false` | See whether an external-agent-capable API token reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, or request details |
| Pulse Intelligence MCP adapter used 30d | `true`/`false` | See whether the `pulse-mcp` adapter reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, prompts, or request details |
@@ -287,7 +304,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 16. Schema v16 adds four
The current telemetry contract is schema version 17. 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
@@ -295,6 +312,17 @@ locally and includes only their rolling 30-day totals in the existing heartbeat;
there is no browser identifier, raw event stream, guest identity, selected
range, cursor coordinate, value, route, or interaction timing.
Schema v17 adds four closed Patrol posture fields and thirteen outcome counts.
The provider class says only how the Patrol model is reached (local,
bring-your-own-key cloud, CLI subscription, the retired hosted route, or none);
it is derived locally from the configured route and never carries the
provider, model name, endpoint, or account. The autonomy level is the
effective mode Pulse enforces. The two token fields are coarse buckets of the
existing local usage ledger, so exact token counts, prices, providers, and
models stay on the install. The outcome counts partition the findings already
counted as investigated; no finding, resource, session, or action identity is
added.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@@ -9944,6 +9944,40 @@
}
]
},
{
"id": "patrol-telemetry-provider-cost-outcome-visibility",
"summary": "The 2026-09-01 Patrol assessment could not answer from telemetry which share of Patrol installs run local, cloud, or hosted models, what Patrol costs an install per month (issue 1789 estimates about 104k input tokens per full run at about 5.5 runs a day, above the Pro fee on non-Flash tiers), how the 240 of 255 fleet investigations that produced no plan actually ended, or which effective Patrol autonomy level an install runs. Schema v17 adds a closed provider-class bucket, the effective autonomy level, coarse 30-day Patrol input and output token buckets, and per-outcome investigation counts, all sourced from existing local usage and finding records with no model names, endpoints, prompts, finding IDs, or resource IDs.",
"owner": "project-owner",
"status": "triaged",
"recorded_at": "2026-09-01",
"lane_ids": [
"L6",
"L14"
],
"subsystem_ids": [
"patrol-intelligence",
"security-privacy"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 3,
"evidence": [
{
"repo": "pulse",
"path": "internal/api/telemetry_pulse_intelligence.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/telemetry/telemetry.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "pkg/server/telemetry_pulse_intelligence.go",
"kind": "file"
}
]
},
{
"id": "release-train-exact-candidate-promotion",
"summary": "Stable promotions built whatever the dispatch branch was at that second: the resolver checked that HEAD descends from the promoted RC but not that its content matches, so v6.4.0 shipped 64 changed files that v6.4.0-rc.12 never soaked, and every v6 version was mapped to main, which moves every few minutes under the autonomous maintainer. The release train needs a branch per train declared in the control plane, a resolver rule that refuses stable content beyond release metadata without a recorded hotfix reason, and a seven day soak for minor releases so the two-week cadence carries a real soak.",
@@ -840,6 +840,14 @@ particular agent update, profile rollout, host command, registration, or fleet
operation succeeded. Lifecycle surfaces must keep reading update readiness and
continuity from the updater, installer, connection ledger, and agent runtime
state instead of inferring it from outbound usage telemetry.
Schema v17 Patrol posture telemetry assembled through `internal/api/` (the
effective Patrol autonomy level, the Patrol model route class, coarse Patrol
token buckets, and investigation outcome counts) is likewise
lifecycle-adjacent adoption analytics only. It reads the AI service, the local
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.
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
@@ -10022,6 +10022,23 @@ field names and integer types. The receiver clamps them like every other count.
No guest, user, route, selected range, cursor coordinate/value, timing, or
browser identifier is part of either API or heartbeat contract.
### Patrol provider class, autonomy, cost buckets, and outcome counts are a closed contract at schema v17
Schema v17 adds `ai_provider_class`, `pulse_intelligence_patrol_autonomy_level`,
`pulse_intelligence_patrol_input_tokens_bucket_30d`, and
`pulse_intelligence_patrol_output_tokens_bucket_30d` as closed strings, plus
thirteen `pulse_intelligence_patrol_investigation_outcome_*_30d` integers. The
Go sender, the Settings preview interface, and the Pulse Pro receiver keep the
same field names and types; `scripts/check_telemetry_schema_parity.py` remains
the executable proof. The strings are always present (never omitted) so an
empty value can only mean a pre-v17 sender, which the receiver stores as
`unknown`. The outcome counters partition
`pulse_intelligence_patrol_investigations_30d` exactly: every investigated
finding lands in one bucket chosen by its current investigation outcome, or by
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.
### Per-tenant resource stores are released on offboarding and shutdown
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the
@@ -2410,7 +2410,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 v16. Schema v8 added content-free
The active outbound contract is schema v17. 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`
@@ -2429,6 +2429,23 @@ stored only as a bounded UTC-day count, and exported only as a rolling 30-day
total. The contract forbids raw browser events, event-level clickstream data,
guest/user identity, routes, selected ranges, cursor coordinates or values,
interaction timing, and browser identity.
Schema v17 adds four closed Patrol posture strings and thirteen investigation
outcome counts so the fleet can be read for local versus cloud model routes,
approximate Patrol cost, effective autonomy mode, and how investigations end.
`ai_provider_class` is derived locally from the configured Patrol or default
model route (`none`, `local`, `cloud_byok`, `cloud_subscription`,
`hosted_legacy`, `unknown`) with a syntactic private-host check on
operator-supplied OpenAI-compatible endpoints; it never resolves DNS and never
carries the provider ID, model name, endpoint, or account.
`pulse_intelligence_patrol_autonomy_level` is the effective level after
licence and Autopilot gating, bounded to `monitor`, `approval`, `assisted`, or
`full`. The two token fields are closed buckets of the existing local usage
ledger's Patrol events; exact token counts, prices, providers, and models stay
on the install. The outcome counts partition the same findings already counted
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.
The earlier draft schema-v8 `business_estate` field was reverted and must not
remain in the license server's accepted ping struct merely because a private receiver build
and database migration briefly carried it. Existing deployed databases need no
@@ -997,6 +997,13 @@ recovery scope, or a storage/recovery-owned secret source.
aggregate milestones and do not represent workload history data, backup
history, recovery points, retention policy, restore evidence, or storage
provider state; storage/recovery surfaces must not consume them as such.
The schema v17 Patrol posture fields assembled through `internal/api/`
(Patrol autonomy level, model route class, coarse token buckets, and
investigation outcome counts) are the same kind of adjacent privacy/API
telemetry: closed buckets derived from AI configuration, the local usage
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.
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
+30 -2
View File
@@ -37,7 +37,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Field | Example | Purpose |
|-------|---------|---------|
| Schema version | `16` | Identify the exact payload contract so old and new signals are not mixed silently |
| Schema version | `17` | 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 |
@@ -133,6 +133,7 @@ Every field is listed below with the reason it exists. Nothing else is included
| Notification failures server error 7d (schema v15) | `0` | Count terminal failures classified locally as destination HTTP 5xx server errors without sending response content, destination identity, or provider identity |
| Notification failures unknown 7d (schema v5) | `0` | Count terminal failures that do not match another fixed class without sending raw errors |
| Alert AI enabled | `true`/`false` | See whether AI analysis on alert firing is switched on, without sending alert content, resource identifiers, or analysis text |
| AI provider class | `local` | See one closed bucket for how the Patrol (or default) model is reached: `none`, `local` (Ollama or a private-network OpenAI-compatible endpoint), `cloud_byok` (a hosted provider with your own key), `cloud_subscription` (the Codex or Claude CLI subscription route), or `hosted_legacy` (the retired Pulse-hosted route), without sending the provider, model name, endpoint, or account |
| Relay enabled | `true`/`false` | See whether remote-access features are being used |
| SSO enabled | `true`/`false` | See whether single-sign-on support is being used |
| Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used |
@@ -199,6 +200,22 @@ Every field is listed below with the reason it exists. Nothing else is included
| Pulse Intelligence Patrol investigations 30d | `3` | Count findings investigated by Patrol in the current 30-day telemetry window without sending finding IDs, resource IDs, or details |
| Pulse Intelligence Patrol resolved findings 30d | `2` | Count findings resolved or fix-verified in the current 30-day telemetry window without sending finding IDs, resource IDs, fix details, or verification detail |
| Pulse Intelligence Patrol blocked cause | `provider_not_configured` | See one fixed machine cause code when Patrol is enabled but blocked from running, so an install whose Patrol can never work is distinguishable from one that runs and finds nothing, without sending blocked-reason text, provider endpoints, model names, error text, or configuration |
| Pulse Intelligence Patrol autonomy level | `approval` | See the effective Patrol mode after licence and Autopilot acknowledgement gating (`monitor`, `approval`, `assisted`, or `full`) without sending acknowledgement text, actors, or timestamps |
| Pulse Intelligence Patrol input tokens bucket 30d | `5m_20m` | See one coarse bucket (`zero`, `under_1m`, `1m_5m`, `5m_20m`, `20m_plus`) for Patrol prompt-token volume in the current 30-day telemetry window without sending exact token counts, prices, providers, models, prompts, or responses |
| Pulse Intelligence Patrol output tokens bucket 30d | `100k_500k` | See one coarse bucket (`zero`, `under_100k`, `100k_500k`, `500k_2m`, `2m_plus`) for Patrol completion-token volume in the current 30-day telemetry window without sending exact token counts, prices, providers, models, prompts, or responses |
| Pulse Intelligence Patrol investigation outcome fix verified 30d | `1` | Count investigated findings whose latest outcome is a verified fix in the current 30-day telemetry window without sending finding IDs, resource IDs, or fix details |
| Pulse Intelligence Patrol investigation outcome fix queued 30d | `2` | Count investigated findings whose latest outcome is a fix awaiting approval without sending finding IDs, resource IDs, or plan details |
| Pulse Intelligence Patrol investigation outcome fix executed 30d | `0` | Count investigated findings whose latest outcome is a fix that ran and is awaiting verification without sending finding IDs, resource IDs, command text, or output |
| Pulse Intelligence Patrol investigation outcome fix rejected 30d | `1` | Count investigated findings whose latest outcome is an operator-rejected fix without sending finding IDs, resource IDs, actors, or reasons |
| Pulse Intelligence Patrol investigation outcome fix failed 30d | `0` | Count investigated findings whose latest outcome is a fix that failed or failed verification without sending finding IDs, resource IDs, command text, error text, or output |
| Pulse Intelligence Patrol investigation outcome fix verification unknown 30d | `3` | Count investigated findings whose latest fix ran but could not be independently verified without sending finding IDs, resource IDs, or verification detail |
| Pulse Intelligence Patrol investigation outcome resolved 30d | `1` | Count investigated findings whose latest outcome is resolved without sending finding IDs, resource IDs, or details |
| Pulse Intelligence Patrol investigation outcome needs attention 30d | `4` | Count investigated findings whose latest outcome requires operator attention without sending finding IDs, resource IDs, or the investigation text |
| Pulse Intelligence Patrol investigation outcome cannot fix 30d | `2` | Count investigated findings Patrol concluded it cannot fix without sending finding IDs, resource IDs, or the investigation text |
| Pulse Intelligence Patrol investigation outcome timed out 30d | `0` | Count investigated findings whose latest investigation timed out without sending finding IDs, resource IDs, or provider detail |
| Pulse Intelligence Patrol investigation outcome in progress 30d | `1` | Count investigated findings whose investigation is still pending or running at send time without sending finding IDs, resource IDs, or session IDs |
| Pulse Intelligence Patrol investigation outcome failed 30d | `0` | Count investigated findings whose investigation errored out before recording an outcome without sending finding IDs, resource IDs, or error text |
| Pulse Intelligence Patrol investigation outcome other 30d | `0` | Count investigated findings that finished without a typed outcome without sending finding IDs, resource IDs, or details |
| Pulse Intelligence external agent enabled | `true`/`false` | See whether at least one token can use the external Pulse Intelligence agent/MCP surface without sending token counts, names, scopes, or values |
| Pulse Intelligence external agent used 30d | `true`/`false` | See whether an external-agent-capable API token reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, or request details |
| Pulse Intelligence MCP adapter used 30d | `true`/`false` | See whether the `pulse-mcp` adapter reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, prompts, or request details |
@@ -287,7 +304,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 16. Schema v16 adds four
The current telemetry contract is schema version 17. 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
@@ -295,6 +312,17 @@ locally and includes only their rolling 30-day totals in the existing heartbeat;
there is no browser identifier, raw event stream, guest identity, selected
range, cursor coordinate, value, route, or interaction timing.
Schema v17 adds four closed Patrol posture fields and thirteen outcome counts.
The provider class says only how the Patrol model is reached (local,
bring-your-own-key cloud, CLI subscription, the retired hosted route, or none);
it is derived locally from the configured route and never carries the
provider, model name, endpoint, or account. The autonomy level is the
effective mode Pulse enforces. The two token fields are coarse buckets of the
existing local usage ledger, so exact token counts, prices, providers, and
models stay on the install. The outcome counts partition the findings already
counted as investigated; no finding, resource, session, or action identity is
added.
#### Server-side handling and retention
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
@@ -58,6 +58,7 @@ const mockTelemetryPreviewPayload = {
notifications_enabled: false,
ai_actions_enabled: false,
alert_ai_enabled: false,
ai_provider_class: 'none',
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,
@@ -171,6 +172,22 @@ const mockTelemetryPreviewPayload = {
pulse_intelligence_patrol_investigations_30d: 0,
pulse_intelligence_patrol_resolved_findings_30d: 0,
pulse_intelligence_patrol_blocked_cause: undefined,
pulse_intelligence_patrol_autonomy_level: 'monitor',
pulse_intelligence_patrol_input_tokens_bucket_30d: 'zero',
pulse_intelligence_patrol_output_tokens_bucket_30d: 'zero',
pulse_intelligence_patrol_investigation_outcome_fix_verified_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_queued_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_executed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_rejected_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_failed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_verification_unknown_30d: 0,
pulse_intelligence_patrol_investigation_outcome_resolved_30d: 0,
pulse_intelligence_patrol_investigation_outcome_needs_attention_30d: 0,
pulse_intelligence_patrol_investigation_outcome_cannot_fix_30d: 0,
pulse_intelligence_patrol_investigation_outcome_timed_out_30d: 0,
pulse_intelligence_patrol_investigation_outcome_in_progress_30d: 0,
pulse_intelligence_patrol_investigation_outcome_failed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_other_30d: 0,
pulse_intelligence_external_agent_enabled: false,
pulse_intelligence_external_agent_used_30d: false,
pulse_intelligence_mcp_adapter_used_30d: false,
+17
View File
@@ -59,6 +59,7 @@ export interface TelemetryPingPreview {
notifications_enabled: boolean;
ai_actions_enabled: boolean;
alert_ai_enabled: boolean;
ai_provider_class: string;
active_alerts: number;
relay_enabled: boolean;
sso_enabled: boolean;
@@ -173,6 +174,22 @@ export interface TelemetryPingPreview {
pulse_intelligence_patrol_investigations_30d: number;
pulse_intelligence_patrol_resolved_findings_30d: number;
pulse_intelligence_patrol_blocked_cause?: string;
pulse_intelligence_patrol_autonomy_level: string;
pulse_intelligence_patrol_input_tokens_bucket_30d: string;
pulse_intelligence_patrol_output_tokens_bucket_30d: string;
pulse_intelligence_patrol_investigation_outcome_fix_verified_30d: number;
pulse_intelligence_patrol_investigation_outcome_fix_queued_30d: number;
pulse_intelligence_patrol_investigation_outcome_fix_executed_30d: number;
pulse_intelligence_patrol_investigation_outcome_fix_rejected_30d: number;
pulse_intelligence_patrol_investigation_outcome_fix_failed_30d: number;
pulse_intelligence_patrol_investigation_outcome_fix_verification_unknown_30d: number;
pulse_intelligence_patrol_investigation_outcome_resolved_30d: number;
pulse_intelligence_patrol_investigation_outcome_needs_attention_30d: number;
pulse_intelligence_patrol_investigation_outcome_cannot_fix_30d: number;
pulse_intelligence_patrol_investigation_outcome_timed_out_30d: number;
pulse_intelligence_patrol_investigation_outcome_in_progress_30d: number;
pulse_intelligence_patrol_investigation_outcome_failed_30d: number;
pulse_intelligence_patrol_investigation_outcome_other_30d: number;
pulse_intelligence_external_agent_enabled: boolean;
pulse_intelligence_external_agent_used_30d: boolean;
pulse_intelligence_mcp_adapter_used_30d: boolean;
@@ -64,6 +64,7 @@ const buildTelemetryPreviewPayload = (
notifications_enabled: false,
ai_actions_enabled: false,
alert_ai_enabled: false,
ai_provider_class: 'none',
active_alerts: 0,
relay_enabled: false,
sso_enabled: false,
@@ -177,6 +178,22 @@ const buildTelemetryPreviewPayload = (
pulse_intelligence_patrol_investigations_30d: 0,
pulse_intelligence_patrol_resolved_findings_30d: 0,
pulse_intelligence_patrol_blocked_cause: undefined,
pulse_intelligence_patrol_autonomy_level: 'monitor',
pulse_intelligence_patrol_input_tokens_bucket_30d: 'zero',
pulse_intelligence_patrol_output_tokens_bucket_30d: 'zero',
pulse_intelligence_patrol_investigation_outcome_fix_verified_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_queued_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_executed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_rejected_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_failed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_fix_verification_unknown_30d: 0,
pulse_intelligence_patrol_investigation_outcome_resolved_30d: 0,
pulse_intelligence_patrol_investigation_outcome_needs_attention_30d: 0,
pulse_intelligence_patrol_investigation_outcome_cannot_fix_30d: 0,
pulse_intelligence_patrol_investigation_outcome_timed_out_30d: 0,
pulse_intelligence_patrol_investigation_outcome_in_progress_30d: 0,
pulse_intelligence_patrol_investigation_outcome_failed_30d: 0,
pulse_intelligence_patrol_investigation_outcome_other_30d: 0,
pulse_intelligence_external_agent_enabled: false,
pulse_intelligence_external_agent_used_30d: false,
pulse_intelligence_mcp_adapter_used_30d: false,
@@ -49,6 +49,21 @@ func pulseIntelligencePatrolBlockedCauseForTelemetry(status ai.PatrolStatus) str
return cause
}
// pulseIntelligencePatrolAutonomyLevel returns the effective Patrol autonomy
// level for the default tenant after licence and Autopilot acknowledgement
// gating, bounded to the four released modes. An install without an AI
// service reports monitor, which is what the runtime enforces in that state.
func (r *Router) pulseIntelligencePatrolAutonomyLevel() string {
if r == nil || r.aiSettingsHandler == nil {
return telemetry.NormalizePatrolAutonomyLevelForTelemetry("")
}
svc := r.aiSettingsHandler.GetAIService(context.Background())
if svc == nil {
return telemetry.NormalizePatrolAutonomyLevelForTelemetry("")
}
return telemetry.NormalizePatrolAutonomyLevelForTelemetry(svc.GetEffectivePatrolAutonomyLevel())
}
// ApplyUpdateTelemetrySnapshot adds router-owned, content-free update funnel
// counters to the outbound usage telemetry snapshot.
func (r *Router) ApplyUpdateTelemetrySnapshot(s *telemetry.Snapshot, now time.Time) {
@@ -67,6 +82,7 @@ func (r *Router) GetPulseIntelligenceActionTelemetry(since time.Time) telemetry.
return snapshot
}
snapshot.PatrolBlockedCause = r.pulseIntelligencePatrolBlockedCause()
snapshot.PatrolAutonomyLevel = r.pulseIntelligencePatrolAutonomyLevel()
if r.resourceHandlers == nil {
return snapshot
}
@@ -0,0 +1,19 @@
package api
import (
"testing"
"time"
)
func TestRouterPulseIntelligencePatrolAutonomyLevelDefaultsToMonitor(t *testing.T) {
var nilRouter *Router
if got := nilRouter.pulseIntelligencePatrolAutonomyLevel(); got != "monitor" {
t.Fatalf("nil router autonomy level = %q, want monitor", got)
}
if got := (&Router{}).pulseIntelligencePatrolAutonomyLevel(); got != "monitor" {
t.Fatalf("router without AI settings handler autonomy level = %q, want monitor", got)
}
if got := (&Router{}).GetPulseIntelligenceActionTelemetry(time.Time{}).PatrolAutonomyLevel; got != "monitor" {
t.Fatalf("action snapshot autonomy level = %q, want monitor", got)
}
}
+330
View File
@@ -0,0 +1,330 @@
package telemetry
import (
"net"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// Schema v17 closed vocabularies. Every value below is a fixed bucket; the
// receiver canonicalizes anything outside these lists to "unknown", so a
// future sender cannot smuggle free text through these fields.
const (
// AIProviderClass buckets describe how the Patrol model is reached, never
// which provider, model, endpoint, or account it is.
AIProviderClassNone = "none"
AIProviderClassLocal = "local"
AIProviderClassCloudBYOK = "cloud_byok"
AIProviderClassCloudSubscription = "cloud_subscription"
AIProviderClassHostedLegacy = "hosted_legacy"
AIProviderClassUnknown = "unknown"
// Patrol 30-day input token buckets. Boundaries are inclusive lower and
// exclusive upper: under_1m is [1, 1M), 1m_5m is [1M, 5M), and so on.
PatrolTokenBucketZero = "zero"
PatrolInputTokensUnder1M = "under_1m"
PatrolInputTokens1M5M = "1m_5m"
PatrolInputTokens5M20M = "5m_20m"
PatrolInputTokens20MPlus = "20m_plus"
PatrolOutputTokensUnder100K = "under_100k"
PatrolOutputTokens100K500K = "100k_500k"
PatrolOutputTokens500K2M = "500k_2m"
PatrolOutputTokens2MPlus = "2m_plus"
// Investigation outcome buckets partition the findings counted by
// pulse_intelligence_patrol_investigations_30d: each investigated finding
// lands in exactly one bucket based on its current investigation state.
PatrolInvestigationOutcomeFixVerified = "fix_verified"
PatrolInvestigationOutcomeFixQueued = "fix_queued"
PatrolInvestigationOutcomeFixExecuted = "fix_executed"
PatrolInvestigationOutcomeFixRejected = "fix_rejected"
PatrolInvestigationOutcomeFixFailed = "fix_failed"
PatrolInvestigationOutcomeFixVerificationUnknown = "fix_verification_unknown"
PatrolInvestigationOutcomeResolved = "resolved"
PatrolInvestigationOutcomeNeedsAttention = "needs_attention"
PatrolInvestigationOutcomeCannotFix = "cannot_fix"
PatrolInvestigationOutcomeTimedOut = "timed_out"
PatrolInvestigationOutcomeInProgress = "in_progress"
PatrolInvestigationOutcomeFailed = "failed"
PatrolInvestigationOutcomeOther = "other"
)
// AIProviderClassValues is the closed export vocabulary for ai_provider_class.
func AIProviderClassValues() []string {
return []string{
AIProviderClassNone,
AIProviderClassLocal,
AIProviderClassCloudBYOK,
AIProviderClassCloudSubscription,
AIProviderClassHostedLegacy,
AIProviderClassUnknown,
}
}
// PatrolInputTokenBucketValues is the closed export vocabulary for
// pulse_intelligence_patrol_input_tokens_bucket_30d.
func PatrolInputTokenBucketValues() []string {
return []string{PatrolTokenBucketZero, PatrolInputTokensUnder1M, PatrolInputTokens1M5M, PatrolInputTokens5M20M, PatrolInputTokens20MPlus}
}
// PatrolOutputTokenBucketValues is the closed export vocabulary for
// pulse_intelligence_patrol_output_tokens_bucket_30d.
func PatrolOutputTokenBucketValues() []string {
return []string{PatrolTokenBucketZero, PatrolOutputTokensUnder100K, PatrolOutputTokens100K500K, PatrolOutputTokens500K2M, PatrolOutputTokens2MPlus}
}
// PatrolInvestigationOutcomeBucketValues lists every outcome bucket in the
// order the Ping fields are declared.
func PatrolInvestigationOutcomeBucketValues() []string {
return []string{
PatrolInvestigationOutcomeFixVerified,
PatrolInvestigationOutcomeFixQueued,
PatrolInvestigationOutcomeFixExecuted,
PatrolInvestigationOutcomeFixRejected,
PatrolInvestigationOutcomeFixFailed,
PatrolInvestigationOutcomeFixVerificationUnknown,
PatrolInvestigationOutcomeResolved,
PatrolInvestigationOutcomeNeedsAttention,
PatrolInvestigationOutcomeCannotFix,
PatrolInvestigationOutcomeTimedOut,
PatrolInvestigationOutcomeInProgress,
PatrolInvestigationOutcomeFailed,
PatrolInvestigationOutcomeOther,
}
}
// ClassifyAIProviderClass reduces the Patrol model route to one closed bucket.
// It classifies the route, not the credential state: an install that selected
// a cloud model and has not entered a key still reports cloud_byok, and
// pulse_intelligence_patrol_blocked_cause says whether Patrol can run. The
// provider ID, model name, endpoint, and account identity never leave the
// function.
//
// - none: AI disabled or no default/Patrol model selected.
// - local: Ollama, or an operator-supplied OpenAI-compatible endpoint whose
// host is loopback, private-range, link-local, unqualified, or a
// .local/.lan/.internal style name (llama.cpp, LM Studio, LocalAI, vLLM).
// - cloud_byok: any hosted provider reached with the operator's own key,
// including a custom OpenAI-compatible endpoint on a public host.
// - cloud_subscription: the locally authenticated Codex or Claude CLI
// subscription routes, which carry no per-token bill.
// - hosted_legacy: the retired Pulse-hosted route still selected in a
// legacy config that has not been normalized yet.
func ClassifyAIProviderClass(cfg *config.AIConfig) string {
if cfg == nil || !cfg.Enabled {
return AIProviderClassNone
}
rawModel := strings.TrimSpace(cfg.PatrolModel)
if rawModel == "" {
rawModel = strings.TrimSpace(cfg.Model)
}
if rawModel == "" {
return AIProviderClassNone
}
model := config.NormalizeQuickstartModelString(rawModel)
if model == "" {
return AIProviderClassHostedLegacy
}
provider, _ := config.ParseModelString(model)
switch strings.ToLower(strings.TrimSpace(provider)) {
case config.AIProviderOllama:
return AIProviderClassLocal
case config.AIProviderCodexSubscription, config.AIProviderClaudeSubscription:
return AIProviderClassCloudSubscription
case config.AIProviderOpenAI:
if config.IsCustomOpenAICompatibleEndpoint(cfg.OpenAIBaseURL) && endpointHostIsLocal(cfg.OpenAIBaseURL) {
return AIProviderClassLocal
}
return AIProviderClassCloudBYOK
case config.AIProviderQuickstart:
return AIProviderClassHostedLegacy
case "":
return AIProviderClassUnknown
}
if _, ok := config.LookupAIProviderDefinition(provider); ok {
return AIProviderClassCloudBYOK
}
return AIProviderClassUnknown
}
// endpointHostIsLocal reports whether a configured base URL points at a host
// an operator would run themselves. It is a syntactic check only; telemetry
// must never resolve DNS or open a connection to classify a route.
func endpointHostIsLocal(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if host == "" {
return false
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
if host == "localhost" || !strings.Contains(host, ".") {
return true
}
for _, suffix := range []string{".local", ".localdomain", ".lan", ".internal", ".home", ".home.arpa", ".localhost"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// PatrolInputTokensBucket maps a 30-day Patrol input token total to its
// closed bucket. Negative totals are treated as zero.
func PatrolInputTokensBucket(total int64) string {
switch {
case total <= 0:
return PatrolTokenBucketZero
case total < 1_000_000:
return PatrolInputTokensUnder1M
case total < 5_000_000:
return PatrolInputTokens1M5M
case total < 20_000_000:
return PatrolInputTokens5M20M
default:
return PatrolInputTokens20MPlus
}
}
// PatrolOutputTokensBucket maps a 30-day Patrol output token total to its
// closed bucket. Output volume runs roughly an order of magnitude below input,
// so the ladder is finer than the input one.
func PatrolOutputTokensBucket(total int64) string {
switch {
case total <= 0:
return PatrolTokenBucketZero
case total < 100_000:
return PatrolOutputTokensUnder100K
case total < 500_000:
return PatrolOutputTokens100K500K
case total < 2_000_000:
return PatrolOutputTokens500K2M
default:
return PatrolOutputTokens2MPlus
}
}
// NormalizePatrolAutonomyLevelForTelemetry bounds the effective Patrol
// autonomy level to the four released modes. Anything else, including an empty
// value from an install without an AI service, reports monitor, which is also
// the runtime's own fallback.
func NormalizePatrolAutonomyLevelForTelemetry(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case config.PatrolAutonomyApproval:
return config.PatrolAutonomyApproval
case config.PatrolAutonomyAssisted:
return config.PatrolAutonomyAssisted
case config.PatrolAutonomyFull:
return config.PatrolAutonomyFull
default:
return config.PatrolAutonomyMonitor
}
}
// PatrolInvestigationOutcomeBucket reduces a finding's investigation outcome
// and status to one closed bucket. The outcome wins when present; a finding
// with no recorded outcome is bucketed by whether its investigation is still
// running, errored out, or finished without a typed outcome.
func PatrolInvestigationOutcomeBucket(outcome, status string) string {
switch strings.ToLower(strings.TrimSpace(outcome)) {
case "fix_verified":
return PatrolInvestigationOutcomeFixVerified
case "fix_queued":
return PatrolInvestigationOutcomeFixQueued
case "fix_executed":
return PatrolInvestigationOutcomeFixExecuted
case "fix_rejected":
return PatrolInvestigationOutcomeFixRejected
case "fix_failed", "fix_verification_failed":
return PatrolInvestigationOutcomeFixFailed
case "fix_verification_unknown":
return PatrolInvestigationOutcomeFixVerificationUnknown
case "resolved":
return PatrolInvestigationOutcomeResolved
case "needs_attention":
return PatrolInvestigationOutcomeNeedsAttention
case "cannot_fix":
return PatrolInvestigationOutcomeCannotFix
case "timed_out":
return PatrolInvestigationOutcomeTimedOut
case "":
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "running":
return PatrolInvestigationOutcomeInProgress
case "failed":
return PatrolInvestigationOutcomeFailed
case "needs_attention":
return PatrolInvestigationOutcomeNeedsAttention
default:
return PatrolInvestigationOutcomeOther
}
default:
return PatrolInvestigationOutcomeOther
}
}
// PatrolInvestigationOutcomeCounts is the count-only partition of investigated
// findings by outcome bucket.
type PatrolInvestigationOutcomeCounts struct {
FixVerified int
FixQueued int
FixExecuted int
FixRejected int
FixFailed int
FixVerificationUnknown int
Resolved int
NeedsAttention int
CannotFix int
TimedOut int
InProgress int
Failed int
Other int
}
// Add increments the bucket for one investigated finding.
func (c *PatrolInvestigationOutcomeCounts) Add(outcome, status string) {
if c == nil {
return
}
switch PatrolInvestigationOutcomeBucket(outcome, status) {
case PatrolInvestigationOutcomeFixVerified:
c.FixVerified++
case PatrolInvestigationOutcomeFixQueued:
c.FixQueued++
case PatrolInvestigationOutcomeFixExecuted:
c.FixExecuted++
case PatrolInvestigationOutcomeFixRejected:
c.FixRejected++
case PatrolInvestigationOutcomeFixFailed:
c.FixFailed++
case PatrolInvestigationOutcomeFixVerificationUnknown:
c.FixVerificationUnknown++
case PatrolInvestigationOutcomeResolved:
c.Resolved++
case PatrolInvestigationOutcomeNeedsAttention:
c.NeedsAttention++
case PatrolInvestigationOutcomeCannotFix:
c.CannotFix++
case PatrolInvestigationOutcomeTimedOut:
c.TimedOut++
case PatrolInvestigationOutcomeInProgress:
c.InProgress++
case PatrolInvestigationOutcomeFailed:
c.Failed++
default:
c.Other++
}
}
// Total returns the number of investigated findings partitioned so far.
func (c PatrolInvestigationOutcomeCounts) Total() int {
return c.FixVerified + c.FixQueued + c.FixExecuted + c.FixRejected + c.FixFailed +
c.FixVerificationUnknown + c.Resolved + c.NeedsAttention + c.CannotFix +
c.TimedOut + c.InProgress + c.Failed + c.Other
}
+273
View File
@@ -0,0 +1,273 @@
package telemetry
import (
"reflect"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestSchemaV17FieldNamesArePinned(t *testing.T) {
if TelemetrySchemaVersion != 17 {
t.Fatalf("TelemetrySchemaVersion = %d, want 17", TelemetrySchemaVersion)
}
want := map[string]string{
"AIProviderClass": "ai_provider_class",
"PulseIntelligencePatrolAutonomyLevel": "pulse_intelligence_patrol_autonomy_level",
"PulseIntelligencePatrolInputTokensBucket30d": "pulse_intelligence_patrol_input_tokens_bucket_30d",
"PulseIntelligencePatrolOutputTokensBucket30d": "pulse_intelligence_patrol_output_tokens_bucket_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixVerified30d": "pulse_intelligence_patrol_investigation_outcome_fix_verified_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixQueued30d": "pulse_intelligence_patrol_investigation_outcome_fix_queued_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d": "pulse_intelligence_patrol_investigation_outcome_fix_executed_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixRejected30d": "pulse_intelligence_patrol_investigation_outcome_fix_rejected_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixFailed30d": "pulse_intelligence_patrol_investigation_outcome_fix_failed_30d",
"PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d": "pulse_intelligence_patrol_investigation_outcome_fix_verification_unknown_30d",
"PulseIntelligencePatrolInvestigationOutcomeResolved30d": "pulse_intelligence_patrol_investigation_outcome_resolved_30d",
"PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d": "pulse_intelligence_patrol_investigation_outcome_needs_attention_30d",
"PulseIntelligencePatrolInvestigationOutcomeCannotFix30d": "pulse_intelligence_patrol_investigation_outcome_cannot_fix_30d",
"PulseIntelligencePatrolInvestigationOutcomeTimedOut30d": "pulse_intelligence_patrol_investigation_outcome_timed_out_30d",
"PulseIntelligencePatrolInvestigationOutcomeInProgress30d": "pulse_intelligence_patrol_investigation_outcome_in_progress_30d",
"PulseIntelligencePatrolInvestigationOutcomeFailed30d": "pulse_intelligence_patrol_investigation_outcome_failed_30d",
"PulseIntelligencePatrolInvestigationOutcomeOther30d": "pulse_intelligence_patrol_investigation_outcome_other_30d",
}
pingType := reflect.TypeOf(Ping{})
for goName, jsonName := range want {
field, ok := pingType.FieldByName(goName)
if !ok {
t.Errorf("Ping missing field %s", goName)
continue
}
tag := field.Tag.Get("json")
// The four strings are never omitted: an absent value must mean a
// pre-v17 sender, not an install that happened to be idle.
if tag != jsonName {
t.Errorf("Ping.%s json tag = %q, want %q (no omitempty)", goName, tag, jsonName)
}
if _, ok := reflect.TypeOf(Snapshot{}).FieldByName(goName); !ok {
t.Errorf("Snapshot missing field %s", goName)
}
}
if len(PatrolInvestigationOutcomeBucketValues()) != 13 {
t.Fatalf("outcome bucket vocabulary = %d values, want 13", len(PatrolInvestigationOutcomeBucketValues()))
}
}
func TestBuildPingForSnapshotCarriesSchemaV17Fields(t *testing.T) {
ping := BuildPingForSnapshot(Snapshot{
AIProviderClass: AIProviderClassLocal,
PulseIntelligencePatrolAutonomyLevel: config.PatrolAutonomyApproval,
PulseIntelligencePatrolInputTokensBucket30d: PatrolInputTokens5M20M,
PulseIntelligencePatrolOutputTokensBucket30d: PatrolOutputTokens100K500K,
PulseIntelligencePatrolInvestigationOutcomeFixVerified30d: 1,
PulseIntelligencePatrolInvestigationOutcomeFixQueued30d: 2,
PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d: 3,
PulseIntelligencePatrolInvestigationOutcomeFixRejected30d: 4,
PulseIntelligencePatrolInvestigationOutcomeFixFailed30d: 5,
PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d: 6,
PulseIntelligencePatrolInvestigationOutcomeResolved30d: 7,
PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d: 8,
PulseIntelligencePatrolInvestigationOutcomeCannotFix30d: 9,
PulseIntelligencePatrolInvestigationOutcomeTimedOut30d: 10,
PulseIntelligencePatrolInvestigationOutcomeInProgress30d: 11,
PulseIntelligencePatrolInvestigationOutcomeFailed30d: 12,
PulseIntelligencePatrolInvestigationOutcomeOther30d: 13,
})
if ping.AIProviderClass != AIProviderClassLocal ||
ping.PulseIntelligencePatrolAutonomyLevel != config.PatrolAutonomyApproval ||
ping.PulseIntelligencePatrolInputTokensBucket30d != PatrolInputTokens5M20M ||
ping.PulseIntelligencePatrolOutputTokensBucket30d != PatrolOutputTokens100K500K {
t.Fatalf("v17 string fields not carried: %+v", ping)
}
got := []int{
ping.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFixQueued30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFixRejected30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFixFailed30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d,
ping.PulseIntelligencePatrolInvestigationOutcomeResolved30d,
ping.PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d,
ping.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d,
ping.PulseIntelligencePatrolInvestigationOutcomeTimedOut30d,
ping.PulseIntelligencePatrolInvestigationOutcomeInProgress30d,
ping.PulseIntelligencePatrolInvestigationOutcomeFailed30d,
ping.PulseIntelligencePatrolInvestigationOutcomeOther30d,
}
for i, value := range got {
if value != i+1 {
t.Fatalf("outcome field %d = %d, want %d", i, value, i+1)
}
}
}
func TestClassifyAIProviderClass(t *testing.T) {
enabled := func(mutate func(*config.AIConfig)) *config.AIConfig {
cfg := &config.AIConfig{Enabled: true}
mutate(cfg)
return cfg
}
cases := []struct {
name string
cfg *config.AIConfig
want string
}{
{"nil config", nil, AIProviderClassNone},
{"ai disabled", &config.AIConfig{Enabled: false, Model: "anthropic:claude-x"}, AIProviderClassNone},
{"no model selected", enabled(func(c *config.AIConfig) {}), AIProviderClassNone},
{"ollama prefix", enabled(func(c *config.AIConfig) { c.Model = "ollama:qwen3:8b" }), AIProviderClassLocal},
{"patrol model overrides default", enabled(func(c *config.AIConfig) {
c.Model = "anthropic:claude-x"
c.PatrolModel = "ollama:qwen3:8b"
}), AIProviderClassLocal},
{"anthropic key route", enabled(func(c *config.AIConfig) { c.Model = "anthropic:claude-x" }), AIProviderClassCloudBYOK},
{"openai official", enabled(func(c *config.AIConfig) { c.Model = "openai:gpt-x" }), AIProviderClassCloudBYOK},
{"openai official explicit base url", enabled(func(c *config.AIConfig) {
c.Model = "openai:gpt-x"
c.OpenAIBaseURL = "https://api.openai.com/v1"
}), AIProviderClassCloudBYOK},
{"openai compatible loopback", enabled(func(c *config.AIConfig) {
c.Model = "openai:local-model"
c.OpenAIBaseURL = "http://127.0.0.1:1234/v1"
}), AIProviderClassLocal},
{"openai compatible private range", enabled(func(c *config.AIConfig) {
c.Model = "openai:local-model"
c.OpenAIBaseURL = "http://192.168.1.20:8080/v1"
}), AIProviderClassLocal},
{"openai compatible mdns host", enabled(func(c *config.AIConfig) {
c.Model = "openai:local-model"
c.OpenAIBaseURL = "http://llm-box.local:8080/v1"
}), AIProviderClassLocal},
{"openai compatible unqualified host", enabled(func(c *config.AIConfig) {
c.Model = "openai:local-model"
c.OpenAIBaseURL = "http://gpu-node:11434/v1"
}), AIProviderClassLocal},
{"openai compatible public host", enabled(func(c *config.AIConfig) {
c.Model = "openai:some-model"
c.OpenAIBaseURL = "https://gateway.example.com/v1"
}), AIProviderClassCloudBYOK},
{"openrouter", enabled(func(c *config.AIConfig) { c.Model = "openrouter:meta-llama/x" }), AIProviderClassCloudBYOK},
{"codex subscription", enabled(func(c *config.AIConfig) { c.Model = "codex-subscription:gpt-x" }), AIProviderClassCloudSubscription},
{"claude subscription", enabled(func(c *config.AIConfig) { c.Model = "claude-subscription:claude-x" }), AIProviderClassCloudSubscription},
{"retired hosted quickstart alias", enabled(func(c *config.AIConfig) { c.Model = config.DefaultAIModelQuickstart }), AIProviderClassHostedLegacy},
{"retired hosted quickstart provider prefix", enabled(func(c *config.AIConfig) { c.Model = config.AIProviderQuickstart + ":pulse-hosted" }), AIProviderClassHostedLegacy},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ClassifyAIProviderClass(tc.cfg); got != tc.want {
t.Fatalf("ClassifyAIProviderClass = %q, want %q", got, tc.want)
}
})
}
}
func TestClassifyAIProviderClassNeverLeaksRouteDetail(t *testing.T) {
cfg := &config.AIConfig{Enabled: true, Model: "openai:secret-model-name", OpenAIBaseURL: "https://corp-gateway.example.net/v1", OpenAIAPIKey: "sk-not-real"}
got := ClassifyAIProviderClass(cfg)
allowed := map[string]bool{}
for _, value := range AIProviderClassValues() {
allowed[value] = true
}
if !allowed[got] {
t.Fatalf("ClassifyAIProviderClass returned %q outside the closed vocabulary", got)
}
}
func TestPatrolTokenBuckets(t *testing.T) {
input := []struct {
total int64
want string
}{
{-5, PatrolTokenBucketZero}, {0, PatrolTokenBucketZero}, {1, PatrolInputTokensUnder1M}, {999_999, PatrolInputTokensUnder1M},
{1_000_000, PatrolInputTokens1M5M}, {4_999_999, PatrolInputTokens1M5M}, {5_000_000, PatrolInputTokens5M20M},
{19_999_999, PatrolInputTokens5M20M}, {20_000_000, PatrolInputTokens20MPlus}, {900_000_000, PatrolInputTokens20MPlus},
}
for _, tc := range input {
if got := PatrolInputTokensBucket(tc.total); got != tc.want {
t.Errorf("PatrolInputTokensBucket(%d) = %q, want %q", tc.total, got, tc.want)
}
}
output := []struct {
total int64
want string
}{
{0, PatrolTokenBucketZero}, {1, PatrolOutputTokensUnder100K}, {99_999, PatrolOutputTokensUnder100K},
{100_000, PatrolOutputTokens100K500K}, {499_999, PatrolOutputTokens100K500K}, {500_000, PatrolOutputTokens500K2M},
{1_999_999, PatrolOutputTokens500K2M}, {2_000_000, PatrolOutputTokens2MPlus},
}
for _, tc := range output {
if got := PatrolOutputTokensBucket(tc.total); got != tc.want {
t.Errorf("PatrolOutputTokensBucket(%d) = %q, want %q", tc.total, got, tc.want)
}
}
}
func TestNormalizePatrolAutonomyLevelForTelemetry(t *testing.T) {
cases := map[string]string{
"": config.PatrolAutonomyMonitor,
"monitor": config.PatrolAutonomyMonitor,
"approval": config.PatrolAutonomyApproval,
" Assisted ": config.PatrolAutonomyAssisted,
"full": config.PatrolAutonomyFull,
"autonomous": config.PatrolAutonomyMonitor,
"garbage": config.PatrolAutonomyMonitor,
}
for in, want := range cases {
if got := NormalizePatrolAutonomyLevelForTelemetry(in); got != want {
t.Errorf("NormalizePatrolAutonomyLevelForTelemetry(%q) = %q, want %q", in, got, want)
}
}
}
func TestPatrolInvestigationOutcomeBucketPartitionsEveryState(t *testing.T) {
cases := []struct {
outcome, status, want string
}{
{"fix_verified", "completed", PatrolInvestigationOutcomeFixVerified},
{"fix_queued", "completed", PatrolInvestigationOutcomeFixQueued},
{"fix_executed", "completed", PatrolInvestigationOutcomeFixExecuted},
{"fix_rejected", "completed", PatrolInvestigationOutcomeFixRejected},
{"fix_failed", "completed", PatrolInvestigationOutcomeFixFailed},
{"fix_verification_failed", "completed", PatrolInvestigationOutcomeFixFailed},
{"fix_verification_unknown", "completed", PatrolInvestigationOutcomeFixVerificationUnknown},
{"resolved", "completed", PatrolInvestigationOutcomeResolved},
{"needs_attention", "needs_attention", PatrolInvestigationOutcomeNeedsAttention},
{"cannot_fix", "completed", PatrolInvestigationOutcomeCannotFix},
{"timed_out", "failed", PatrolInvestigationOutcomeTimedOut},
{"", "pending", PatrolInvestigationOutcomeInProgress},
{"", "running", PatrolInvestigationOutcomeInProgress},
{"", "failed", PatrolInvestigationOutcomeFailed},
{"", "needs_attention", PatrolInvestigationOutcomeNeedsAttention},
{"", "completed", PatrolInvestigationOutcomeOther},
{"", "", PatrolInvestigationOutcomeOther},
{"something_new", "completed", PatrolInvestigationOutcomeOther},
}
var counts PatrolInvestigationOutcomeCounts
for _, tc := range cases {
if got := PatrolInvestigationOutcomeBucket(tc.outcome, tc.status); got != tc.want {
t.Errorf("bucket(%q, %q) = %q, want %q", tc.outcome, tc.status, got, tc.want)
}
counts.Add(tc.outcome, tc.status)
}
if counts.Total() != len(cases) {
t.Fatalf("outcome counts total = %d, want %d (every investigated finding must land in exactly one bucket)", counts.Total(), len(cases))
}
if counts.FixFailed != 2 || counts.InProgress != 2 || counts.Other != 3 || counts.NeedsAttention != 2 {
t.Fatalf("unexpected partition: %+v", counts)
}
}
func TestAIProviderClassVocabularyStaysFreeOfRetiredHostedWording(t *testing.T) {
// The vocabulary is disclosed verbatim in the shipped privacy document,
// which the frontend copy contract keeps free of the retired hosted
// quickstart wording. A bucket name that reintroduces it would fail
// that contract on the public docs, not here, so pin it at the source.
for _, value := range AIProviderClassValues() {
if strings.Contains(strings.ToLower(value), "quickstart") {
t.Fatalf("provider class value %q reintroduces retired hosted wording", value)
}
}
if got := ClassifyAIProviderClass(&config.AIConfig{Enabled: true, Model: config.DefaultAIModelQuickstart}); got != AIProviderClassHostedLegacy {
t.Fatalf("legacy hosted route class = %q, want %q", got, AIProviderClassHostedLegacy)
}
}
@@ -14,6 +14,11 @@ type PulseIntelligenceAIUsageEvidence struct {
AssistantContextAICalls int
AssistantToolCalls int
PatrolAICalls int
// PatrolInputTokens and PatrolOutputTokens are the exact local Patrol
// token totals for the window. They are bucketed before export; the
// exact figures never leave the install.
PatrolInputTokens int64
PatrolOutputTokens int64
}
// PulseIntelligenceAIUsageEvidenceFromHistory projects AI usage history into
@@ -39,6 +44,12 @@ func PulseIntelligenceAIUsageEvidenceFromHistory(history *config.AIUsageHistoryD
}
case "patrol":
evidence.PatrolAICalls++
if event.InputTokens > 0 {
evidence.PatrolInputTokens += int64(event.InputTokens)
}
if event.OutputTokens > 0 {
evidence.PatrolOutputTokens += int64(event.OutputTokens)
}
}
}
return evidence
+279 -205
View File
@@ -196,7 +196,15 @@ const (
// Schema v16 adds four content-free workload-history adoption counters.
// Each browser reports each closed milestone at most once per session to a
// bounded local daily tally; no event stream or browser identity is sent.
TelemetrySchemaVersion = 16
// Schema v17 adds the closed Patrol provider class (local, cloud_byok,
// cloud_subscription, hosted_legacy, none), the effective Patrol
// autonomy level, coarse 30-day Patrol input and output token buckets, and
// per-outcome investigation counts. The 2026-09-01 Patrol assessment could
// not tell local from cloud installs, estimate what Patrol costs an
// 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
)
type installIDRecord struct {
@@ -302,12 +310,16 @@ type Ping struct {
NotificationsEnabled bool `json:"notifications_enabled"`
AIActionsEnabled bool `json:"ai_actions_enabled"`
AlertAIEnabled bool `json:"alert_ai_enabled"`
ActiveAlerts int `json:"active_alerts"`
RelayEnabled bool `json:"relay_enabled"`
SSOEnabled bool `json:"sso_enabled"`
MultiTenant bool `json:"multi_tenant"`
PaidLicense bool `json:"paid_license"`
HasAPITokens bool `json:"has_api_tokens"`
// AIProviderClass is the closed route class of the Patrol (or default)
// model: none, local, cloud_byok, cloud_subscription, hosted_legacy,
// or unknown. Never a provider ID, model name, endpoint, or account.
AIProviderClass string `json:"ai_provider_class"`
ActiveAlerts int `json:"active_alerts"`
RelayEnabled bool `json:"relay_enabled"`
SSOEnabled bool `json:"sso_enabled"`
MultiTenant bool `json:"multi_tenant"`
PaidLicense bool `json:"paid_license"`
HasAPITokens bool `json:"has_api_tokens"`
// Licensed-feature adoption. Counts only: no role names, schedule names,
// profile names, recipients, report contents, or audit event detail.
@@ -446,27 +458,50 @@ type Ping struct {
PulseIntelligencePatrolInvestigations30d int `json:"pulse_intelligence_patrol_investigations_30d"`
PulseIntelligencePatrolResolvedFindings30d int `json:"pulse_intelligence_patrol_resolved_findings_30d"`
PulseIntelligencePatrolBlockedCause string `json:"pulse_intelligence_patrol_blocked_cause,omitempty"`
PulseIntelligenceExternalAgentEnabled bool `json:"pulse_intelligence_external_agent_enabled"`
PulseIntelligenceExternalAgentUsed30d bool `json:"pulse_intelligence_external_agent_used_30d"`
PulseIntelligenceMCPAdapterUsed30d bool `json:"pulse_intelligence_mcp_adapter_used_30d"`
PulseIntelligenceExternalAgentContextRequests30d int `json:"pulse_intelligence_external_agent_context_requests_30d"`
PulseIntelligenceExternalAgentEventStreamRequests30d int `json:"pulse_intelligence_external_agent_event_stream_requests_30d"`
PulseIntelligenceExternalAgentProvisioningRequests30d int `json:"pulse_intelligence_external_agent_provisioning_requests_30d"`
PulseIntelligenceExternalAgentOperatorStateRequests30d int `json:"pulse_intelligence_external_agent_operator_state_requests_30d"`
PulseIntelligenceExternalAgentFindingRequests30d int `json:"pulse_intelligence_external_agent_finding_requests_30d"`
PulseIntelligenceExternalAgentActionRequests30d int `json:"pulse_intelligence_external_agent_action_requests_30d"`
PulseIntelligenceActionPlans30d int `json:"pulse_intelligence_action_plans_30d"`
PulseIntelligenceApprovalRequests30d int `json:"pulse_intelligence_approval_requests_30d"`
PulseIntelligenceRejectedActionDecisions30d int `json:"pulse_intelligence_rejected_action_decisions_30d"`
PulseIntelligenceApprovedActionDecisions30d int `json:"pulse_intelligence_approved_action_decisions_30d"`
PulseIntelligenceApprovedActionAttempts30d int `json:"pulse_intelligence_approved_action_attempts_30d"`
PulseIntelligenceApprovedActionSuccesses30d int `json:"pulse_intelligence_approved_action_successes_30d"`
PulseIntelligencePatrolActionPlans30d int `json:"pulse_intelligence_patrol_action_plans_30d"`
PulseIntelligencePatrolApprovalRequests30d int `json:"pulse_intelligence_patrol_approval_requests_30d"`
PulseIntelligencePatrolRejectedActionDecisions30d int `json:"pulse_intelligence_patrol_rejected_action_decisions_30d"`
PulseIntelligencePatrolApprovedActionDecisions30d int `json:"pulse_intelligence_patrol_approved_action_decisions_30d"`
PulseIntelligencePatrolApprovedActionAttempts30d int `json:"pulse_intelligence_patrol_approved_action_attempts_30d"`
PulseIntelligencePatrolApprovedActionSuccesses30d int `json:"pulse_intelligence_patrol_approved_action_successes_30d"`
// Effective Patrol autonomy level after licence and Autopilot
// acknowledgement gating: monitor, approval, assisted, or full.
PulseIntelligencePatrolAutonomyLevel string `json:"pulse_intelligence_patrol_autonomy_level"`
// Coarse 30-day Patrol token volume from the local usage ledger. Closed
// buckets only; exact totals, prices, providers, and models stay local.
PulseIntelligencePatrolInputTokensBucket30d string `json:"pulse_intelligence_patrol_input_tokens_bucket_30d"`
PulseIntelligencePatrolOutputTokensBucket30d string `json:"pulse_intelligence_patrol_output_tokens_bucket_30d"`
// Investigation outcome partition of the findings counted by
// pulse_intelligence_patrol_investigations_30d. Each investigated finding
// is in exactly one bucket; the buckets sum to the investigation count.
PulseIntelligencePatrolInvestigationOutcomeFixVerified30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_verified_30d"`
PulseIntelligencePatrolInvestigationOutcomeFixQueued30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_queued_30d"`
PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_executed_30d"`
PulseIntelligencePatrolInvestigationOutcomeFixRejected30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_rejected_30d"`
PulseIntelligencePatrolInvestigationOutcomeFixFailed30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_failed_30d"`
PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d int `json:"pulse_intelligence_patrol_investigation_outcome_fix_verification_unknown_30d"`
PulseIntelligencePatrolInvestigationOutcomeResolved30d int `json:"pulse_intelligence_patrol_investigation_outcome_resolved_30d"`
PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d int `json:"pulse_intelligence_patrol_investigation_outcome_needs_attention_30d"`
PulseIntelligencePatrolInvestigationOutcomeCannotFix30d int `json:"pulse_intelligence_patrol_investigation_outcome_cannot_fix_30d"`
PulseIntelligencePatrolInvestigationOutcomeTimedOut30d int `json:"pulse_intelligence_patrol_investigation_outcome_timed_out_30d"`
PulseIntelligencePatrolInvestigationOutcomeInProgress30d int `json:"pulse_intelligence_patrol_investigation_outcome_in_progress_30d"`
PulseIntelligencePatrolInvestigationOutcomeFailed30d int `json:"pulse_intelligence_patrol_investigation_outcome_failed_30d"`
PulseIntelligencePatrolInvestigationOutcomeOther30d int `json:"pulse_intelligence_patrol_investigation_outcome_other_30d"`
PulseIntelligenceExternalAgentEnabled bool `json:"pulse_intelligence_external_agent_enabled"`
PulseIntelligenceExternalAgentUsed30d bool `json:"pulse_intelligence_external_agent_used_30d"`
PulseIntelligenceMCPAdapterUsed30d bool `json:"pulse_intelligence_mcp_adapter_used_30d"`
PulseIntelligenceExternalAgentContextRequests30d int `json:"pulse_intelligence_external_agent_context_requests_30d"`
PulseIntelligenceExternalAgentEventStreamRequests30d int `json:"pulse_intelligence_external_agent_event_stream_requests_30d"`
PulseIntelligenceExternalAgentProvisioningRequests30d int `json:"pulse_intelligence_external_agent_provisioning_requests_30d"`
PulseIntelligenceExternalAgentOperatorStateRequests30d int `json:"pulse_intelligence_external_agent_operator_state_requests_30d"`
PulseIntelligenceExternalAgentFindingRequests30d int `json:"pulse_intelligence_external_agent_finding_requests_30d"`
PulseIntelligenceExternalAgentActionRequests30d int `json:"pulse_intelligence_external_agent_action_requests_30d"`
PulseIntelligenceActionPlans30d int `json:"pulse_intelligence_action_plans_30d"`
PulseIntelligenceApprovalRequests30d int `json:"pulse_intelligence_approval_requests_30d"`
PulseIntelligenceRejectedActionDecisions30d int `json:"pulse_intelligence_rejected_action_decisions_30d"`
PulseIntelligenceApprovedActionDecisions30d int `json:"pulse_intelligence_approved_action_decisions_30d"`
PulseIntelligenceApprovedActionAttempts30d int `json:"pulse_intelligence_approved_action_attempts_30d"`
PulseIntelligenceApprovedActionSuccesses30d int `json:"pulse_intelligence_approved_action_successes_30d"`
PulseIntelligencePatrolActionPlans30d int `json:"pulse_intelligence_patrol_action_plans_30d"`
PulseIntelligencePatrolApprovalRequests30d int `json:"pulse_intelligence_patrol_approval_requests_30d"`
PulseIntelligencePatrolRejectedActionDecisions30d int `json:"pulse_intelligence_patrol_rejected_action_decisions_30d"`
PulseIntelligencePatrolApprovedActionDecisions30d int `json:"pulse_intelligence_patrol_approved_action_decisions_30d"`
PulseIntelligencePatrolApprovedActionAttempts30d int `json:"pulse_intelligence_patrol_approved_action_attempts_30d"`
PulseIntelligencePatrolApprovedActionSuccesses30d int `json:"pulse_intelligence_patrol_approved_action_successes_30d"`
// Cause-coded approved-action failure counters. Together with successes
// and still-in-flight attempts these partition the attempt count, so the
@@ -493,183 +528,200 @@ type Ping struct {
// The telemetry package calls a user-provided SnapshotFunc to populate this,
// keeping the package decoupled from monitor/config internals.
type Snapshot struct {
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
AgentHosts int
DockerHosts int
DockerContainers int
KubernetesClusters int
KubernetesNodes int
KubernetesPods int
KubernetesDeployments int
StoragePools int
PhysicalDisks int
CephClusters int
NetworkShares int
TrueNASSystems int
TrueNASVMs int
TrueNASApps int
VMwareHosts int
VMwareVMs int
VMwareDatastores int
AvailabilityTargets int
AvailabilityProbeTargets int
AvailabilityProbeAgents int
AIEnabled bool
PatrolEnabled bool
DiscoveryEnabled bool
NotificationsEnabled bool
AIActionsEnabled bool
AlertAIEnabled bool
ActiveAlerts int
RelayEnabled bool
SSOEnabled bool
MultiTenant bool
PaidLicense bool
HasAPITokens bool
RBACCustomRoles int
RBACUserAssignments int
AuditReads30d int
ReportSchedules int
ReportSchedulesEnabled int
ReportSchedulesRun30d int
AgentProfiles int
UpdateAttempts30d int
UpdateSuccesses30d int
UpdateFailures30d int
UpdateLastFailureCategory string
NodeTestAttempts30d int
NodeTestFailures30d int
WorkloadHistoryPreviewSessions30d int
WorkloadHistoryScrubSessions30d int
WorkloadHistoryRangeChangeSessions30d int
WorkloadHistoryDetailsSelectionSessions30d int
AuthConfigured bool
ConfiguredConnections int
AlertsFired30d int
AlertsAcknowledged30d int
AlertsResolved30d int
ActiveAlertsInfo int
ActiveAlertsWarning int
ActiveAlertsCritical int
ActiveAlertsAgeUnder1h int
ActiveAlertsAge1h24h int
ActiveAlertsAge1d7d int
ActiveAlertsAge7dPlus int
AlertsFiredInfo30d int
AlertsFiredWarning30d int
AlertsFiredCritical30d int
AlertsResolvedInfo30d int
AlertsResolvedWarning30d int
AlertsResolvedCritical30d int
AlertsResolutionUnder15m30d int
AlertsResolution15m1h30d int
AlertsResolution1h24h30d int
AlertsResolution1d7d30d int
AlertsResolution7dPlus30d int
AlertsRepeatOccurrences30d int
AlertsSnoozedOccurrences30d int
AlertsResolvedWhileSnoozed30d int
AlertManagerTenants int
AlertDeliveryActiveTenants int
AlertFlappingEnabledTenants int
AlertIntentPolicyConfiguredTenants int
AlertEventHistoryAuthoritativeTenants int
AlertActiveStateAuthoritativeTenants int
AlertActiveStatePersistenceDegradedTenants int
NotificationAttempts7d int
NotificationDeliveries7d int
NotificationFailures7d int
NotificationFailuresAuthentication7d int
NotificationFailuresRateLimited7d int
NotificationFailuresConnectivity7d int
NotificationFailuresTLS7d int
NotificationFailuresConfiguration7d int
NotificationFailuresRejected7d int
NotificationFailuresServerError7d int
NotificationFailuresUnknown7d int
PulseIntelligenceLoopConfigured bool
PulseIntelligenceLoopActive30d bool
PulseIntelligenceCompleteOperationsLoop30d bool
PulseIntelligenceApprovedExecutionLoop30d bool
PulseIntelligenceResolvedOperationsLoop30d bool
PulseIntelligencePatrolControlCompletedOperationsLoop30d bool
PulseIntelligencePatrolControlResolvedOperationsLoop30d bool
PulseIntelligencePatrolControlPaidCompletedOperationsLoop30d bool
PulseIntelligencePatrolControlPaidResolvedOperationsLoop30d bool
PulseIntelligenceProActivationCompletedOperationsLoop30d bool
PulseIntelligenceProActivationResolvedOperationsLoop30d bool
PulseIntelligenceProActivationPaidCompletedOperationsLoop30d bool
PulseIntelligenceProActivationPaidResolvedOperationsLoop30d bool
PulseIntelligenceGovernedActionActive30d bool
PulseIntelligenceAssistantOperationsLoop30d bool
PulseIntelligenceAssistantApprovedExecutionLoop30d bool
PulseIntelligenceAssistantApprovedActionSuccessLoop30d bool
PulseIntelligenceAssistantResolvedOperationsLoop30d bool
PulseIntelligenceExternalAgentOperationsLoop30d bool
PulseIntelligenceExternalAgentApprovedExecutionLoop30d bool
PulseIntelligenceExternalAgentApprovedActionSuccessLoop30d bool
PulseIntelligenceExternalAgentResolvedOperationsLoop30d bool
PulseIntelligenceMCPAdapterOperationsLoop30d bool
PulseIntelligenceMCPAdapterApprovedExecutionLoop30d bool
PulseIntelligenceMCPAdapterApprovedActionSuccessLoop30d bool
PulseIntelligenceMCPAdapterResolvedOperationsLoop30d bool
PulseIntelligenceOperationsLoopStarterRequests30d int
PulseIntelligenceAssistantOperationsLoopStarterRequests30d int
PulseIntelligencePatrolOperationsLoopStarterRequests30d int
PulseIntelligencePatrolControlOperationsLoopStarterRequests30d int
PulseIntelligenceProActivationOperationsLoopStarterRequests30d int
PulseIntelligenceMCPOperationsLoopStarterRequests30d int
PulseIntelligenceAssistantAICalls30d int
PulseIntelligenceAssistantContextAICalls30d int
PulseIntelligenceAssistantToolCalls30d int
PulseIntelligencePatrolAICalls30d int
PulseIntelligencePatrolRuns30d int
PulseIntelligencePatrolNewFindings30d int
PulseIntelligencePatrolInvestigations30d int
PulseIntelligencePatrolResolvedFindings30d int
PulseIntelligencePatrolBlockedCause string
PulseIntelligenceExternalAgentEnabled bool
PulseIntelligenceExternalAgentOperationsLoopReady bool
PulseIntelligenceExternalAgentUsed30d bool
PulseIntelligenceMCPAdapterUsed30d bool
PulseIntelligenceExternalAgentContextRequests30d int
PulseIntelligenceExternalAgentEventStreamRequests30d int
PulseIntelligenceExternalAgentProvisioningRequests30d int
PulseIntelligenceExternalAgentOperatorStateRequests30d int
PulseIntelligenceExternalAgentFindingRequests30d int
PulseIntelligenceExternalAgentActionRequests30d int
PulseIntelligenceActionPlans30d int
PulseIntelligenceApprovalRequests30d int
PulseIntelligenceRejectedActionDecisions30d int
PulseIntelligenceApprovedActionDecisions30d int
PulseIntelligenceApprovedActionAttempts30d int
PulseIntelligenceApprovedActionSuccesses30d int
PulseIntelligencePatrolActionPlans30d int
PulseIntelligencePatrolApprovalRequests30d int
PulseIntelligencePatrolRejectedActionDecisions30d int
PulseIntelligencePatrolApprovedActionDecisions30d int
PulseIntelligencePatrolApprovedActionAttempts30d int
PulseIntelligencePatrolApprovedActionSuccesses30d int
PulseIntelligenceApprovedActionFailuresPreDispatch30d int
PulseIntelligenceApprovedActionFailuresExecution30d int
PulseIntelligenceApprovedActionFailuresUnverified30d int
PulseIntelligenceApprovedActionStuckExecuting30d int
PulseIntelligenceApprovedActionInFlight30d int
PulseIntelligenceApprovedActionUnclassified30d int
PulseIntelligenceApprovedActionRefusalsPlanStale30d int
PulseIntelligenceApprovedActionRefusalsPolicy30d int
PulseIntelligenceApprovedActionRefusalsCapability30d int
PulseIntelligenceApprovedActionRefusalsTargetChanged30d int
PulseIntelligenceApprovedActionRefusalsPrerequisite30d int
PulseIntelligenceApprovedActionRefusalsContract30d int
PulseIntelligenceApprovedActionRefusalsUncoded30d int
PulseIntelligenceApprovedActionRefusalsOther30d int
PulseIntelligenceVerifiedFindingResolutions30d int
PulseIntelligenceApprovedActionLastFailureReason30d string
PVENodes int
PBSInstances int
PMGInstances int
VMs int
Containers int
AgentHosts int
DockerHosts int
DockerContainers int
KubernetesClusters int
KubernetesNodes int
KubernetesPods int
KubernetesDeployments int
StoragePools int
PhysicalDisks int
CephClusters int
NetworkShares int
TrueNASSystems int
TrueNASVMs int
TrueNASApps int
VMwareHosts int
VMwareVMs int
VMwareDatastores int
AvailabilityTargets int
AvailabilityProbeTargets int
AvailabilityProbeAgents int
AIEnabled bool
PatrolEnabled bool
DiscoveryEnabled bool
NotificationsEnabled bool
AIActionsEnabled bool
AlertAIEnabled bool
AIProviderClass string
ActiveAlerts int
RelayEnabled bool
SSOEnabled bool
MultiTenant bool
PaidLicense bool
HasAPITokens bool
RBACCustomRoles int
RBACUserAssignments int
AuditReads30d int
ReportSchedules int
ReportSchedulesEnabled int
ReportSchedulesRun30d int
AgentProfiles int
UpdateAttempts30d int
UpdateSuccesses30d int
UpdateFailures30d int
UpdateLastFailureCategory string
NodeTestAttempts30d int
NodeTestFailures30d int
WorkloadHistoryPreviewSessions30d int
WorkloadHistoryScrubSessions30d int
WorkloadHistoryRangeChangeSessions30d int
WorkloadHistoryDetailsSelectionSessions30d int
AuthConfigured bool
ConfiguredConnections int
AlertsFired30d int
AlertsAcknowledged30d int
AlertsResolved30d int
ActiveAlertsInfo int
ActiveAlertsWarning int
ActiveAlertsCritical int
ActiveAlertsAgeUnder1h int
ActiveAlertsAge1h24h int
ActiveAlertsAge1d7d int
ActiveAlertsAge7dPlus int
AlertsFiredInfo30d int
AlertsFiredWarning30d int
AlertsFiredCritical30d int
AlertsResolvedInfo30d int
AlertsResolvedWarning30d int
AlertsResolvedCritical30d int
AlertsResolutionUnder15m30d int
AlertsResolution15m1h30d int
AlertsResolution1h24h30d int
AlertsResolution1d7d30d int
AlertsResolution7dPlus30d int
AlertsRepeatOccurrences30d int
AlertsSnoozedOccurrences30d int
AlertsResolvedWhileSnoozed30d int
AlertManagerTenants int
AlertDeliveryActiveTenants int
AlertFlappingEnabledTenants int
AlertIntentPolicyConfiguredTenants int
AlertEventHistoryAuthoritativeTenants int
AlertActiveStateAuthoritativeTenants int
AlertActiveStatePersistenceDegradedTenants int
NotificationAttempts7d int
NotificationDeliveries7d int
NotificationFailures7d int
NotificationFailuresAuthentication7d int
NotificationFailuresRateLimited7d int
NotificationFailuresConnectivity7d int
NotificationFailuresTLS7d int
NotificationFailuresConfiguration7d int
NotificationFailuresRejected7d int
NotificationFailuresServerError7d int
NotificationFailuresUnknown7d int
PulseIntelligenceLoopConfigured bool
PulseIntelligenceLoopActive30d bool
PulseIntelligenceCompleteOperationsLoop30d bool
PulseIntelligenceApprovedExecutionLoop30d bool
PulseIntelligenceResolvedOperationsLoop30d bool
PulseIntelligencePatrolControlCompletedOperationsLoop30d bool
PulseIntelligencePatrolControlResolvedOperationsLoop30d bool
PulseIntelligencePatrolControlPaidCompletedOperationsLoop30d bool
PulseIntelligencePatrolControlPaidResolvedOperationsLoop30d bool
PulseIntelligenceProActivationCompletedOperationsLoop30d bool
PulseIntelligenceProActivationResolvedOperationsLoop30d bool
PulseIntelligenceProActivationPaidCompletedOperationsLoop30d bool
PulseIntelligenceProActivationPaidResolvedOperationsLoop30d bool
PulseIntelligenceGovernedActionActive30d bool
PulseIntelligenceAssistantOperationsLoop30d bool
PulseIntelligenceAssistantApprovedExecutionLoop30d bool
PulseIntelligenceAssistantApprovedActionSuccessLoop30d bool
PulseIntelligenceAssistantResolvedOperationsLoop30d bool
PulseIntelligenceExternalAgentOperationsLoop30d bool
PulseIntelligenceExternalAgentApprovedExecutionLoop30d bool
PulseIntelligenceExternalAgentApprovedActionSuccessLoop30d bool
PulseIntelligenceExternalAgentResolvedOperationsLoop30d bool
PulseIntelligenceMCPAdapterOperationsLoop30d bool
PulseIntelligenceMCPAdapterApprovedExecutionLoop30d bool
PulseIntelligenceMCPAdapterApprovedActionSuccessLoop30d bool
PulseIntelligenceMCPAdapterResolvedOperationsLoop30d bool
PulseIntelligenceOperationsLoopStarterRequests30d int
PulseIntelligenceAssistantOperationsLoopStarterRequests30d int
PulseIntelligencePatrolOperationsLoopStarterRequests30d int
PulseIntelligencePatrolControlOperationsLoopStarterRequests30d int
PulseIntelligenceProActivationOperationsLoopStarterRequests30d int
PulseIntelligenceMCPOperationsLoopStarterRequests30d int
PulseIntelligenceAssistantAICalls30d int
PulseIntelligenceAssistantContextAICalls30d int
PulseIntelligenceAssistantToolCalls30d int
PulseIntelligencePatrolAICalls30d int
PulseIntelligencePatrolRuns30d int
PulseIntelligencePatrolNewFindings30d int
PulseIntelligencePatrolInvestigations30d int
PulseIntelligencePatrolResolvedFindings30d int
PulseIntelligencePatrolBlockedCause string
PulseIntelligencePatrolAutonomyLevel string
PulseIntelligencePatrolInputTokensBucket30d string
PulseIntelligencePatrolOutputTokensBucket30d string
PulseIntelligencePatrolInvestigationOutcomeFixVerified30d int
PulseIntelligencePatrolInvestigationOutcomeFixQueued30d int
PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d int
PulseIntelligencePatrolInvestigationOutcomeFixRejected30d int
PulseIntelligencePatrolInvestigationOutcomeFixFailed30d int
PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d int
PulseIntelligencePatrolInvestigationOutcomeResolved30d int
PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d int
PulseIntelligencePatrolInvestigationOutcomeCannotFix30d int
PulseIntelligencePatrolInvestigationOutcomeTimedOut30d int
PulseIntelligencePatrolInvestigationOutcomeInProgress30d int
PulseIntelligencePatrolInvestigationOutcomeFailed30d int
PulseIntelligencePatrolInvestigationOutcomeOther30d int
PulseIntelligenceExternalAgentEnabled bool
PulseIntelligenceExternalAgentOperationsLoopReady bool
PulseIntelligenceExternalAgentUsed30d bool
PulseIntelligenceMCPAdapterUsed30d bool
PulseIntelligenceExternalAgentContextRequests30d int
PulseIntelligenceExternalAgentEventStreamRequests30d int
PulseIntelligenceExternalAgentProvisioningRequests30d int
PulseIntelligenceExternalAgentOperatorStateRequests30d int
PulseIntelligenceExternalAgentFindingRequests30d int
PulseIntelligenceExternalAgentActionRequests30d int
PulseIntelligenceActionPlans30d int
PulseIntelligenceApprovalRequests30d int
PulseIntelligenceRejectedActionDecisions30d int
PulseIntelligenceApprovedActionDecisions30d int
PulseIntelligenceApprovedActionAttempts30d int
PulseIntelligenceApprovedActionSuccesses30d int
PulseIntelligencePatrolActionPlans30d int
PulseIntelligencePatrolApprovalRequests30d int
PulseIntelligencePatrolRejectedActionDecisions30d int
PulseIntelligencePatrolApprovedActionDecisions30d int
PulseIntelligencePatrolApprovedActionAttempts30d int
PulseIntelligencePatrolApprovedActionSuccesses30d int
PulseIntelligenceApprovedActionFailuresPreDispatch30d int
PulseIntelligenceApprovedActionFailuresExecution30d int
PulseIntelligenceApprovedActionFailuresUnverified30d int
PulseIntelligenceApprovedActionStuckExecuting30d int
PulseIntelligenceApprovedActionInFlight30d int
PulseIntelligenceApprovedActionUnclassified30d int
PulseIntelligenceApprovedActionRefusalsPlanStale30d int
PulseIntelligenceApprovedActionRefusalsPolicy30d int
PulseIntelligenceApprovedActionRefusalsCapability30d int
PulseIntelligenceApprovedActionRefusalsTargetChanged30d int
PulseIntelligenceApprovedActionRefusalsPrerequisite30d int
PulseIntelligenceApprovedActionRefusalsContract30d int
PulseIntelligenceApprovedActionRefusalsUncoded30d int
PulseIntelligenceApprovedActionRefusalsOther30d int
PulseIntelligenceVerifiedFindingResolutions30d int
PulseIntelligenceApprovedActionLastFailureReason30d string
}
// PulseIntelligenceActionSnapshot is the action-governance portion of the
@@ -735,6 +787,11 @@ type PulseIntelligenceActionSnapshot struct {
// provider endpoints, model names, and configuration never leave the
// runtime.
PatrolBlockedCause string
// PatrolAutonomyLevel is the effective Patrol autonomy level after
// licence and Autopilot acknowledgement gating: monitor, approval,
// assisted, or full. Empty means the AI service was unavailable and the
// sender reports monitor.
PatrolAutonomyLevel string
}
// ApplyUpdateTelemetrySnapshot adds content-free update funnel counters from
@@ -1195,6 +1252,7 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
ping.NotificationsEnabled = s.NotificationsEnabled
ping.AIActionsEnabled = s.AIActionsEnabled
ping.AlertAIEnabled = s.AlertAIEnabled
ping.AIProviderClass = s.AIProviderClass
ping.ActiveAlerts = s.ActiveAlerts
ping.RelayEnabled = s.RelayEnabled
ping.SSOEnabled = s.SSOEnabled
@@ -1303,6 +1361,22 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
ping.PulseIntelligencePatrolInvestigations30d = s.PulseIntelligencePatrolInvestigations30d
ping.PulseIntelligencePatrolResolvedFindings30d = s.PulseIntelligencePatrolResolvedFindings30d
ping.PulseIntelligencePatrolBlockedCause = s.PulseIntelligencePatrolBlockedCause
ping.PulseIntelligencePatrolAutonomyLevel = s.PulseIntelligencePatrolAutonomyLevel
ping.PulseIntelligencePatrolInputTokensBucket30d = s.PulseIntelligencePatrolInputTokensBucket30d
ping.PulseIntelligencePatrolOutputTokensBucket30d = s.PulseIntelligencePatrolOutputTokensBucket30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d = s.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixQueued30d = s.PulseIntelligencePatrolInvestigationOutcomeFixQueued30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d = s.PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixRejected30d = s.PulseIntelligencePatrolInvestigationOutcomeFixRejected30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixFailed30d = s.PulseIntelligencePatrolInvestigationOutcomeFixFailed30d
ping.PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d = s.PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d
ping.PulseIntelligencePatrolInvestigationOutcomeResolved30d = s.PulseIntelligencePatrolInvestigationOutcomeResolved30d
ping.PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d = s.PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d
ping.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d = s.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d
ping.PulseIntelligencePatrolInvestigationOutcomeTimedOut30d = s.PulseIntelligencePatrolInvestigationOutcomeTimedOut30d
ping.PulseIntelligencePatrolInvestigationOutcomeInProgress30d = s.PulseIntelligencePatrolInvestigationOutcomeInProgress30d
ping.PulseIntelligencePatrolInvestigationOutcomeFailed30d = s.PulseIntelligencePatrolInvestigationOutcomeFailed30d
ping.PulseIntelligencePatrolInvestigationOutcomeOther30d = s.PulseIntelligencePatrolInvestigationOutcomeOther30d
ping.PulseIntelligenceExternalAgentEnabled = s.PulseIntelligenceExternalAgentEnabled
ping.PulseIntelligenceExternalAgentUsed30d = s.PulseIntelligenceExternalAgentUsed30d
ping.PulseIntelligenceMCPAdapterUsed30d = s.PulseIntelligenceMCPAdapterUsed30d
+35 -25
View File
@@ -423,31 +423,36 @@ func TestApplySnapshot(t *testing.T) {
PulseIntelligencePatrolInvestigations30d: 6,
PulseIntelligencePatrolResolvedFindings30d: 3,
PulseIntelligencePatrolBlockedCause: "provider_not_configured",
PulseIntelligenceExternalAgentEnabled: true,
PulseIntelligenceExternalAgentUsed30d: true,
PulseIntelligenceMCPAdapterUsed30d: true,
PulseIntelligenceExternalAgentContextRequests30d: 8,
PulseIntelligenceExternalAgentEventStreamRequests30d: 2,
PulseIntelligenceExternalAgentProvisioningRequests30d: 1,
PulseIntelligenceExternalAgentOperatorStateRequests30d: 3,
PulseIntelligenceExternalAgentFindingRequests30d: 5,
PulseIntelligenceExternalAgentActionRequests30d: 4,
PulseIntelligenceActionPlans30d: 6,
PulseIntelligenceApprovalRequests30d: 2,
PulseIntelligenceRejectedActionDecisions30d: 1,
PulseIntelligenceApprovedActionDecisions30d: 1,
PulseIntelligenceApprovedActionAttempts30d: 1,
PulseIntelligenceApprovedActionSuccesses30d: 1,
PulseIntelligenceApprovedActionInFlight30d: 2,
PulseIntelligenceApprovedActionUnclassified30d: 3,
PulseIntelligenceApprovedActionRefusalsPlanStale30d: 4,
PulseIntelligenceApprovedActionRefusalsPolicy30d: 5,
PulseIntelligenceApprovedActionRefusalsCapability30d: 6,
PulseIntelligenceApprovedActionRefusalsTargetChanged30d: 7,
PulseIntelligenceApprovedActionRefusalsPrerequisite30d: 8,
PulseIntelligenceApprovedActionRefusalsContract30d: 9,
PulseIntelligenceApprovedActionRefusalsOther30d: 10,
PulseIntelligenceVerifiedFindingResolutions30d: 8,
PulseIntelligencePatrolAutonomyLevel: "approval",
PulseIntelligencePatrolInputTokensBucket30d: "5m_20m",
PulseIntelligencePatrolOutputTokensBucket30d: "100k_500k",
PulseIntelligencePatrolInvestigationOutcomeCannotFix30d: 4,
AIProviderClass: "local",
PulseIntelligenceExternalAgentEnabled: true,
PulseIntelligenceExternalAgentUsed30d: true,
PulseIntelligenceMCPAdapterUsed30d: true,
PulseIntelligenceExternalAgentContextRequests30d: 8,
PulseIntelligenceExternalAgentEventStreamRequests30d: 2,
PulseIntelligenceExternalAgentProvisioningRequests30d: 1,
PulseIntelligenceExternalAgentOperatorStateRequests30d: 3,
PulseIntelligenceExternalAgentFindingRequests30d: 5,
PulseIntelligenceExternalAgentActionRequests30d: 4,
PulseIntelligenceActionPlans30d: 6,
PulseIntelligenceApprovalRequests30d: 2,
PulseIntelligenceRejectedActionDecisions30d: 1,
PulseIntelligenceApprovedActionDecisions30d: 1,
PulseIntelligenceApprovedActionAttempts30d: 1,
PulseIntelligenceApprovedActionSuccesses30d: 1,
PulseIntelligenceApprovedActionInFlight30d: 2,
PulseIntelligenceApprovedActionUnclassified30d: 3,
PulseIntelligenceApprovedActionRefusalsPlanStale30d: 4,
PulseIntelligenceApprovedActionRefusalsPolicy30d: 5,
PulseIntelligenceApprovedActionRefusalsCapability30d: 6,
PulseIntelligenceApprovedActionRefusalsTargetChanged30d: 7,
PulseIntelligenceApprovedActionRefusalsPrerequisite30d: 8,
PulseIntelligenceApprovedActionRefusalsContract30d: 9,
PulseIntelligenceApprovedActionRefusalsOther30d: 10,
PulseIntelligenceVerifiedFindingResolutions30d: 8,
}
}
@@ -553,6 +558,11 @@ func TestApplySnapshot(t *testing.T) {
ping.PulseIntelligencePatrolInvestigations30d != 6 ||
ping.PulseIntelligencePatrolResolvedFindings30d != 3 ||
ping.PulseIntelligencePatrolBlockedCause != "provider_not_configured" ||
ping.PulseIntelligencePatrolAutonomyLevel != "approval" ||
ping.PulseIntelligencePatrolInputTokensBucket30d != "5m_20m" ||
ping.PulseIntelligencePatrolOutputTokensBucket30d != "100k_500k" ||
ping.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d != 4 ||
ping.AIProviderClass != "local" ||
ping.PulseIntelligenceActionPlans30d != 6 ||
ping.PulseIntelligenceApprovalRequests30d != 2 ||
ping.PulseIntelligenceRejectedActionDecisions30d != 1 ||
+4
View File
@@ -618,8 +618,12 @@ func Run(ctx context.Context, version string) (runErr error) {
snap.DiscoveryEnabled = currentCfg.DiscoveryEnabled
// Feature flags from persisted config (using pre-created persistence).
snap.AIProviderClass = telemetry.AIProviderClassNone
if aiCfg, err := telemetryPersistence.LoadAIConfig(); err == nil && aiCfg != nil {
snap.AIEnabled = aiCfg.Enabled
// Closed route class only; provider, model, endpoint, and
// account identity never enter the snapshot.
snap.AIProviderClass = telemetry.ClassifyAIProviderClass(aiCfg)
snap.PatrolEnabled = aiCfg.IsPatrolEnabled()
snap.DiscoveryEnabled = snap.DiscoveryEnabled || aiCfg.IsDiscoveryEnabled()
snap.AIActionsEnabled = aiCfg.IsControlEnabled()
@@ -25,6 +25,15 @@ func applyPulseIntelligenceTelemetrySnapshot(
now = now.UTC()
since := now.Add(-telemetry.PulseIntelligenceTelemetryWindow)
// Schema v17 closed-vocabulary defaults. A missing usage ledger or AI
// service must still export a valid bucket, never an empty string.
if snap.AIProviderClass == "" {
snap.AIProviderClass = telemetry.AIProviderClassNone
}
snap.PulseIntelligencePatrolAutonomyLevel = telemetry.NormalizePatrolAutonomyLevelForTelemetry(actionSnapshot.PatrolAutonomyLevel)
snap.PulseIntelligencePatrolInputTokensBucket30d = telemetry.PatrolTokenBucketZero
snap.PulseIntelligencePatrolOutputTokensBucket30d = telemetry.PatrolTokenBucketZero
applyPulseIntelligenceAIUsageSnapshot(snap, persistence, since)
applyPulseIntelligencePatrolRunSnapshot(snap, persistence, since)
applyPulseIntelligenceFindingSnapshot(snap, persistence, since)
@@ -222,6 +231,9 @@ func applyPulseIntelligenceAIUsageSnapshot(snap *telemetry.Snapshot, persistence
snap.PulseIntelligenceAssistantContextAICalls30d = evidence.AssistantContextAICalls
snap.PulseIntelligenceAssistantToolCalls30d = evidence.AssistantToolCalls
snap.PulseIntelligencePatrolAICalls30d = evidence.PatrolAICalls
// Exact token totals stay local; only the closed bucket is exported.
snap.PulseIntelligencePatrolInputTokensBucket30d = telemetry.PatrolInputTokensBucket(evidence.PatrolInputTokens)
snap.PulseIntelligencePatrolOutputTokensBucket30d = telemetry.PatrolOutputTokensBucket(evidence.PatrolOutputTokens)
}
func applyPulseIntelligencePatrolRunSnapshot(snap *telemetry.Snapshot, persistence *config.ConfigPersistence, since time.Time) {
@@ -259,14 +271,32 @@ func applyPulseIntelligenceFindingSnapshot(snap *telemetry.Snapshot, persistence
if err != nil || data == nil {
return
}
var outcomes telemetry.PatrolInvestigationOutcomeCounts
for _, finding := range data.Findings {
if pulseIntelligenceFindingInvestigatedSince(finding, since) {
snap.PulseIntelligencePatrolInvestigations30d++
// Partition the same population by its current investigation
// state so the outcome buckets always sum to the investigation
// count. Only the closed bucket leaves the install.
outcomes.Add(finding.InvestigationOutcome, finding.InvestigationStatus)
}
if pulseIntelligenceFindingResolvedSince(finding, since) {
snap.PulseIntelligencePatrolResolvedFindings30d++
}
}
snap.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d = outcomes.FixVerified
snap.PulseIntelligencePatrolInvestigationOutcomeFixQueued30d = outcomes.FixQueued
snap.PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d = outcomes.FixExecuted
snap.PulseIntelligencePatrolInvestigationOutcomeFixRejected30d = outcomes.FixRejected
snap.PulseIntelligencePatrolInvestigationOutcomeFixFailed30d = outcomes.FixFailed
snap.PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d = outcomes.FixVerificationUnknown
snap.PulseIntelligencePatrolInvestigationOutcomeResolved30d = outcomes.Resolved
snap.PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d = outcomes.NeedsAttention
snap.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d = outcomes.CannotFix
snap.PulseIntelligencePatrolInvestigationOutcomeTimedOut30d = outcomes.TimedOut
snap.PulseIntelligencePatrolInvestigationOutcomeInProgress30d = outcomes.InProgress
snap.PulseIntelligencePatrolInvestigationOutcomeFailed30d = outcomes.Failed
snap.PulseIntelligencePatrolInvestigationOutcomeOther30d = outcomes.Other
}
func pulseIntelligenceFindingInvestigatedSince(finding *config.AIFindingRecord, since time.Time) bool {
@@ -20,8 +20,9 @@ func TestApplyPulseIntelligenceTelemetrySnapshot_AggregatesContentFreeLoopCounts
{Timestamp: recent, UseCase: "chat"},
{Timestamp: recent.Add(-time.Minute), UseCase: "CHAT", ContextScope: "resource_context", ToolCallCount: 3},
{Timestamp: recent.Add(-2 * time.Minute), UseCase: "chat", FindingID: "finding-123", ToolCallCount: 2},
{Timestamp: recent, UseCase: "patrol"},
{Timestamp: recent, UseCase: "unknown", ToolCallCount: 99},
{Timestamp: recent, UseCase: "patrol", InputTokens: 6_000_000, OutputTokens: 120_000},
{Timestamp: recent, UseCase: "unknown", ToolCallCount: 99, InputTokens: 50_000_000},
{Timestamp: recent, UseCase: "patrol_readiness", InputTokens: 50_000_000},
{Timestamp: old, UseCase: "chat", ToolCallCount: 99},
}); err != nil {
t.Fatalf("SaveAIUsageHistory: %v", err)
@@ -39,7 +40,8 @@ func TestApplyPulseIntelligenceTelemetrySnapshot_AggregatesContentFreeLoopCounts
LastInvestigatedAt: &recent,
},
"recent-record-started": {
ID: "recent-record-started",
ID: "recent-record-started",
InvestigationStatus: "running",
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-recent",
FindingID: "recent-record-started",
@@ -132,12 +134,50 @@ func TestApplyPulseIntelligenceTelemetrySnapshot_AggregatesContentFreeLoopCounts
ApprovedActionRefusalsContract30d: 9,
ApprovedActionRefusalsOther30d: 10,
VerifiedFindingResolutions30d: 1,
PatrolAutonomyLevel: "assisted",
}
applyPulseIntelligenceTelemetrySnapshot(&snap, persistence, cfg, actions, now)
if snap.PulseIntelligenceAssistantAICalls30d != 3 {
t.Fatalf("assistant AI calls = %d, want 3", snap.PulseIntelligenceAssistantAICalls30d)
}
if snap.PulseIntelligencePatrolAutonomyLevel != "assisted" {
t.Fatalf("patrol autonomy level = %q, want assisted", snap.PulseIntelligencePatrolAutonomyLevel)
}
if snap.AIProviderClass != telemetry.AIProviderClassNone {
t.Fatalf("provider class default = %q, want none", snap.AIProviderClass)
}
// Only use_case=patrol tokens count; readiness preflights and unknown use
// cases must not inflate the Patrol cost bucket.
if snap.PulseIntelligencePatrolInputTokensBucket30d != telemetry.PatrolInputTokens5M20M ||
snap.PulseIntelligencePatrolOutputTokensBucket30d != telemetry.PatrolOutputTokens100K500K {
t.Fatalf("patrol token buckets = %q/%q, want 5m_20m/100k_500k",
snap.PulseIntelligencePatrolInputTokensBucket30d, snap.PulseIntelligencePatrolOutputTokensBucket30d)
}
outcomeTotal := snap.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFixQueued30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFixExecuted30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFixRejected30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFixFailed30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFixVerificationUnknown30d +
snap.PulseIntelligencePatrolInvestigationOutcomeResolved30d +
snap.PulseIntelligencePatrolInvestigationOutcomeNeedsAttention30d +
snap.PulseIntelligencePatrolInvestigationOutcomeCannotFix30d +
snap.PulseIntelligencePatrolInvestigationOutcomeTimedOut30d +
snap.PulseIntelligencePatrolInvestigationOutcomeInProgress30d +
snap.PulseIntelligencePatrolInvestigationOutcomeFailed30d +
snap.PulseIntelligencePatrolInvestigationOutcomeOther30d
if outcomeTotal != snap.PulseIntelligencePatrolInvestigations30d {
t.Fatalf("outcome buckets sum to %d, want investigations %d", outcomeTotal, snap.PulseIntelligencePatrolInvestigations30d)
}
if snap.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d != 1 ||
snap.PulseIntelligencePatrolInvestigationOutcomeInProgress30d != 1 ||
snap.PulseIntelligencePatrolInvestigationOutcomeOther30d != 1 {
t.Fatalf("outcome partition = verified %d / in_progress %d / other %d, want 1/1/1",
snap.PulseIntelligencePatrolInvestigationOutcomeFixVerified30d,
snap.PulseIntelligencePatrolInvestigationOutcomeInProgress30d,
snap.PulseIntelligencePatrolInvestigationOutcomeOther30d)
}
if snap.PulseIntelligenceAssistantContextAICalls30d != 2 {
t.Fatalf("assistant context AI calls = %d, want 2", snap.PulseIntelligenceAssistantContextAICalls30d)
}
@@ -1096,3 +1136,24 @@ func TestApplyPulseIntelligenceTelemetrySnapshotCarriesPatrolBlockedCause(t *tes
t.Fatalf("PulseIntelligencePatrolBlockedCause = %q, want provider_not_configured", snap.PulseIntelligencePatrolBlockedCause)
}
}
func TestApplyPulseIntelligenceTelemetrySnapshotSchemaV17DefaultsWithoutPersistence(t *testing.T) {
snap := &telemetry.Snapshot{}
applyPulseIntelligenceTelemetrySnapshot(snap, nil, nil, telemetry.PulseIntelligenceActionSnapshot{}, time.Now().UTC())
if snap.AIProviderClass != telemetry.AIProviderClassNone {
t.Fatalf("AIProviderClass = %q, want none", snap.AIProviderClass)
}
if snap.PulseIntelligencePatrolAutonomyLevel != "monitor" {
t.Fatalf("PulseIntelligencePatrolAutonomyLevel = %q, want monitor", snap.PulseIntelligencePatrolAutonomyLevel)
}
if snap.PulseIntelligencePatrolInputTokensBucket30d != telemetry.PatrolTokenBucketZero ||
snap.PulseIntelligencePatrolOutputTokensBucket30d != telemetry.PatrolTokenBucketZero {
t.Fatalf("token buckets = %q/%q, want zero/zero", snap.PulseIntelligencePatrolInputTokensBucket30d, snap.PulseIntelligencePatrolOutputTokensBucket30d)
}
// A pre-existing provider class from the config pass must survive.
snap = &telemetry.Snapshot{AIProviderClass: telemetry.AIProviderClassLocal}
applyPulseIntelligenceTelemetrySnapshot(snap, nil, nil, telemetry.PulseIntelligenceActionSnapshot{PatrolAutonomyLevel: "garbage"}, time.Now().UTC())
if snap.AIProviderClass != telemetry.AIProviderClassLocal || snap.PulseIntelligencePatrolAutonomyLevel != "monitor" {
t.Fatalf("provider class/autonomy = %q/%q, want local/monitor", snap.AIProviderClass, snap.PulseIntelligencePatrolAutonomyLevel)
}
}
@@ -10,7 +10,7 @@ import {
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 16;
const EXPECTED_TELEMETRY_SCHEMA_VERSION = 17;
type WorkerFixtures = {
authStorageStatePath: string;