From 274bb4c60f351fc7f3ed5b22fd75ee12166cda29 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 30 Apr 2026 10:45:16 +0100 Subject: [PATCH] Retire customer-side commercial analytics emission --- .../v6/internal/subsystems/api-contracts.md | 6 +- .../v6/internal/subsystems/cloud-paid.md | 54 ++++--- .../subsystems/frontend-primitives.md | 11 +- .../internal/subsystems/security-privacy.md | 3 + ...ttings-diagnostics-boundary-audit.test.mjs | 42 ++++++ .../settings-diagnostics-boundary-audit.mjs | 76 ++++++++-- .../infrastructureOnboardingMetrics.test.ts | 89 ++--------- .../utils/__tests__/upgradeMetrics.test.ts | 80 +++------- frontend-modern/src/utils/conversionEvents.ts | 4 +- .../utils/infrastructureOnboardingMetrics.ts | 142 +++--------------- frontend-modern/src/utils/upgradeMetrics.ts | 133 +++------------- 11 files changed, 225 insertions(+), 415 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a869a96d9..d2808500d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1815,8 +1815,10 @@ reporting routes themselves: `/api/upgrade-metrics/stats`, `/api/upgrade-metrics/health`, `/api/upgrade-metrics/config`, and `/api/admin/upgrade-metrics-funnel` must require admin/settings-scope access rather than becoming general authenticated product reads. Authenticated event -ingestion at `/api/upgrade-metrics/events` may remain fire-and-forget UX -plumbing, but reporting and control stay on the privileged settings boundary. +ingestion at `/api/upgrade-metrics/events` may remain only as compatibility or +admin-owned ingestion plumbing; customer frontend surfaces must not call it for +pricing, checkout, paywall, commercial funnel, or infrastructure-onboarding +signals. Reporting and control stay on the privileged settings boundary. That same public-demo API boundary must also hide runtime-admin operations surfaces instead of treating them as harmless reads. Demo sessions must receive `404` for `/api/diagnostics`, `/api/diagnostics/docker/prepare-token`, and the diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 329e2d5be..54cb132c9 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -522,16 +522,17 @@ runtime gating as separate unlinked claims. storage, API response, or grant signing so Lifetime, Pro, Pro+, and grandfathered recurring customers cannot be downgraded to the partial feature list carried by an old JWT or legacy plan row. -14. Keep self-hosted commercial funnel stage ownership explicit: - `pkg/licensing/conversion_events.go`, - `pkg/licensing/conversion_store.go`, and - `frontend-modern/src/utils/upgradeMetrics.ts` own in-app `Plans & Billing` - stage events such as `pricing_viewed` and `checkout_clicked`, while - `pulse-pro:license-server/v6_checkout.go` owns the Pulse Account handoff - equivalents bound to `portal_handoff_id`. Pulse must not infer those - portal stages from referrer state, and the commercial service must keep - those self-hosted handoffs on release track `v6` even while the public - site remains on `v5` before GA. +14. Keep self-hosted commercial funnel stage ownership out of the customer + frontend. `pkg/licensing/conversion_events.go` and + `pkg/licensing/conversion_store.go` may retain server/admin compatibility + handling for local commercial reporting, while + `frontend-modern/src/utils/upgradeMetrics.ts` is a compatibility no-op and + must not POST in-app `Plans & Billing`, pricing, checkout, paywall, or + onboarding events from browser product surfaces. `pulse-pro:license-server/v6_checkout.go` + owns the Pulse Account handoff equivalents bound to `portal_handoff_id`. + Pulse must not infer those portal stages from referrer state, and the + commercial service must keep those self-hosted handoffs on release track + `v6` even while the public site remains on `v5` before GA. 15. Keep local commercial funnel reporting inside the self-hosted privacy boundary, not user diagnostics: local upgrade-metric summaries, daily buckets, surface/capability breakdowns, and infrastructure-onboarding @@ -542,8 +543,9 @@ runtime gating as separate unlinked claims. expose them in the customer support diagnostics payload or panel. The local reporting/control endpoints themselves must stay privileged: stats, health, config, and funnel reads must require admin plus - settings-scope access; only event ingestion may remain an authenticated - fire-and-forget path for user interactions. + settings-scope access; event ingestion may remain only for compatibility + or admin-owned producers and must not be called by customer frontend + interactions. 16. Keep ordinary self-hosted v6 commercial prompts opt-in. Cloud-paid runtime may keep checkout, activation, recovery, and support-only trial plumbing available for explicit handoffs and entitled installs, but default @@ -1895,18 +1897,19 @@ feature/limit primitives, billing and entitlement type shapes, commercial migration and trial flow, conversion telemetry, host lifecycle tracking, and public-key/build-mode boundaries should all resolve through explicit proof routes rather than a package-wide `pkg/licensing/` fallback. -That same conversion-telemetry boundary now treats self-hosted commercial -progression as explicit stage events instead of inferring everything from -backend completion. `pkg/licensing/conversion_events.go`, -`pkg/licensing/conversion_store.go`, and -`frontend-modern/src/utils/upgradeMetrics.ts` own local `pricing_viewed` and -`checkout_clicked` events for the in-app `Plans & Billing` plan surface, while -`pulse-pro:license-server/v6_checkout.go` owns the Pulse Account handoff -equivalents bound to `portal_handoff_id` and the canonical checkout intent. -The browser app must not try to recreate those Pulse Account stages from -referrer state, and the commercial service must not collapse self-hosted v6 -handoffs back onto the public-site release track when production public GA is -still on v5. +That same conversion-telemetry boundary now keeps self-hosted commercial +progression out of customer-side browser analytics. +`pkg/licensing/conversion_events.go` and `pkg/licensing/conversion_store.go` +may own local server/admin reporting compatibility, while +`frontend-modern/src/utils/upgradeMetrics.ts` remains a compatibility no-op +and must not emit local `pricing_viewed`, `checkout_clicked`, paywall, +commercial funnel, or onboarding events from the in-app `Plans & Billing` plan +surface. `pulse-pro:license-server/v6_checkout.go` owns the Pulse Account +handoff equivalents bound to `portal_handoff_id` and the canonical checkout +intent. The browser app must not try to recreate those Pulse Account stages +from referrer state, and the commercial service must not collapse self-hosted +v6 handoffs back onto the public-site release track when production public GA +is still on v5. That same local conversion store remains the canonical read model for self-hosted commercial reporting, but not for user support diagnostics: admin-owned reporting surfaces may read structured local funnel reports from @@ -1915,7 +1918,8 @@ support diagnostics panel must not expose pricing, checkout, conversion, or infrastructure-onboarding analytics. The API route boundary must preserve that same split: local commercial metric stats, health, config, and funnel reads are admin/settings-scope surfaces, while `/api/upgrade-metrics/events` remains only -the authenticated local ingestion path for explicit product interactions. +compatibility/admin-owned ingestion and must not be called by customer browser +product interactions. Stripe checkout and subscription webhook persistence now also follows the canonical Cloud/MSP limit rule: when paid state is granted, billing-state writes must persist authoritative `limits.max_monitored_systems` derived from canonical diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5dc588db8..c8904e946 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -690,7 +690,8 @@ work extends shared components instead of creating new local variants. 5. User-facing diagnostics or settings panels rendering maintainer/admin analytics such as commercial funnel, sales funnel, pricing/checkout conversion, or infrastructure onboarding telemetry. Those signals belong in - admin-owned metrics surfaces, not the product diagnostics UI. + admin-owned metrics surfaces, not the product diagnostics UI or customer + frontend event emission. ## Completion Obligations @@ -1014,9 +1015,11 @@ explicit `Usage data and privacy` model centered on `Anonymous outbound telemetry`; maintainer commercial-event controls, upgrade-metrics labels, and sales/onboarding reporting language must not appear in customer-facing Settings or support diagnostics, and public configuration docs must not list their -internal compatibility switches as ordinary operator settings. The telemetry -copy must describe normalized release identity rather than falling back to -ambiguous `telemetry`, `upgrade metrics`, or raw-version wording. +internal compatibility switches as ordinary operator settings. Customer +frontend code must also not POST those events to `/api/upgrade-metrics/events`. +The telemetry copy must describe normalized release identity rather than +falling back to ambiguous `telemetry`, `upgrade metrics`, or raw-version +wording. Shared table, disclosure, and form primitives must also stay explicitly typed at the browser edge. Summary rows may memoize repeated pending-update reads, shared buttons must preserve discriminated disclosure props, toggle and a11y diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 477116acf..a6663facc 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -210,6 +210,9 @@ canonically owned by `frontend-modern/src/components/Settings/useSystemSettingsS while local commercial compatibility state remains an internal store/backend concern in `frontend-modern/src/stores/systemSettings.ts`, not settings navigation metadata or other frontend-primitives owners. +Retired local-upgrade-metrics compatibility must not become customer-side +commercial analytics emission; browser product surfaces must not POST pricing, +checkout, paywall, funnel, or onboarding signals to `/api/upgrade-metrics/events`. The security transport surfaces remain intentionally shared with `api-contracts`: token, auth, and telemetry settings payloads are still API diff --git a/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs b/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs index f6635a118..d35dacef0 100644 --- a/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs +++ b/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs @@ -16,6 +16,8 @@ function writeFixture(root, repoRoot, files) { 'export function DiagnosticsResultsPanel() { return null; }\n', [path.join(root, 'src', 'components', 'Settings', 'diagnosticsModel.ts')]: 'export interface DiagnosticsInfo { version: string; }\n', + [path.join(root, 'src', 'utils', 'upgradeMetrics.ts')]: + 'export function trackUpgradeMetricEvent() {}\n', ...files, }; @@ -82,4 +84,44 @@ export function stripInternalAnalyticsDiagnosticsFields(payload) { expect(collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot })).toEqual([]); }); + + it('reports product-side upgrade-metrics ingestion plumbing', () => { + const { root, repoRoot } = makeFixture(({ root }) => ({ + [path.join(root, 'src', 'utils', 'upgradeMetrics.ts')]: ` +import { apiFetch } from '@/utils/apiClient'; + +export function trackUpgradeMetricEvent() { + void apiFetch('/api/upgrade-metrics/events'); +} +`, + })); + + const findings = collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot }); + + expect(findings.map((finding) => finding.rule)).toEqual([ + 'canonical-settings/no-product-upgrade-metrics-ingestion', + 'canonical-settings/no-product-upgrade-metrics-ingestion', + 'canonical-settings/no-product-upgrade-metrics-ingestion', + 'canonical-settings/no-product-upgrade-metrics-endpoint', + ]); + }); + + it('reports direct production frontend calls to upgrade-metrics ingestion', () => { + const { root, repoRoot } = makeFixture(({ root }) => ({ + [path.join(root, 'src', 'components', 'Settings', 'CommercialProbe.tsx')]: ` +export function CommercialProbe() { + void fetch('/api/upgrade-metrics/events'); + return null; +} +`, + [path.join(root, 'src', 'components', 'Settings', '__tests__', 'CommercialProbe.test.tsx')]: + "expect(source).toContain('/api/upgrade-metrics/events');\n", + })); + + const findings = collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot }); + + expect(findings.map((finding) => finding.rule)).toEqual([ + 'canonical-settings/no-product-upgrade-metrics-endpoint', + ]); + }); }); diff --git a/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs b/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs index 95cbd3208..46649a3cf 100644 --- a/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs +++ b/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs @@ -47,8 +47,57 @@ const INTERNAL_ANALYTICS_RULES = [ }, ], }, + { + getFilePath: ({ root }) => path.join(root, 'src', 'utils', 'upgradeMetrics.ts'), + rules: [ + { + rule: 'canonical-settings/no-product-upgrade-metrics-ingestion', + regex: + /\/api\/upgrade-metrics\/events|@\/utils\/apiClient|\bapiFetch\s*\(|\bfetch\s*\(|\bsendBeacon\b/g, + message: + 'Do not emit maintainer/admin commercial analytics from the customer frontend. Keep upgrade-metrics ingestion out of product surfaces.', + }, + ], + }, + { + getFilePaths: ({ root }) => listProductionSourceFiles(path.join(root, 'src')), + rules: [ + { + rule: 'canonical-settings/no-product-upgrade-metrics-endpoint', + regex: /\/api\/upgrade-metrics\/events/g, + message: + 'Do not call local commercial analytics ingestion from production customer frontend source.', + }, + ], + }, ]; +function isProductionSourceFile(filePath) { + if (!/\.(?:ts|tsx)$/.test(filePath)) return false; + if (filePath.includes(`${path.sep}__tests__${path.sep}`)) return false; + if (/\.(?:test|spec)\.(?:ts|tsx)$/.test(filePath)) return false; + return true; +} + +function listProductionSourceFiles(dir) { + const files = []; + if (!fs.existsSync(dir)) return files; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listProductionSourceFiles(entryPath)); + continue; + } + + if (entry.isFile() && isProductionSourceFile(entryPath)) { + files.push(entryPath); + } + } + + return files; +} + function lineForIndex(content, index) { let line = 1; for (let i = 0; i < index; i += 1) { @@ -67,19 +116,22 @@ export function collectUserDiagnosticsInternalAnalyticsFindings({ } = {}) { const findings = []; - for (const { getFilePath, rules } of INTERNAL_ANALYTICS_RULES) { - const filePath = getFilePath({ root, repoRoot }); - const content = fs.readFileSync(filePath, 'utf8'); - const relativePath = relativeToRoot(root, filePath); + for (const { getFilePath, getFilePaths, rules } of INTERNAL_ANALYTICS_RULES) { + const filePaths = getFilePaths?.({ root, repoRoot }) ?? [getFilePath({ root, repoRoot })]; - for (const { rule, regex, message } of rules) { - for (const match of content.matchAll(regex)) { - findings.push({ - file: relativePath, - line: lineForIndex(content, match.index ?? 0), - rule, - message, - }); + for (const filePath of filePaths) { + const content = fs.readFileSync(filePath, 'utf8'); + const relativePath = relativeToRoot(root, filePath); + + for (const { rule, regex, message } of rules) { + for (const match of content.matchAll(regex)) { + findings.push({ + file: relativePath, + line: lineForIndex(content, match.index ?? 0), + rule, + message, + }); + } } } } diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingMetrics.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingMetrics.test.ts index 4b80dcab1..1f8b9101c 100644 --- a/frontend-modern/src/utils/__tests__/infrastructureOnboardingMetrics.test.ts +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingMetrics.test.ts @@ -1,109 +1,44 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { trackUpgradeMetricEventMock } = vi.hoisted(() => ({ - trackUpgradeMetricEventMock: vi.fn(), -})); - -vi.mock('@/utils/upgradeMetrics', () => ({ - trackUpgradeMetricEvent: trackUpgradeMetricEventMock, - UPGRADE_METRIC_EVENTS: { - INFRASTRUCTURE_ONBOARDING_OPENED: 'infrastructure_onboarding_opened', - INFRASTRUCTURE_ONBOARDING_PATH_SELECTED: 'infrastructure_onboarding_path_selected', - INFRASTRUCTURE_ONBOARDING_PROBE_RESULT: 'infrastructure_onboarding_probe_result', - INFRASTRUCTURE_ONBOARDING_CATALOG_SELECTED: 'infrastructure_onboarding_catalog_selected', - INFRASTRUCTURE_ONBOARDING_CREDENTIALS_OPENED: 'infrastructure_onboarding_credentials_opened', - }, -})); +import { describe, expect, it } from 'vitest'; import { clearSharedInfrastructureOnboardingMetricsTracker, createInfrastructureOnboardingMetricsTracker, getSharedInfrastructureOnboardingMetricsTracker, } from '@/utils/infrastructureOnboardingMetrics'; +import infrastructureOnboardingMetricsSource from '@/utils/infrastructureOnboardingMetrics.ts?raw'; describe('infrastructureOnboardingMetrics', () => { - beforeEach(() => { - trackUpgradeMetricEventMock.mockClear(); - clearSharedInfrastructureOnboardingMetricsTracker(); + it('does not bridge infrastructure onboarding to maintainer analytics', () => { + expect(infrastructureOnboardingMetricsSource).not.toContain('trackUpgradeMetricEvent'); + expect(infrastructureOnboardingMetricsSource).not.toContain('UPGRADE_METRIC_EVENTS'); + expect(infrastructureOnboardingMetricsSource).not.toContain('/api/upgrade-metrics/events'); + expect(infrastructureOnboardingMetricsSource).not.toContain('sessionStorage'); }); - it('deduplicates flow-scoped onboarding steps inside one flow', () => { + it('keeps the tracker contract callable as a compatibility no-op', () => { const tracker = createInfrastructureOnboardingMetricsTracker(); tracker.recordOpened(); tracker.recordOpened(); tracker.recordPathSelected('api'); tracker.recordPathSelected('api'); + tracker.recordProbeResult('no-match'); + tracker.recordProbeResult('error'); tracker.recordCatalogSelected('truenas'); tracker.recordCatalogSelected('truenas'); tracker.recordCredentialsOpened('truenas'); tracker.recordCredentialsOpened('truenas'); - - expect(trackUpgradeMetricEventMock).toHaveBeenCalledTimes(4); - expect(trackUpgradeMetricEventMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - type: 'infrastructure_onboarding_opened', - surface: 'settings_infrastructure_add', - }), - ); - expect(trackUpgradeMetricEventMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: 'infrastructure_onboarding_path_selected', - capability: 'api', - }), - ); - expect(trackUpgradeMetricEventMock).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - type: 'infrastructure_onboarding_catalog_selected', - capability: 'truenas', - }), - ); - expect(trackUpgradeMetricEventMock).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - type: 'infrastructure_onboarding_credentials_opened', - capability: 'truenas', - }), - ); }); - it('records each probe attempt separately and isolates flow ids between trackers', () => { + it('returns the same no-op tracker for created and shared flows', () => { const firstTracker = createInfrastructureOnboardingMetricsTracker(); - const secondTracker = createInfrastructureOnboardingMetricsTracker(); - - firstTracker.recordProbeResult('no-match'); - firstTracker.recordProbeResult('no-match'); - secondTracker.recordOpened(); - - expect(trackUpgradeMetricEventMock).toHaveBeenCalledTimes(3); - - const firstProbe = trackUpgradeMetricEventMock.mock.calls[0][0]; - const secondProbe = trackUpgradeMetricEventMock.mock.calls[1][0]; - const secondFlowOpen = trackUpgradeMetricEventMock.mock.calls[2][0]; - - expect(firstProbe.idempotencyKey).not.toBe(secondProbe.idempotencyKey); - expect(firstProbe.idempotencyKey).not.toBe(secondFlowOpen.idempotencyKey); - expect(firstProbe.capability).toBe('no-match'); - expect(secondProbe.capability).toBe('no-match'); - }); - - it('reuses the shared tracker until the flow is cleared', () => { - const firstTracker = getSharedInfrastructureOnboardingMetricsTracker(); const secondTracker = getSharedInfrastructureOnboardingMetricsTracker(); expect(firstTracker).toBe(secondTracker); - firstTracker.recordOpened(); - secondTracker.recordOpened(); - - expect(trackUpgradeMetricEventMock).toHaveBeenCalledTimes(1); - clearSharedInfrastructureOnboardingMetricsTracker(); const thirdTracker = getSharedInfrastructureOnboardingMetricsTracker(); - expect(thirdTracker).not.toBe(firstTracker); + expect(thirdTracker).toBe(firstTracker); }); }); diff --git a/frontend-modern/src/utils/__tests__/upgradeMetrics.test.ts b/frontend-modern/src/utils/__tests__/upgradeMetrics.test.ts index 490731ff0..6c35a4c87 100644 --- a/frontend-modern/src/utils/__tests__/upgradeMetrics.test.ts +++ b/frontend-modern/src/utils/__tests__/upgradeMetrics.test.ts @@ -1,85 +1,41 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { apiFetchMock } = vi.hoisted(() => ({ - apiFetchMock: vi.fn(() => Promise.resolve(new Response('{}', { status: 200 }))), -})); - -vi.mock('@/utils/apiClient', () => ({ - apiFetch: apiFetchMock, -})); +import { describe, expect, it } from 'vitest'; import { - trackCheckoutClicked, - trackPricingViewed, trackAgentFirstConnected, trackAgentInstallCommandCopied, trackAgentInstallProfileSelected, trackAgentInstallTokenGenerated, + trackCheckoutClicked, + trackPaywallViewed, + trackPricingViewed, + trackUpgradeClicked, trackUpgradeMetricEvent, } from '@/utils/upgradeMetrics'; +import upgradeMetricsSource from '@/utils/upgradeMetrics.ts?raw'; -function getPayloadForCall(index: number) { - const [, options] = apiFetchMock.mock.calls[index] as unknown as [string, RequestInit]; - return JSON.parse(String(options.body)); -} - -describe('upgradeMetrics local-only UX metrics wrappers', () => { - beforeEach(() => { - apiFetchMock.mockClear(); +describe('upgradeMetrics customer frontend boundary', () => { + it('does not carry browser-side ingestion plumbing for maintainer analytics', () => { + expect(upgradeMetricsSource).not.toContain('/api/upgrade-metrics/events'); + expect(upgradeMetricsSource).not.toContain('@/utils/apiClient'); + expect(upgradeMetricsSource).not.toContain('apiFetch('); + expect(upgradeMetricsSource).not.toContain('fetch('); + expect(upgradeMetricsSource).not.toContain('sendBeacon'); }); - it('sends unified agent onboarding events with expected types', () => { + it('keeps compatibility wrappers callable without emitting product analytics', () => { + trackPaywallViewed('rbac', 'settings_roles_panel'); + trackPricingViewed('settings_self_hosted_billing_plan', 'self_hosted_plan'); + trackUpgradeClicked('settings_reporting_panel', 'reporting'); + trackCheckoutClicked('settings_self_hosted_billing_compare_prompt', 'self_hosted_plan'); trackAgentInstallTokenGenerated('settings_unified_agents', 'manual'); trackAgentInstallCommandCopied('settings_unified_agents', 'linux:auto:install'); trackAgentInstallProfileSelected('settings_unified_agents', 'proxmox-pbs'); trackAgentFirstConnected('setup_wizard_complete', 'first_agent'); - - expect(apiFetchMock).toHaveBeenCalledTimes(4); - expect(getPayloadForCall(0).type).toBe('agent_install_token_generated'); - expect(getPayloadForCall(1).type).toBe('agent_install_command_copied'); - expect(getPayloadForCall(2).type).toBe('agent_install_profile_selected'); - expect(getPayloadForCall(3).type).toBe('agent_first_connected'); - - expect(getPayloadForCall(0).surface).toBe('settings_unified_agents'); - expect(getPayloadForCall(2).capability).toBe('proxmox-pbs'); - }); - - it('deduplicates repeated identical events within one minute', () => { - trackAgentInstallCommandCopied('settings_unified_agents', 'linux:auto:install:dedupe'); - trackAgentInstallCommandCopied('settings_unified_agents', 'linux:auto:install:dedupe'); - - expect(apiFetchMock).toHaveBeenCalledTimes(1); - }); - - it('honors caller-supplied idempotency keys for distinct same-minute events', () => { trackUpgradeMetricEvent({ type: 'agent_install_command_copied', surface: 'settings_unified_agents', capability: 'linux:auto:install:custom', idempotencyKey: 'custom-1', }); - trackUpgradeMetricEvent({ - type: 'agent_install_command_copied', - surface: 'settings_unified_agents', - capability: 'linux:auto:install:custom', - idempotencyKey: 'custom-2', - }); - - expect(apiFetchMock).toHaveBeenCalledTimes(2); - expect(getPayloadForCall(0).idempotency_key).toBe('custom-1'); - expect(getPayloadForCall(1).idempotency_key).toBe('custom-2'); - }); - - it('sends canonical pricing and checkout funnel events for self-hosted billing surfaces', () => { - trackPricingViewed('settings_self_hosted_billing_plan', 'self_hosted_plan'); - trackCheckoutClicked('settings_self_hosted_billing_compare_prompt', 'self_hosted_plan'); - - expect(apiFetchMock).toHaveBeenCalledTimes(2); - expect(getPayloadForCall(0).type).toBe('pricing_viewed'); - expect(getPayloadForCall(0).surface).toBe('settings_self_hosted_billing_plan'); - expect(getPayloadForCall(0).capability).toBe('self_hosted_plan'); - expect(getPayloadForCall(1).type).toBe('checkout_clicked'); - expect(getPayloadForCall(1).surface).toBe('settings_self_hosted_billing_compare_prompt'); - expect(getPayloadForCall(1).capability).toBe('self_hosted_plan'); }); }); diff --git a/frontend-modern/src/utils/conversionEvents.ts b/frontend-modern/src/utils/conversionEvents.ts index a67205c17..8b8cb9582 100644 --- a/frontend-modern/src/utils/conversionEvents.ts +++ b/frontend-modern/src/utils/conversionEvents.ts @@ -1,5 +1,5 @@ -// Compatibility wrapper: migrate callers to "@/utils/upgradeMetrics". -// Keep these exports stable for older modules/tests. +// Compatibility wrapper for retired customer-side commercial analytics exports. +// Keep these stable while older modules/tests migrate away from the names. export type { UpgradeMetricEvent as ConversionEvent } from './upgradeMetrics'; export { diff --git a/frontend-modern/src/utils/infrastructureOnboardingMetrics.ts b/frontend-modern/src/utils/infrastructureOnboardingMetrics.ts index 2c273b405..d94cb9ef6 100644 --- a/frontend-modern/src/utils/infrastructureOnboardingMetrics.ts +++ b/frontend-modern/src/utils/infrastructureOnboardingMetrics.ts @@ -1,29 +1,10 @@ import type { ConnectionType } from '@/api/connections'; -import { trackUpgradeMetricEvent, UPGRADE_METRIC_EVENTS } from '@/utils/upgradeMetrics'; export type InfrastructureOnboardingPath = 'api' | 'agent'; export type InfrastructureOnboardingProbeOutcome = 'detected' | 'no-match' | 'error'; export const INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE = 'settings_infrastructure_add'; -let onboardingFlowCounter = 0; -const sharedTrackers = new Map(); -const SHARED_FLOW_STORAGE_KEY_PREFIX = 'pulse.infrastructure-onboarding.flow'; - -function createOnboardingFlowId(): string { - const uuid = globalThis.crypto?.randomUUID?.(); - if (uuid) { - return `infra-onboarding:${uuid}`; - } - - onboardingFlowCounter += 1; - return `infra-onboarding:${Date.now().toString(36)}:${onboardingFlowCounter.toString(36)}`; -} - -function buildFlowScopedKey(flowId: string, scope: string): string { - return `${flowId}:${scope}`; -} - export interface InfrastructureOnboardingMetricsTracker { recordOpened: () => void; recordPathSelected: (path: InfrastructureOnboardingPath) => void; @@ -32,112 +13,39 @@ export interface InfrastructureOnboardingMetricsTracker { recordCredentialsOpened: (type: ConnectionType) => void; } +const NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER: InfrastructureOnboardingMetricsTracker = { + recordOpened() { + // Compatibility no-op. + }, + recordPathSelected(_path) { + // Compatibility no-op. + }, + recordProbeResult(_outcome) { + // Compatibility no-op. + }, + recordCatalogSelected(_type) { + // Compatibility no-op. + }, + recordCredentialsOpened(_type) { + // Compatibility no-op. + }, +}; + export function createInfrastructureOnboardingMetricsTracker( - surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, - flowId = createOnboardingFlowId(), + _surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, + _flowId?: string, ): InfrastructureOnboardingMetricsTracker { - const selectedPaths = new Set(); - const selectedCatalogTypes = new Set(); - const openedCredentialTypes = new Set(); - let opened = false; - let probeAttempt = 0; - - return { - recordOpened() { - if (opened) return; - opened = true; - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.INFRASTRUCTURE_ONBOARDING_OPENED, - surface, - idempotencyKey: buildFlowScopedKey(flowId, 'opened'), - }); - }, - - recordPathSelected(path) { - if (selectedPaths.has(path)) return; - selectedPaths.add(path); - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.INFRASTRUCTURE_ONBOARDING_PATH_SELECTED, - surface, - capability: path, - idempotencyKey: buildFlowScopedKey(flowId, `path:${path}`), - }); - }, - - recordProbeResult(outcome) { - probeAttempt += 1; - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.INFRASTRUCTURE_ONBOARDING_PROBE_RESULT, - surface, - capability: outcome, - idempotencyKey: buildFlowScopedKey(flowId, `probe:${probeAttempt}:${outcome}`), - }); - }, - - recordCatalogSelected(type) { - if (selectedCatalogTypes.has(type)) return; - selectedCatalogTypes.add(type); - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.INFRASTRUCTURE_ONBOARDING_CATALOG_SELECTED, - surface, - capability: type, - idempotencyKey: buildFlowScopedKey(flowId, `catalog:${type}`), - }); - }, - - recordCredentialsOpened(type) { - if (openedCredentialTypes.has(type)) return; - openedCredentialTypes.add(type); - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.INFRASTRUCTURE_ONBOARDING_CREDENTIALS_OPENED, - surface, - capability: type, - idempotencyKey: buildFlowScopedKey(flowId, `credentials:${type}`), - }); - }, - }; -} - -function sharedFlowStorageKey(surface: string): string { - return `${SHARED_FLOW_STORAGE_KEY_PREFIX}:${surface}`; -} - -function readSharedFlowId(surface: string): string | null { - try { - return globalThis.sessionStorage?.getItem(sharedFlowStorageKey(surface)) ?? null; - } catch { - return null; - } -} - -function writeSharedFlowId(surface: string, flowId: string): void { - try { - globalThis.sessionStorage?.setItem(sharedFlowStorageKey(surface), flowId); - } catch { - // Session storage persistence is best-effort only. - } + return NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER; } export function getSharedInfrastructureOnboardingMetricsTracker( - surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, + _surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, ): InfrastructureOnboardingMetricsTracker { - const tracker = sharedTrackers.get(surface); - if (tracker) return tracker; - - const flowId = readSharedFlowId(surface) ?? createOnboardingFlowId(); - writeSharedFlowId(surface, flowId); - const nextTracker = createInfrastructureOnboardingMetricsTracker(surface, flowId); - sharedTrackers.set(surface, nextTracker); - return nextTracker; + return NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER; } export function clearSharedInfrastructureOnboardingMetricsTracker( - surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, + _surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE, ): void { - sharedTrackers.delete(surface); - try { - globalThis.sessionStorage?.removeItem(sharedFlowStorageKey(surface)); - } catch { - // Session storage persistence is best-effort only. - } + // Compatibility no-op. } diff --git a/frontend-modern/src/utils/upgradeMetrics.ts b/frontend-modern/src/utils/upgradeMetrics.ts index e78b6cb6d..16ec24aa0 100644 --- a/frontend-modern/src/utils/upgradeMetrics.ts +++ b/frontend-modern/src/utils/upgradeMetrics.ts @@ -1,7 +1,3 @@ -import { apiFetch } from '@/utils/apiClient'; -import { logger } from '@/utils/logger'; -import { shouldDisableLocalUpgradeMetrics } from '@/stores/systemSettings'; - export interface UpgradeMetricEvent { type: string; capability?: string; @@ -45,132 +41,41 @@ export const UPGRADE_METRIC_EVENTS = { INFRASTRUCTURE_ONBOARDING_CREDENTIALS_OPENED: 'infrastructure_onboarding_credentials_opened', } as const; -const ONE_MINUTE_MS = 60_000; -const recentlySentKeys = new Set(); -const sentAtByKey = new Map(); - -function pruneExpiredKeys(now: number): void { - for (const [key, sentAt] of sentAtByKey.entries()) { - if (now - sentAt <= ONE_MINUTE_MS) continue; - sentAtByKey.delete(key); - recentlySentKeys.delete(key); - } -} - export function trackUpgradeMetricEvent( - event: TrackUpgradeMetricEventInput, + _event: TrackUpgradeMetricEventInput, ): void { - if (shouldDisableLocalUpgradeMetrics()) { - return; - } - - const now = Date.now(); - const idempotencyKey = - event.idempotencyKey ?? - `${event.type}:${event.surface}:${event.capability || ''}:${Math.floor(now / ONE_MINUTE_MS)}`; - - pruneExpiredKeys(now); - if (recentlySentKeys.has(idempotencyKey)) { - return; - } - - recentlySentKeys.add(idempotencyKey); - sentAtByKey.set(idempotencyKey, now); - - const payload: UpgradeMetricEvent = { - type: event.type, - capability: event.capability, - surface: event.surface, - tenant_mode: event.tenant_mode, - limit_key: event.limit_key, - current_value: event.current_value, - limit_value: event.limit_value, - timestamp: now, - idempotency_key: idempotencyKey, - }; - - try { - void apiFetch('/api/upgrade-metrics/events', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch((error) => { - // Local upgrade metrics should never break user interactions. - logger.debug('[upgradeMetrics] Failed to send upgrade metric event', { - type: event.type, - surface: event.surface, - error, - }); - }); - } catch (error) { - logger.debug('[upgradeMetrics] Failed to queue upgrade metric event request', { - type: event.type, - surface: event.surface, - error, - }); - } + // Compatibility no-op: customer frontend surfaces must not emit maintainer + // commercial, funnel, or onboarding analytics. } -export function trackPaywallViewed(capability: string, surface: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.PAYWALL_VIEWED, - capability, - surface, - }); +export function trackPaywallViewed(_capability: string, _surface: string): void { + // Compatibility no-op. } -export function trackPricingViewed(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.PRICING_VIEWED, - surface, - capability, - }); +export function trackPricingViewed(_surface: string, _capability?: string): void { + // Compatibility no-op. } -export function trackUpgradeClicked(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.UPGRADE_CLICKED, - surface, - capability, - }); +export function trackUpgradeClicked(_surface: string, _capability?: string): void { + // Compatibility no-op. } -export function trackCheckoutClicked(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.CHECKOUT_CLICKED, - surface, - capability, - }); +export function trackCheckoutClicked(_surface: string, _capability?: string): void { + // Compatibility no-op. } -export function trackAgentInstallTokenGenerated(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.AGENT_INSTALL_TOKEN_GENERATED, - surface, - capability, - }); +export function trackAgentInstallTokenGenerated(_surface: string, _capability?: string): void { + // Compatibility no-op. } -export function trackAgentInstallCommandCopied(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.AGENT_INSTALL_COMMAND_COPIED, - surface, - capability, - }); +export function trackAgentInstallCommandCopied(_surface: string, _capability?: string): void { + // Compatibility no-op. } -export function trackAgentInstallProfileSelected(surface: string, profile: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.AGENT_INSTALL_PROFILE_SELECTED, - surface, - capability: profile, - }); +export function trackAgentInstallProfileSelected(_surface: string, _profile: string): void { + // Compatibility no-op. } -export function trackAgentFirstConnected(surface: string, capability?: string): void { - trackUpgradeMetricEvent({ - type: UPGRADE_METRIC_EVENTS.AGENT_FIRST_CONNECTED, - surface, - capability, - }); +export function trackAgentFirstConnected(_surface: string, _capability?: string): void { + // Compatibility no-op. }