diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 394bf837d..f1df8dcbd 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 4fa909638..5c0036325 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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.", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 90139165f..90d209462 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 15f92ce6c..065c8206d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 93bebb1ed..e6e16c2db 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0184dbb96..fcfcb0008 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4d29339e3..492b20024 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,20 +1,17 @@ { "version": 1, - "base_sha": "facee87bb4e7b84a0ce682d6e142d35b55be3ace", - "verified_at": "2026-09-02T08:55:07Z", + "base_sha": "b5f694b216e7a333c8ce26746b4e218e59c65efc", + "verified_at": "2026-09-02T10:09:24Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/patrol.ts", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx" + "frontend-modern/src/api/settings.ts" ], "content_sha256": { - "frontend-modern/src/api/patrol.ts": "c411c12d504b53e64b1435c5b336ba953f5051f6909f2bfef3e46067d5b88f34", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "0d46fd5afbbdf8e1b885fa558be070c07203cc6613dda4458e9790fa8f2e4099", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "9a9728f618c502a5379f56e93cad29ea939f5c26e73e3c7d22195308a8087b81" + "frontend-modern/src/api/settings.ts": "6eebcc6d711b8ca26ff435eaa89918567c96c17e8a70fd0c3eb688335f89925c" }, "routes": [ - "/patrol" + "/settings/system-general#usage-telemetry", + "/docs/PRIVACY" ], "viewports": [ { @@ -22,21 +19,18 @@ "height": 800 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "Activity tab with the This week card above Verified outcomes, populated from an isolated mock-mode backend (11 runs, 3 new issues, watch-only mode)", - "card tiles in single column at 375px with no horizontal overflow", - "card refresh in flight and settled", - "Verified outcomes empty state and Review and history below the card", - "watch-only tile copy for Investigated and Fixes run" + "Settings > System > General usage telemetry section with the schema v17 payload preview loaded from a scratch build of this exact tree on main facee87bb4e7 (mock mode, PULSE_MOCK_MODE=true, backend on 7656, Vite on 5174): the pre[aria-label=\"Telemetry payload preview\"] payload reported schema_version 17 and every new field (ai_provider_class=none, pulse_intelligence_patrol_autonomy_level=monitor, both Patrol token buckets=zero, all thirteen pulse_intelligence_patrol_investigation_outcome_*_30d counts=0 summing to pulse_intelligence_patrol_investigations_30d=0) at both viewports, with document.scrollWidth equal to the viewport width (no horizontal overflow) on desktop and narrow.", + "Shipped privacy document popup at /docs/PRIVACY opened from the Full details link, carrying the schema version 17 row and the new field rows (tests/integration/tests/19-telemetry-disclosure.spec.ts, chromium project, 2 passed; the mobile projects skip this desktop-only spec by design and were covered by the narrow-viewport manual inspection instead).", + "Re-verified after rebasing onto main facee87bb4e7 with the hosted_legacy vocabulary rename: same spec run (2 passed on chromium) and the same two-viewport inspection, schema_version 17, all seventeen fields present, ai_provider_class=none, outcome buckets summing to the investigation count, no horizontal overflow at 390 wide.", + "Re-verified again on main b5f694b216e7 (Patrol page and Helm changes landed in between, none touching the settings preview): disclosure spec 2 passed on chromium after one warm-up retry, and the two-viewport inspection again reported schema_version 17, all seventeen fields, buckets summing to the investigation count, no horizontal overflow at 390 wide." ], "interactions": [ - "clicked the Activity workspace tab", - "scrolled the card into view at desktop and narrow widths", - "clicked Refresh this week's summary and confirmed the tiles reloaded without an error state", - "checked console for card-originated errors (only unrelated dev websocket/update-check noise)" + "Loaded /settings/system-general#usage-telemetry with the harness cookie session at 1280x800 and 390x844, scrolled the Preview payload button into view, clicked it, waited for the payload pre, parsed the JSON, and asserted the seventeen schema v17 keys were present with closed-vocabulary values.", + "Ran tests/integration/tests/19-telemetry-disclosure.spec.ts (chromium and mobile-chrome projects) against the scratch build with EXPECTED_TELEMETRY_SCHEMA_VERSION = 17: Full details popup to /docs/PRIVACY, Preview payload click, schema_version and counter assertions, and the install ID reset flow." ] } diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 394bf837d..f1df8dcbd 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -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. diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index fcf24c73d..2dcaab4ae 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -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, diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 81ffb4c59..476212518 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -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; diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts index 428f541f7..cf8ff8684 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts @@ -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, diff --git a/internal/api/telemetry_pulse_intelligence.go b/internal/api/telemetry_pulse_intelligence.go index 0fd125e1b..df3514e8d 100644 --- a/internal/api/telemetry_pulse_intelligence.go +++ b/internal/api/telemetry_pulse_intelligence.go @@ -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 } diff --git a/internal/api/telemetry_pulse_intelligence_patrol_test.go b/internal/api/telemetry_pulse_intelligence_patrol_test.go new file mode 100644 index 000000000..96299f24d --- /dev/null +++ b/internal/api/telemetry_pulse_intelligence_patrol_test.go @@ -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) + } +} diff --git a/internal/telemetry/patrol_cost.go b/internal/telemetry/patrol_cost.go new file mode 100644 index 000000000..8a3009e22 --- /dev/null +++ b/internal/telemetry/patrol_cost.go @@ -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 +} diff --git a/internal/telemetry/patrol_cost_test.go b/internal/telemetry/patrol_cost_test.go new file mode 100644 index 000000000..08182b3a8 --- /dev/null +++ b/internal/telemetry/patrol_cost_test.go @@ -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) + } +} diff --git a/internal/telemetry/pulse_intelligence_evidence.go b/internal/telemetry/pulse_intelligence_evidence.go index 655ede4c8..f6a639756 100644 --- a/internal/telemetry/pulse_intelligence_evidence.go +++ b/internal/telemetry/pulse_intelligence_evidence.go @@ -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 diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 0506f9e2e..770777c07 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -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 diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 70aeaa5c2..617349a1b 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -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 || diff --git a/pkg/server/server.go b/pkg/server/server.go index d176f7be8..69d2952e9 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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() diff --git a/pkg/server/telemetry_pulse_intelligence.go b/pkg/server/telemetry_pulse_intelligence.go index 524958afe..dc5f5e860 100644 --- a/pkg/server/telemetry_pulse_intelligence.go +++ b/pkg/server/telemetry_pulse_intelligence.go @@ -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 { diff --git a/pkg/server/telemetry_pulse_intelligence_test.go b/pkg/server/telemetry_pulse_intelligence_test.go index df7b74add..be02a9df8 100644 --- a/pkg/server/telemetry_pulse_intelligence_test.go +++ b/pkg/server/telemetry_pulse_intelligence_test.go @@ -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) + } +} diff --git a/tests/integration/tests/19-telemetry-disclosure.spec.ts b/tests/integration/tests/19-telemetry-disclosure.spec.ts index fdff7dd19..f46adee01 100644 --- a/tests/integration/tests/19-telemetry-disclosure.spec.ts +++ b/tests/integration/tests/19-telemetry-disclosure.spec.ts @@ -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;