Retire customer-side commercial analytics emission

This commit is contained in:
rcourtman
2026-04-30 10:45:16 +01:00
parent 977764bc09
commit 274bb4c60f
11 changed files with 225 additions and 415 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
]);
});
});
@@ -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,
});
}
}
}
}
@@ -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);
});
});
@@ -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');
});
});
@@ -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 {
@@ -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<string, InfrastructureOnboardingMetricsTracker>();
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<InfrastructureOnboardingPath>();
const selectedCatalogTypes = new Set<ConnectionType>();
const openedCredentialTypes = new Set<ConnectionType>();
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.
}
+19 -114
View File
@@ -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<string>();
const sentAtByKey = new Map<string, number>();
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.
}