diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx
index da2d3c10ef..abd1487e0d 100644
--- a/ui/components/providers/providers-accounts-view.test.tsx
+++ b/ui/components/providers/providers-accounts-view.test.tsx
@@ -1,18 +1,24 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ PROVIDER_FUNNEL_EVENT,
+ type ProviderFunnelDetail,
+} from "@/lib/provider-funnel/provider-funnel-events";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
const {
+ onboardingTriggerSpy,
providersAccountsTableSpy,
refreshMock,
replaceMock,
searchParamsValue,
} = vi.hoisted(() => ({
+ onboardingTriggerSpy: vi.fn(),
providersAccountsTableSpy: vi.fn(),
refreshMock: vi.fn(),
replaceMock: vi.fn(),
@@ -28,6 +34,14 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
+vi.mock("@/components/onboarding", () => ({
+ OnboardingTrigger: (props: { startAtTarget?: string }) => {
+ onboardingTriggerSpy(props);
+ return null;
+ },
+ PageReady: () => null,
+}));
+
vi.mock("@/components/providers/table", () => ({
SkeletonTableProviders: () =>
,
}));
@@ -132,9 +146,21 @@ const disconnectedProviders: ProviderProps[] = [
];
describe("ProvidersAccountsView", () => {
+ const funnelSignals: ProviderFunnelDetail[] = [];
+ const recordFunnelSignal: EventListener = (event) => {
+ funnelSignals.push((event as CustomEvent).detail);
+ };
+
+ beforeEach(() => {
+ funnelSignals.length = 0;
+ window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
afterEach(() => {
+ window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.restoreAllMocks();
providersAccountsTableSpy.mockClear();
+ onboardingTriggerSpy.mockClear();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
@@ -273,6 +299,143 @@ describe("ProvidersAccountsView", () => {
expect(replaceMock).not.toHaveBeenCalled();
});
+ it("signals which control opened the wizard", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Add Provider" }));
+
+ // Then
+ expect(funnelSignals).toEqual([
+ { step: "wizard_opened", source: "page_button" },
+ ]);
+ });
+
+ it("signals the empty-state CTA as the wizard entry point", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: /open add provider modal/i }),
+ );
+
+ // Then
+ expect(funnelSignals).toEqual([
+ { step: "wizard_opened", source: "empty_state" },
+ ]);
+ });
+
+ it("signals the entry point carried in the URL and cleans it on close", async () => {
+ // Given
+ searchParamsValue.current =
+ "tab=connected&addProvider=true&addProviderSource=sidebar_cta";
+ const replaceStateSpy = vi.spyOn(window.history, "replaceState");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Then
+ expect(funnelSignals).toEqual([
+ { step: "wizard_opened", source: "sidebar_cta" },
+ ]);
+
+ // When
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ // Then
+ expect(replaceStateSpy).toHaveBeenCalledWith(
+ null,
+ "",
+ "/providers?tab=connected",
+ );
+ });
+
+ it("treats an unknown URL entry point as a plain URL open", () => {
+ // Given
+ searchParamsValue.current = "addProvider=true&addProviderSource=made_up";
+
+ // When
+ render(
+ ,
+ );
+
+ // Then
+ expect(funnelSignals).toEqual([{ step: "wizard_opened", source: "url" }]);
+ });
+
+ it("starts the tour at the provider-type step when the wizard is already open", () => {
+ // Given
+ searchParamsValue.current = "addProvider=true&onboarding=add-provider";
+
+ // When
+ render(
+ ,
+ );
+
+ // Then: the welcome and "open the wizard" steps have nothing left to ask for.
+ expect(onboardingTriggerSpy).toHaveBeenLastCalledWith(
+ expect.objectContaining({ startAtTarget: "provider-type" }),
+ );
+ });
+
+ it("lets the tour start from its first step while the wizard is closed", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(onboardingTriggerSpy).toHaveBeenLastCalledWith(
+ expect.objectContaining({ startAtTarget: undefined }),
+ );
+ });
+
it("keeps filters and table visible when providers are disconnected", () => {
// Given/When
render(
diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx
index 2bbd24c0a1..b94202af38 100644
--- a/ui/components/providers/providers-accounts-view.tsx
+++ b/ui/components/providers/providers-accounts-view.tsx
@@ -16,10 +16,19 @@ import type {
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
+import { useMountEffect } from "@/hooks/use-mount-effect";
import { getFlowById } from "@/lib/onboarding";
+import {
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_STEP,
+ WIZARD_OPEN_SOURCE,
+ type WizardOpenSource,
+} from "@/lib/provider-funnel/provider-funnel-events";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
+ ADD_PROVIDER_SOURCE_PARAM,
+ resolveAddProviderSource,
} from "@/lib/providers-navigation";
import {
ADD_PROVIDER_TOUR_TARGETS,
@@ -102,7 +111,25 @@ export function ProvidersAccountsView({
OrgWizardInitialData | undefined
>(undefined);
- const openProviderWizard = (initialData?: ProviderWizardInitialData) => {
+ const signalWizardOpened = (source: WizardOpenSource) =>
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
+ source,
+ });
+
+ // A URL-opened wizard never goes through openProviderWizard, so signal it on mount.
+ useMountEffect(() => {
+ if (!shouldOpenProviderWizardFromUrl) return;
+ signalWizardOpened(
+ resolveAddProviderSource(searchParams.get(ADD_PROVIDER_SOURCE_PARAM)),
+ );
+ });
+
+ const openProviderWizard = (
+ source: WizardOpenSource,
+ initialData?: ProviderWizardInitialData,
+ ) => {
+ signalWizardOpened(source);
setOrgWizardInitialData(undefined);
setProviderWizardInitialData(initialData);
setIsProviderWizardOpen(true);
@@ -130,6 +157,7 @@ export function ProvidersAccountsView({
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
+ params.delete(ADD_PROVIDER_SOURCE_PARAM);
const query = params.toString();
window.history.replaceState(
null,
@@ -146,6 +174,12 @@ export function ProvidersAccountsView({
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
@@ -154,7 +188,9 @@ export function ProvidersAccountsView({
openProviderWizard()}
+ onOpenWizard={() =>
+ openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE)
+ }
ctaTourId="add-provider-trigger"
/>
) : (
@@ -175,7 +211,11 @@ export function ProvidersAccountsView({
actions={
<>
- openProviderWizard()} />
+
+ openProviderWizard(WIZARD_OPEN_SOURCE.PAGE_BUTTON)
+ }
+ />
>
}
/>
@@ -186,7 +226,9 @@ export function ProvidersAccountsView({
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
- onOpenProviderWizard={openProviderWizard}
+ onOpenProviderWizard={(initialData) =>
+ openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
+ }
onOpenOrganizationWizard={openOrganizationWizard}
/>
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
index ea8892ea5b..0f19fce732 100644
--- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
+++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx
@@ -1,6 +1,10 @@
import { act, renderHook, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ PROVIDER_FUNNEL_EVENT,
+ type ProviderFunnelDetail,
+} from "@/lib/provider-funnel/provider-funnel-events";
import { useOrgSetupStore } from "@/store/organizations/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { ORG_WIZARD_STEP, ORGANIZATION_TYPE } from "@/types/organizations";
@@ -40,7 +44,18 @@ vi.mock("next-auth/react", () => ({
}));
describe("useProviderWizardController", () => {
+ const funnelSignals: ProviderFunnelDetail[] = [];
+ const recordFunnelSignal: EventListener = (event) => {
+ funnelSignals.push((event as CustomEvent).detail);
+ };
+
+ afterEach(() => {
+ window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
beforeEach(() => {
+ funnelSignals.length = 0;
+ window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.useRealTimers();
vi.clearAllMocks();
requestOpenOnWizardCloseMock.mockClear();
@@ -144,6 +159,70 @@ describe("useProviderWizardController", () => {
expect(refreshMock).toHaveBeenCalledTimes(1);
});
+ it("signals the step where the wizard was left and that no provider was created", () => {
+ // Given
+ const { result } = renderHook(() =>
+ useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
+ );
+
+ // When
+ act(() => {
+ result.current.handleClose();
+ });
+
+ // Then
+ expect(funnelSignals).toEqual([
+ { step: "wizard_closed", lastStep: "connect", providerCreated: false },
+ ]);
+ });
+
+ it("signals a close after the provider was created, from the step reached", () => {
+ // Given
+ const { result } = renderHook(() =>
+ useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
+ );
+ act(() => {
+ useProviderWizardStore.getState().setProvider({
+ id: "provider-1",
+ type: "aws",
+ uid: "123456789012",
+ alias: null,
+ });
+ result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST);
+ });
+
+ // When
+ act(() => {
+ result.current.handleClose();
+ });
+
+ // Then
+ expect(funnelSignals).toEqual([
+ { step: "wizard_closed", lastStep: "test", providerCreated: true },
+ ]);
+ });
+
+ it("signals the organization method when the organizations flow opens", () => {
+ // Given
+ const { result } = renderHook(() =>
+ useProviderWizardController({ open: true, onOpenChange: vi.fn() }),
+ );
+
+ // When
+ act(() => {
+ result.current.openOrganizationsFlow(ORGANIZATION_TYPE.AZURE);
+ });
+
+ // Then
+ expect(funnelSignals).toEqual([
+ {
+ step: "method_selected",
+ providerType: "azure",
+ method: "organization",
+ },
+ ]);
+ });
+
it("hydrates update mode when initial data is provided", async () => {
// Given
const onOpenChange = vi.fn();
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
index 0059c43a77..468f283ecc 100644
--- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
+++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
@@ -4,6 +4,11 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
+import {
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_METHOD,
+ PROVIDER_FUNNEL_STEP,
+} from "@/lib/provider-funnel/provider-funnel-events";
import { isCloud } from "@/lib/shared/env";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
@@ -44,6 +49,20 @@ const ORG_DOCS_URL = {
[ORGANIZATION_TYPE.GCP]: DOCS_URLS.GCP_ORGANIZATIONS,
} as const satisfies Record;
+// Stable names for the abandonment signal; the numeric step ids are not a contract.
+const PROVIDER_STEP_NAME = {
+ [PROVIDER_WIZARD_STEP.CONNECT]: "connect",
+ [PROVIDER_WIZARD_STEP.CREDENTIALS]: "credentials",
+ [PROVIDER_WIZARD_STEP.TEST]: "test",
+ [PROVIDER_WIZARD_STEP.LAUNCH]: "launch",
+} as const satisfies Record;
+
+const ORG_STEP_NAME = {
+ [ORG_WIZARD_STEP.SETUP]: "organizations_setup",
+ [ORG_WIZARD_STEP.VALIDATE]: "organizations_validate",
+ [ORG_WIZARD_STEP.LAUNCH]: "organizations_launch",
+} as const satisfies Record;
+
const EMPTY_FOOTER_CONFIG: WizardFooterConfig = {
showBack: false,
backLabel: "Back",
@@ -205,6 +224,15 @@ export function useProviderWizardController({
// Read providerId before reset clears it — non-null means a provider was connected.
const connectedProviderId = useProviderWizardStore.getState().providerId;
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.WIZARD_CLOSED,
+ lastStep:
+ wizardVariant === WIZARD_VARIANT.PROVIDER
+ ? PROVIDER_STEP_NAME[currentStep]
+ : ORG_STEP_NAME[orgCurrentStep],
+ providerCreated: connectedProviderId !== null,
+ });
+
resetProviderWizard();
resetOrgWizard();
setWizardVariant(WIZARD_VARIANT.PROVIDER);
@@ -251,6 +279,11 @@ export function useProviderWizardController({
// Organizations diverges from the credentials path the tour guides toward; end
// it so it doesn't dangle on a step that no longer fits. No-op off-onboarding.
endActiveTour();
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED,
+ providerType: orgType,
+ method: PROVIDER_FUNNEL_METHOD.ORGANIZATION,
+ });
resetOrgWizard();
setOrganizationType(orgType);
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx
index fab1a4440f..46e000d249 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.tsx
+++ b/ui/components/providers/wizard/provider-wizard-modal.tsx
@@ -12,6 +12,10 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability";
import { useScrollHint } from "@/hooks/use-scroll-hint";
+import {
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_STEP,
+} from "@/lib/provider-funnel/provider-funnel-events";
import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour";
import {
ORG_SETUP_PHASE,
@@ -78,6 +82,7 @@ export function ProviderWizardModal({
organizationType,
orgCurrentStep,
orgSetupPhase,
+ providerTypeHint,
resolvedFooterConfig,
setCurrentStep,
setFooterConfig,
@@ -179,6 +184,13 @@ export function ProviderWizardModal({
// Picking a type reveals the account-detail inputs. Advance the tour
// to its wizard-body step, pinned beside the form. No-op off-onboarding.
if (providerType) advanceActiveTour();
+ // The form re-reports the same type on re-render; signal a pick once.
+ if (providerType && providerType !== providerTypeHint) {
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED,
+ providerType,
+ });
+ }
setProviderTypeHint(providerType);
}}
/>
diff --git a/ui/components/providers/workflow/credentials-role-helper.test.tsx b/ui/components/providers/workflow/credentials-role-helper.test.tsx
new file mode 100644
index 0000000000..eadfad51b1
--- /dev/null
+++ b/ui/components/providers/workflow/credentials-role-helper.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ PROVIDER_FUNNEL_EVENT,
+ type ProviderFunnelDetail,
+} from "@/lib/provider-funnel/provider-funnel-events";
+
+import { CredentialsRoleHelper } from "./credentials-role-helper";
+
+const templateLinks = {
+ cloudformation: "https://example.com/template.yml",
+ cloudformationQuickLink: "https://example.com/quick-create",
+ terraform: "https://example.com/terraform",
+};
+
+describe("CredentialsRoleHelper", () => {
+ const funnelSignals: ProviderFunnelDetail[] = [];
+ const recordFunnelSignal: EventListener = (event) => {
+ funnelSignals.push((event as CustomEvent).detail);
+ };
+
+ beforeEach(() => {
+ funnelSignals.length = 0;
+ window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
+ afterEach(() => {
+ window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
+ describe("when connecting a provider", () => {
+ it("signals which role template the user opened", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("link", { name: /CloudFormation Quick Link/i }),
+ );
+ await user.click(
+ screen.getByRole("link", { name: "CloudFormation Template" }),
+ );
+ await user.click(screen.getByRole("link", { name: "Terraform Code" }));
+
+ // Then
+ expect(funnelSignals).toEqual([
+ {
+ step: "role_template_opened",
+ template: "cloudformation_quick_create",
+ },
+ { step: "role_template_opened", template: "cloudformation_template" },
+ { step: "role_template_opened", template: "terraform" },
+ ]);
+ });
+ });
+
+ describe("when configuring an integration", () => {
+ it("stays out of the provider funnel", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("link", { name: /CloudFormation Quick Link/i }),
+ );
+
+ // Then
+ expect(funnelSignals).toEqual([]);
+ });
+ });
+});
diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx
index 12e0bd3502..549104877a 100644
--- a/ui/components/providers/workflow/credentials-role-helper.tsx
+++ b/ui/components/providers/workflow/credentials-role-helper.tsx
@@ -3,6 +3,12 @@
import { IdIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
+import {
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_STEP,
+ ROLE_TEMPLATE_KIND,
+ type RoleTemplateKind,
+} from "@/lib/provider-funnel/provider-funnel-events";
import { IntegrationType } from "@/types/integrations";
interface CredentialsRoleHelperProps {
@@ -22,6 +28,15 @@ export const CredentialsRoleHelper = ({
}: CredentialsRoleHelperProps) => {
const isAmazonS3 = integrationType === "amazon_s3";
+ // Integrations reuse this helper; only the add-provider journey is signalled.
+ const signalTemplateOpened = (template: RoleTemplateKind) => {
+ if (integrationType) return;
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.ROLE_TEMPLATE_OPENED,
+ template,
+ });
+ };
+
return (
@@ -40,6 +55,11 @@ export const CredentialsRoleHelper = ({
href={templateLinks.cloudformationQuickLink}
target="_blank"
rel="noopener noreferrer"
+ onClick={() =>
+ signalTemplateOpened(
+ ROLE_TEMPLATE_KIND.CLOUDFORMATION_QUICK_CREATE,
+ )
+ }
>
Use the following AWS CloudFormation Quick Link to create the IAM
Role
@@ -71,6 +91,9 @@ export const CredentialsRoleHelper = ({
href={templateLinks.cloudformation}
target="_blank"
rel="noopener noreferrer"
+ onClick={() =>
+ signalTemplateOpened(ROLE_TEMPLATE_KIND.CLOUDFORMATION_TEMPLATE)
+ }
>
CloudFormation {integrationType ? "" : "Template"}
@@ -85,6 +108,7 @@ export const CredentialsRoleHelper = ({
href={templateLinks.terraform}
target="_blank"
rel="noopener noreferrer"
+ onClick={() => signalTemplateOpened(ROLE_TEMPLATE_KIND.TERRAFORM)}
>
Terraform {integrationType ? "" : "Code"}