mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Instrument infrastructure onboarding funnel and harden onboarding proof
This commit is contained in:
parent
dad0529a2f
commit
767a0dd5ba
14 changed files with 460 additions and 103 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
97
frontend-modern/src/utils/infrastructureOnboardingMetrics.ts
Normal file
97
frontend-modern/src/utils/infrastructureOnboardingMetrics.ts
Normal 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}`),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue