Instrument infrastructure onboarding funnel and harden onboarding proof

This commit is contained in:
rcourtman 2026-04-22 15:04:33 +01:00
parent dad0529a2f
commit 767a0dd5ba
14 changed files with 460 additions and 103 deletions

View file

@ -1,7 +1,7 @@
import { Component, For, Show } from 'solid-js';
import type { ProbeCandidate } from '@/api/connections';
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
import type { ConnectionEditorState } from './useConnectionEditor';
import type { CompletedProbePhase, ConnectionEditorState } from './useConnectionEditor';
import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
@ -9,12 +9,20 @@ export interface AddressProbeStepProps {
state: ConnectionEditorState;
onSelectCandidate: (candidate: ProbeCandidate) => void;
onInstallAgent?: () => void;
onProbeSubmitted?: () => void;
onProbeResolved?: (outcome: CompletedProbePhase) => void;
}
export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
const handleSubmit = (event: SubmitEvent) => {
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
void props.state.runProbe();
if (props.state.address().trim().length === 0) {
return;
}
props.onProbeSubmitted?.();
const outcome = await props.state.runProbe();
props.onProbeResolved?.(outcome);
};
const autoDetectLabels = getInfrastructureAutoDetectLabels();
@ -100,31 +108,34 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
</div>
<ul class="divide-y divide-border rounded-md border border-border">
<For each={props.state.candidates()}>
{(candidate) => (
<li>
<button
type="button"
class="flex w-full flex-col items-start gap-1 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
onClick={() => props.onSelectCandidate(candidate)}
>
<div class="text-sm font-medium text-base-content">
{CONNECTION_TYPE_LABELS[candidate.type] ?? candidate.type}
</div>
<div class="text-xs text-muted">{candidate.host}</div>
<Show when={candidate.hints && Object.keys(candidate.hints).length > 0}>
<div class="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted">
<For each={Object.entries(candidate.hints ?? {})}>
{([key, value]) => (
<span>
<span class="font-medium">{key}:</span> {value}
</span>
)}
</For>
{(candidate) => {
const hintEntries = Object.entries(candidate.hints ?? {});
return (
<li>
<button
type="button"
class="flex w-full flex-col items-start gap-1 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
onClick={() => props.onSelectCandidate(candidate)}
>
<div class="text-sm font-medium text-base-content">
{CONNECTION_TYPE_LABELS[candidate.type] ?? candidate.type}
</div>
</Show>
</button>
</li>
)}
<div class="text-xs text-muted">{candidate.host}</div>
<Show when={hintEntries.length > 0}>
<div class="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted">
<For each={hintEntries}>
{([key, value]) => (
<span>
<span class="font-medium">{key}:</span> {value}
</span>
)}
</For>
</div>
</Show>
</button>
</li>
);
}}
</For>
</ul>
</div>

View file

