mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Retire customer-side commercial analytics emission
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user