@ -1,4 +1,13 @@
import { Component, For, type JSX, Show, createMemo, createSignal } from 'solid-js';
import {
Component,
For,
type JSX,
Show,
createEffect,
createMemo,
createSignal,
onMount,
} from 'solid-js';
import { Archive, ArrowRight, Cpu, Database, Mail, Server, ServerCog } from 'lucide-solid';
import type { ConnectionType, ProbeCandidate } from '@/api/connections';
import { AddressProbeStep } from './AddressProbeStep';
@ -19,6 +28,7 @@ import {
getInfrastructureOnboardingProductPresentation,
getInfrastructureSupportSummaryBadges,
} from '@/utils/infrastructureOnboardingPresentation';
import { createInfrastructureOnboardingMetricsTracker } from '@/utils/infrastructureOnboardingMetrics';
export type ConnectionEditorMode = 'add' | 'edit';
@ -69,6 +79,8 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(null);
const [selectedFamilyId, setSelectedFamilyId] =
createSignal<ConnectionEditorCatalogFamilyId | null>(null);
const onboardingMetrics =
(props.mode ?? 'add') === 'add' ? createInfrastructureOnboardingMetricsTracker() : null;
const platformCatalogEntries = createMemo<ConnectionEditorCatalogEntry[]>(() =>
buildConnectionEditorCatalogEntries(props.manualTypeOptions),
@ -86,19 +98,48 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const activeType = () => selectedType();
const showCredentialSlot = () => activeType() !== null;
const recordPathSelectedForType = (type: ConnectionType) => {
onboardingMetrics?.recordPathSelected(type === 'agent' ? 'agent' : 'api');
};
onMount(() => {
if (!onboardingMetrics) return;
onboardingMetrics.recordOpened();
if (props.initialType) {
recordPathSelectedForType(props.initialType);
}
});
createEffect(() => {
const type = selectedType();
if (!onboardingMetrics || !type) return;
onboardingMetrics.recordCredentialsOpened(type);
});
const chooseCandidate = (candidate: ProbeCandidate) => {
onboardingMetrics?.recordPathSelected('api');
setSelectedCandidate(candidate);
setSelectedFamilyId(null);
setSelectedType(candidate.type);
};
const chooseManualType = (type: ConnectionType) => {
const chooseManualType = (
type: ConnectionType,
options?: {
fromCatalog?: boolean;
},
) => {
recordPathSelectedForType(type);
if (options?.fromCatalog && type !== 'agent') {
onboardingMetrics?.recordCatalogSelected(type);
}
setSelectedCandidate(null);
setSelectedFamilyId(null);
setSelectedType(type);
};
const chooseFamily = (familyId: ConnectionEditorCatalogFamilyId) => {
onboardingMetrics?.recordPathSelected('api');
setSelectedCandidate(null);
setSelectedFamilyId(familyId);
};
@ -255,6 +296,8 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
state={state}
onSelectCandidate={chooseCandidate}
onInstallAgent={() => chooseManualType('agent')}
onProbeSubmitted={() => onboardingMetrics?.recordPathSelected('api')}
onProbeResolved={(outcome) => onboardingMetrics?.recordProbeResult(outcome)}
/>
<Show
@ -283,7 +326,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
return (
<button
type="button"
onClick={() => chooseManualType(entry.type)}
onClick={() => chooseManualType(entry.type, { fromCatalog: true })}
class="group flex h-full flex-col gap-2 rounded-lg border border-border bg-surface p-4 text-left transition-colors hover:border-blue-500 hover:bg-blue-50/40 dark:hover:bg-blue-950/20"
>
<div class="flex items-center gap-2.5">
@ -370,7 +413,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
return (
<button
type="button"
onClick={() => chooseManualType(type)}
onClick={() => chooseManualType(type, { fromCatalog: true })}
class="group flex h-full flex-col gap-2 rounded-lg border border-border bg-surface-alt p-4 text-left transition-colors hover:border-blue-500 hover:bg-blue-50/40 dark:hover:bg-blue-950/20"
>
<div class="flex items-center gap-2.5">

View file

@ -3,16 +3,30 @@ import { render, fireEvent, screen, waitFor } from '@solidjs/testing-library';
import { ConnectionEditor } from '../ConnectionEditor';
import { ConnectionsAPI, type ProbeResponse } from '@/api/connections';
vi.mock('@/api/connections', async () => {
const actual = await vi.importActual<typeof import('@/api/connections')>('@/api/connections');
return {
...actual,
ConnectionsAPI: {
list: vi.fn(),
probe: vi.fn(),
},
};
});
const connectionsApiMock = vi.hoisted(() => ({
list: vi.fn(),
probe: vi.fn(),
setEnabled: vi.fn(),
remove: vi.fn(),
}));
const onboardingMetricsTrackerMock = vi.hoisted(() => ({
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
}));
const createInfrastructureOnboardingMetricsTrackerMock = vi.hoisted(() =>
vi.fn(() => onboardingMetricsTrackerMock),
);
vi.mock('@/api/connections', () => ({
ConnectionsAPI: connectionsApiMock,
}));
vi.mock('@/utils/infrastructureOnboardingMetrics', () => ({
createInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
}));
const mockedProbe = vi.mocked(ConnectionsAPI.probe);
@ -37,6 +51,8 @@ describe('ConnectionEditor', () => {
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
expect(createInfrastructureOnboardingMetricsTrackerMock).toHaveBeenCalledTimes(1);
expect(onboardingMetricsTrackerMock.recordOpened).toHaveBeenCalledTimes(1);
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'pve.lab' } });
@ -45,6 +61,10 @@ describe('ConnectionEditor', () => {
fireEvent.click(probeButton);
await waitFor(() => expect(mockedProbe).toHaveBeenCalledWith('pve.lab'));
await waitFor(() =>
expect(onboardingMetricsTrackerMock.recordProbeResult).toHaveBeenCalledWith('detected'),
);
expect(onboardingMetricsTrackerMock.recordPathSelected).toHaveBeenCalledWith('api');
const candidateHost = await screen.findByText('https://pve.lab:8006');
const candidateButton = candidateHost.closest('button');
@ -52,6 +72,7 @@ describe('ConnectionEditor', () => {
fireEvent.click(candidateButton!);
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:pve'));
expect(onboardingMetricsTrackerMock.recordCredentialsOpened).toHaveBeenCalledWith('pve');
expect(renderSlot).toHaveBeenCalled();
const lastCall = renderSlot.mock.calls.at(-1)![0];
expect(lastCall.type).toBe('pve');
@ -71,6 +92,9 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
await waitFor(() =>
expect(onboardingMetricsTrackerMock.recordProbeResult).toHaveBeenCalledWith('no-match'),
);
await screen.findByText(/no supported api-backed platform detected/i);
@ -79,6 +103,8 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
expect(onboardingMetricsTrackerMock.recordCatalogSelected).toHaveBeenCalledWith('truenas');
expect(onboardingMetricsTrackerMock.recordCredentialsOpened).toHaveBeenCalledWith('truenas');
const lastCall = renderSlot.mock.calls.at(-1)![0];
expect(lastCall.type).toBe('truenas');
expect(lastCall.candidate).toBeNull();
@ -164,6 +190,8 @@ describe('ConnectionEditor', () => {
fireEvent.click(agentButton);
expect(screen.getByTestId('slot').textContent).toBe('slot:agent');
expect(onboardingMetricsTrackerMock.recordPathSelected).toHaveBeenCalledWith('agent');
expect(onboardingMetricsTrackerMock.recordCredentialsOpened).toHaveBeenCalledWith('agent');
const call = renderSlot.mock.calls.at(-1)![0];
expect(call.type).toBe('agent');
expect(call.candidate).toBeNull();
@ -181,6 +209,7 @@ describe('ConnectionEditor', () => {
/>
));
expect(createInfrastructureOnboardingMetricsTrackerMock).not.toHaveBeenCalled();
expect(screen.getByTestId('slot').textContent).toBe('slot:vmware');
const call = renderSlot.mock.calls.at(0)![0];
expect(call.mode).toBe('edit');

View file

@ -27,6 +27,7 @@ function describeProbeError(error: unknown): string {
}
export type ProbePhase = 'idle' | 'probing' | 'detected' | 'no-match' | 'error';
export type CompletedProbePhase = Extract<ProbePhase, 'detected' | 'no-match' | 'error'>;
export interface ConnectionEditorState {
address: () => string;
@ -36,7 +37,7 @@ export interface ConnectionEditorState {
probedMs: () => number;
errorMessage: () => string;
reset: () => void;
runProbe: () => Promise<void>;
runProbe: () => Promise<CompletedProbePhase>;
}
export type PlatformConnectionType = Extract<
@ -233,7 +234,7 @@ export function createConnectionEditorState(): ConnectionEditorState {
if (!isSubmittableAddress(value)) {
setErrorMessage('Enter an address to probe.');
setPhase('error');
return;
return 'error';
}
setPhase('probing');
@ -247,12 +248,14 @@ export function createConnectionEditorState(): ConnectionEditorState {
} catch (error: unknown) {
setErrorMessage(describeProbeError(error));
setPhase('error');
return;
return 'error';
}
setProbedMs(response.probedMs);
setCandidates(response.candidates);
setPhase(response.candidates.length > 0 ? 'detected' : 'no-match');
const nextPhase: CompletedProbePhase = response.candidates.length > 0 ? 'detected' : 'no-match';
setPhase(nextPhase);
return nextPhase;
};
return {

View file

@ -60,7 +60,8 @@ describe('ConnectionsTable', () => {
it('renders an empty-state hint when no rows exist', () => {
render(() => <ConnectionsTable rows={() => []} />);
expect(screen.getByText(/No monitored systems yet/i)).toBeInTheDocument();
expect(screen.getByText('Start monitoring infrastructure')).toBeInTheDocument();
expect(screen.getByText(/Supported today: TrueNAS SCALE/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeNull();
});

View file

@ -14,6 +14,22 @@ const navigateSpy = vi.hoisted(() => vi.fn());
const setSearchParamsSpy = vi.hoisted(() => vi.fn());
const presentationPolicyIsReadOnlyMock = vi.hoisted(() => vi.fn(() => false));
const probeSpy = vi.hoisted(() => vi.fn().mockResolvedValue({ candidates: [], probedMs: 0 }));
const connectionsApiMock = vi.hoisted(() => ({
list: vi.fn(),
probe: probeSpy,
setEnabled: vi.fn(),
remove: vi.fn(),
}));
const onboardingMetricsTrackerMock = vi.hoisted(() => ({
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
}));
const createInfrastructureOnboardingMetricsTrackerMock = vi.hoisted(() =>
vi.fn(() => onboardingMetricsTrackerMock),
);
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
@ -91,16 +107,13 @@ vi.mock('../AgentProfilesPanel', () => ({
AgentProfilesPanel: () => <div data-testid="agent-profiles">profiles</div>,
}));
vi.mock('@/api/connections', async () => {
const actual = await vi.importActual<typeof import('@/api/connections')>('@/api/connections');
return {
...actual,
ConnectionsAPI: {
...actual.ConnectionsAPI,
probe: probeSpy,
},
};
});
vi.mock('@/api/connections', () => ({
ConnectionsAPI: connectionsApiMock,
}));
vi.mock('@/utils/infrastructureOnboardingMetrics', () => ({
createInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
}));
const connectionFixture = (overrides: Partial<Connection> = {}): Connection => ({
id: 'pve:zeus',

View file

@ -0,0 +1,87 @@
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 { createInfrastructureOnboardingMetricsTracker } from '@/utils/infrastructureOnboardingMetrics';
describe('infrastructureOnboardingMetrics', () => {
beforeEach(() => {
trackUpgradeMetricEventMock.mockClear();
});
it('deduplicates flow-scoped onboarding steps inside one flow', () => {
const tracker = createInfrastructureOnboardingMetricsTracker();
tracker.recordOpened();
tracker.recordOpened();
tracker.recordPathSelected('api');
tracker.recordPathSelected('api');
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', () => {
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');
});
});

View file

@ -15,6 +15,7 @@ import {
trackAgentInstallCommandCopied,
trackAgentInstallProfileSelected,
trackAgentInstallTokenGenerated,
trackUpgradeMetricEvent,
} from '@/utils/upgradeMetrics';
function getPayloadForCall(index: number) {
@ -50,6 +51,25 @@ describe('upgradeMetrics local-only UX metrics wrappers', () => {
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');

View file

@ -0,0 +1,97 @@
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;
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;
recordProbeResult: (outcome: InfrastructureOnboardingProbeOutcome) => void;
recordCatalogSelected: (type: ConnectionType) => void;
recordCredentialsOpened: (type: ConnectionType) => void;
}
export function createInfrastructureOnboardingMetricsTracker(
surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE,
): InfrastructureOnboardingMetricsTracker {
const flowId = createOnboardingFlowId();
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}`),
});
},
};
}

View file

@ -14,6 +14,17 @@ export interface UpgradeMetricEvent {
idempotency_key: string;
}
export interface TrackUpgradeMetricEventInput {
type: string;
surface: string;
capability?: string;
tenant_mode?: string;
limit_key?: string;
current_value?: number;
limit_value?: number;
idempotencyKey?: string;
}
export const UPGRADE_METRIC_EVENTS = {
PRICING_VIEWED: 'pricing_viewed',
PAYWALL_VIEWED: 'paywall_viewed',
@ -27,6 +38,11 @@ export const UPGRADE_METRIC_EVENTS = {
AGENT_INSTALL_COMMAND_COPIED: 'agent_install_command_copied',
AGENT_INSTALL_PROFILE_SELECTED: 'agent_install_profile_selected',
AGENT_FIRST_CONNECTED: 'agent_first_connected',
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',
} as const;
const ONE_MINUTE_MS = 60_000;
@ -42,14 +58,16 @@ function pruneExpiredKeys(now: number): void {
}
export function trackUpgradeMetricEvent(
event: Partial<UpgradeMetricEvent> & { type: string; surface: string },
event: TrackUpgradeMetricEventInput,
): void {
if (shouldDisableLocalUpgradeMetrics()) {
return;
}
const now = Date.now();
const idempotencyKey = `${event.type}:${event.surface}:${event.capability || ''}:${Math.floor(now / ONE_MINUTE_MS)}`;
const idempotencyKey =
event.idempotencyKey ??
`${event.type}:${event.surface}:${event.capability || ''}:${Math.floor(now / ONE_MINUTE_MS)}`;
pruneExpiredKeys(now);
if (recentlySentKeys.has(idempotencyKey)) {

View file

@ -3,21 +3,26 @@ package conversion
import pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
const (
EventPricingViewed = pkglicensing.EventPricingViewed
EventPaywallViewed = pkglicensing.EventPaywallViewed
EventTrialStarted = pkglicensing.EventTrialStarted
EventLicenseActivated = pkglicensing.EventLicenseActivated
EventLicenseActivationFailed = pkglicensing.EventLicenseActivationFailed
EventUpgradeClicked = pkglicensing.EventUpgradeClicked
EventCheckoutClicked = pkglicensing.EventCheckoutClicked
EventCheckoutStarted = pkglicensing.EventCheckoutStarted
EventCheckoutCompleted = pkglicensing.EventCheckoutCompleted
EventLimitWarningShown = pkglicensing.EventLimitWarningShown
EventLimitBlocked = pkglicensing.EventLimitBlocked
EventAgentInstallTokenGenerated = pkglicensing.EventAgentInstallTokenGenerated
EventAgentInstallCommandCopied = pkglicensing.EventAgentInstallCommandCopied
EventAgentInstallProfileSelected = pkglicensing.EventAgentInstallProfileSelected
EventAgentFirstConnected = pkglicensing.EventAgentFirstConnected
EventPricingViewed = pkglicensing.EventPricingViewed
EventPaywallViewed = pkglicensing.EventPaywallViewed
EventTrialStarted = pkglicensing.EventTrialStarted
EventLicenseActivated = pkglicensing.EventLicenseActivated
EventLicenseActivationFailed = pkglicensing.EventLicenseActivationFailed
EventUpgradeClicked = pkglicensing.EventUpgradeClicked
EventCheckoutClicked = pkglicensing.EventCheckoutClicked
EventCheckoutStarted = pkglicensing.EventCheckoutStarted
EventCheckoutCompleted = pkglicensing.EventCheckoutCompleted
EventLimitWarningShown = pkglicensing.EventLimitWarningShown
EventLimitBlocked = pkglicensing.EventLimitBlocked
EventAgentInstallTokenGenerated = pkglicensing.EventAgentInstallTokenGenerated
EventAgentInstallCommandCopied = pkglicensing.EventAgentInstallCommandCopied
EventAgentInstallProfileSelected = pkglicensing.EventAgentInstallProfileSelected
EventAgentFirstConnected = pkglicensing.EventAgentFirstConnected
EventInfrastructureOnboardingOpened = pkglicensing.EventInfrastructureOnboardingOpened
EventInfrastructureOnboardingPathSelected = pkglicensing.EventInfrastructureOnboardingPathSelected
EventInfrastructureOnboardingProbeResult = pkglicensing.EventInfrastructureOnboardingProbeResult
EventInfrastructureOnboardingCatalogSelected = pkglicensing.EventInfrastructureOnboardingCatalogSelected
EventInfrastructureOnboardingCredentialsOpened = pkglicensing.EventInfrastructureOnboardingCredentialsOpened
)
type ConversionEvent = pkglicensing.ConversionEvent

View file

@ -69,6 +69,16 @@ func TestConversionEventValidate(t *testing.T) {
IdempotencyKey: "id-checkout-clicked-1",
},
},
{
name: "valid infrastructure onboarding path event",
event: ConversionEvent{
Type: EventInfrastructureOnboardingPathSelected,
Surface: "settings_infrastructure_add",
Capability: "api",
Timestamp: now,
IdempotencyKey: "id-infra-onboarding-path-1",
},
},
{
name: "limit warning requires limit key",
event: ConversionEvent{Type: EventLimitWarningShown, Surface: "dashboard", Timestamp: now, IdempotencyKey: "id-1"},

View file

@ -6,39 +6,49 @@ import (
)
const (
EventPricingViewed = "pricing_viewed"
EventPaywallViewed = "paywall_viewed"
EventTrialStarted = "trial_started"
EventLicenseActivated = "license_activated"
EventLicenseActivationFailed = "license_activation_failed"
EventUpgradeClicked = "upgrade_clicked"
EventCheckoutClicked = "checkout_clicked"
EventCheckoutStarted = "checkout_started"
EventCheckoutCompleted = "checkout_completed"
EventLimitWarningShown = "limit_warning_shown"
EventLimitBlocked = "limit_blocked"
EventAgentInstallTokenGenerated = "agent_install_token_generated"
EventAgentInstallCommandCopied = "agent_install_command_copied"
EventAgentInstallProfileSelected = "agent_install_profile_selected"
EventAgentFirstConnected = "agent_first_connected"
EventPricingViewed = "pricing_viewed"
EventPaywallViewed = "paywall_viewed"
EventTrialStarted = "trial_started"
EventLicenseActivated = "license_activated"
EventLicenseActivationFailed = "license_activation_failed"
EventUpgradeClicked = "upgrade_clicked"
EventCheckoutClicked = "checkout_clicked"
EventCheckoutStarted = "checkout_started"
EventCheckoutCompleted = "checkout_completed"
EventLimitWarningShown = "limit_warning_shown"
EventLimitBlocked = "limit_blocked"
EventAgentInstallTokenGenerated = "agent_install_token_generated"
EventAgentInstallCommandCopied = "agent_install_command_copied"
EventAgentInstallProfileSelected = "agent_install_profile_selected"
EventAgentFirstConnected = "agent_first_connected"
EventInfrastructureOnboardingOpened = "infrastructure_onboarding_opened"
EventInfrastructureOnboardingPathSelected = "infrastructure_onboarding_path_selected"
EventInfrastructureOnboardingProbeResult = "infrastructure_onboarding_probe_result"
EventInfrastructureOnboardingCatalogSelected = "infrastructure_onboarding_catalog_selected"
EventInfrastructureOnboardingCredentialsOpened = "infrastructure_onboarding_credentials_opened"
)
var supportedConversionEventTypes = map[string]struct{}{
EventPricingViewed: {},
EventPaywallViewed: {},
EventTrialStarted: {},
EventLicenseActivated: {},
EventLicenseActivationFailed: {},
EventUpgradeClicked: {},
EventCheckoutClicked: {},
EventCheckoutStarted: {},
EventCheckoutCompleted: {},
EventLimitWarningShown: {},
EventLimitBlocked: {},
EventAgentInstallTokenGenerated: {},
EventAgentInstallCommandCopied: {},
EventAgentInstallProfileSelected: {},
EventAgentFirstConnected: {},
EventPricingViewed: {},
EventPaywallViewed: {},
EventTrialStarted: {},
EventLicenseActivated: {},
EventLicenseActivationFailed: {},
EventUpgradeClicked: {},
EventCheckoutClicked: {},
EventCheckoutStarted: {},
EventCheckoutCompleted: {},
EventLimitWarningShown: {},
EventLimitBlocked: {},
EventAgentInstallTokenGenerated: {},
EventAgentInstallCommandCopied: {},
EventAgentInstallProfileSelected: {},
EventAgentFirstConnected: {},
EventInfrastructureOnboardingOpened: {},
EventInfrastructureOnboardingPathSelected: {},
EventInfrastructureOnboardingProbeResult: {},
EventInfrastructureOnboardingCatalogSelected: {},
EventInfrastructureOnboardingCredentialsOpened: {},
}
// ConversionEvent is the canonical conversion instrumentation envelope.

View file

@ -69,6 +69,16 @@ func TestConversionEventValidate(t *testing.T) {
IdempotencyKey: "id-checkout-clicked-1",
},
},
{
name: "valid infrastructure onboarding path event",
event: ConversionEvent{
Type: EventInfrastructureOnboardingPathSelected,
Surface: "settings_infrastructure_add",
Capability: "api",
Timestamp: now,
IdempotencyKey: "id-infra-onboarding-path-1",
},
},
{
name: "limit warning requires limit key",
event: ConversionEvent{Type: EventLimitWarningShown, Surface: "dashboard", Timestamp: now, IdempotencyKey: "id-1"},