diff --git a/cli/src/commands/demo/command.ts b/cli/src/commands/demo/command.ts index f976a6317b..99f29653e6 100644 --- a/cli/src/commands/demo/command.ts +++ b/cli/src/commands/demo/command.ts @@ -15,6 +15,7 @@ import { checkExistingOnboarding, } from './api.js'; import { + captureOnboardingEvent, checkDockerReadiness, clearScreen, getDemoLogPath, @@ -104,7 +105,17 @@ async function cleanupFederatedGraph( const deleteResponse = await cleanUpFederatedGraph(client, graphData); if (deleteResponse.error) { - spinner.fail(`Removing federated graph ${graphData.graph.name} failed.`); + const failText = `Removing federated graph ${graphData.graph.name} failed.`; + spinner.fail(failText); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'delete_federated_graph', + entry_source: 'wgc', + error_category: 'resource', + error_message: `${failText}\n${deleteResponse.error.message}`, + }, + }); console.error(deleteResponse.error.message); await waitForKeyPress( @@ -117,6 +128,14 @@ async function cleanupFederatedGraph( ); } + captureOnboardingEvent({ + name: 'onboarding_step_completed', + properties: { + step_name: 'delete_federated_graph', + entry_source: 'wgc', + }, + }); + spinner.succeed(`Federated graph ${pc.bold(graphData.graph.name)} removed.`); } @@ -265,7 +284,10 @@ async function handleStep3( logPath: string; }, ) { + let firedQueries = 0; + function retryFn() { + firedQueries = 0; resetScreen(userInfo); return handleStep3(opts, { userInfo, routerBaseUrl, signal, logPath }); } @@ -280,7 +302,17 @@ async function handleStep3( // Delete existing token first (idempotent — no error if missing) const deleteResult = await deleteRouterToken(tokenParams); if (deleteResult.error) { - console.error(`Failed to clean up existing router token: ${deleteResult.error.message}`); + const errorText = `Failed to clean up existing router token: ${deleteResult.error.message}`; + console.error(errorText); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: errorText, + }, + }); await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); return; } @@ -289,7 +321,17 @@ async function handleStep3( const createResult = await createRouterToken(tokenParams); if (createResult.error) { - spinner.fail(`Failed to generate router token: ${createResult.error.message}`); + const failText = `Failed to generate router token: ${createResult.error.message}`; + spinner.fail(failText); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: failText, + }, + }); await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); return; } @@ -316,8 +358,30 @@ async function handleStep3( const body = await res.json(); querySpinner.succeed('Sample query response:'); console.log(pc.dim(JSON.stringify(body, null, 2))); + + if (firedQueries === 0) { + captureOnboardingEvent({ + name: 'onboarding_step_completed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + }, + }); + } + + firedQueries++; } catch (err) { - querySpinner.fail(`Sample query failed: ${err instanceof Error ? err.message : String(err)}`); + const failText = `Sample query failed: ${err instanceof Error ? err.message : String(err)}`; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: failText, + }, + }); + querySpinner.fail(failText); } showQueryPrompt(); } @@ -337,7 +401,17 @@ async function handleStep3( }); if (routerResult.error) { - console.error(`\nRouter exited with error: ${routerResult.error.message}`); + const errorText = `Router exited with error: ${routerResult.error.message}`; + console.error(`\n${errorText}`); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: errorText, + }, + }); await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); } else { showQueryPrompt(); @@ -356,14 +430,35 @@ async function handleGetOnboardingResponse(client: BaseCommandOptions['client'], return onboardingCheck.onboarding; } case 'not-allowed': { - program.error('Only organization owners can trigger onboarding.'); + const errorText = 'Only organization owners can trigger onboarding.'; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'check_onboarding', + entry_source: 'wgc', + error_category: 'resource', + error_message: errorText, + }, + }); + program.error(errorText); break; } case 'error': { - console.error('An issue occured while fetching the onboarding status'); + const errorText = 'An issue occured while fetching the onboarding status'; + console.error(errorText); console.error(onboardingCheck.error); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'check_onboarding', + entry_source: 'wgc', + error_category: 'resource', + error_message: `${errorText}\n${onboardingCheck.error}`, + }, + }); + await waitForKeyPress({ Enter: retryFn }, 'Hit Enter to retry. CTRL+C to quit.'); break; } @@ -383,6 +478,15 @@ async function getUserInfo(client: BaseCommandOptions['client']) { if (error) { spinner.fail(error.message); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'resource', + error_message: error.message, + }, + }); program.error(error.message); } @@ -405,10 +509,12 @@ export default function (opts: BaseCommandOptions) { const userInfo = await getUserInfo(opts.client); updateScreenWithUserInfo(userInfo); - const onboardingUrl = `${config.webURL}/onboarding`; + const onboardingUrl = new URL('/onboarding', config.webURL); async function openOnboardingUrl() { - const process = await open(onboardingUrl); + const browserUrl = new URL(onboardingUrl); + browserUrl.searchParams.set('referrer', 'wgc'); + const process = await open(browserUrl.toString()); process.on('error', (error) => { console.log(pc.yellow(`\nCouldn't open browser: ${error.message}`)); }); @@ -425,12 +531,28 @@ export default function (opts: BaseCommandOptions) { resetScreen(userInfo); + captureOnboardingEvent({ + name: 'onboarding_step_completed', + properties: { + step_name: 'init', + entry_source: 'wgc', + }, + }); + const onboardingCheck = await handleStep1(opts, userInfo); if (!onboardingCheck) { return; } + captureOnboardingEvent({ + name: 'onboarding_step_completed', + properties: { + step_name: 'check_onboarding', + entry_source: 'wgc', + }, + }); + const logPath = getDemoLogPath(); const step2Result = await handleStep2(opts, { @@ -445,6 +567,14 @@ export default function (opts: BaseCommandOptions) { return; } + captureOnboardingEvent({ + name: 'onboarding_step_completed', + properties: { + step_name: 'create_federated_graph', + entry_source: 'wgc', + }, + }); + const routerBaseUrl = new URL(step2Result.routingUrl).origin; await handleStep3(opts, { userInfo, routerBaseUrl, signal: controller.signal, logPath }); } finally { diff --git a/cli/src/commands/demo/util.ts b/cli/src/commands/demo/util.ts index 5fe713334e..6f11c5dc30 100644 --- a/cli/src/commands/demo/util.ts +++ b/cli/src/commands/demo/util.ts @@ -7,6 +7,7 @@ import ora from 'ora'; import pc from 'picocolors'; import { z } from 'zod'; import { config, cacheDir } from '../../core/config.js'; +import { capture } from '../../core/telemetry.js'; import { getDefaultPlatforms, publishPluginPipeline, readPluginFiles } from '../../core/plugin-publish.js'; import type { BaseCommandOptions } from '../../core/types/types.js'; import { visibleLength } from '../../utils.js'; @@ -137,13 +138,33 @@ export async function prepareSupportingData() { ); if (!treeResponse.ok) { spinner.fail('Failed to fetch repository tree.'); - program.error(`GitHub API error: ${treeResponse.statusText}`); + const errorText = `GitHub API error: ${treeResponse.statusText}`; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'support_files', + error_message: errorText, + }, + }); + program.error(errorText); } const parsed = GitHubTreeSchema.safeParse(await treeResponse.json()); if (!parsed.success) { spinner.fail('Failed to parse repository tree.'); - program.error('Unexpected response format from GitHub API. The repository structure may have changed.'); + const errorText = 'Unexpected response format from GitHub API. The repository structure may have changed.'; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'support_files', + error_message: errorText, + }, + }); + program.error(errorText); } const files = parsed.data.tree.filter((entry) => entry.type === 'blob' && entry.path.startsWith('plugins/')); @@ -164,15 +185,29 @@ export async function prepareSupportingData() { return { path: file.path, error: null }; } catch (err) { - return { path: file.path, error: err instanceof Error ? err.message : String(err) }; + return { + path: file.path, + error: err instanceof Error ? err.message : String(err), + }; } }), ); const failed = results.filter((r) => r.error !== null); if (failed.length > 0) { - spinner.fail(`Failed to fetch some files from onboarding repository or store them in ${cosmoDir}.`); - program.error(failed.map((f) => ` ${f.path}: ${f.error}`).join('\n')); + const failText = `Failed to fetch some files from onboarding repository or store them in ${cosmoDir}.`; + const errorText = failed.map((f) => ` ${f.path}: ${f.error}`).join('\n'); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'support_files', + error_message: `${failText}\n${errorText}`, + }, + }); + spinner.fail(failText); + program.error(errorText); } spinner.succeed(`Support files copied to ${pc.bold(cosmoDir)}`); @@ -226,14 +261,34 @@ export async function checkDockerReadiness(): Promise { const spinner = demoSpinner('Checking Docker availability…').start(); if (!(await isDockerAvailable())) { - spinner.fail('Docker is not available.'); + const failText = 'Docker is not available.'; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'docker_readiness', + error_message: failText, + }, + }); + spinner.fail(failText); program.error( `Docker CLI is not installed or the daemon is not running.\nInstall Docker: ${pc.underline('https://docs.docker.com/get-docker/')}`, ); } if (!(await isBuildxAvailable())) { - spinner.fail('Docker Buildx is not available.'); + const failText = 'Docker Buildx is not available.'; + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'docker_readiness', + error_message: failText, + }, + }); + spinner.fail(failText); program.error( `Docker Buildx plugin is required for multi-platform builds.\nSee: ${pc.underline('https://docs.docker.com/build/install-buildx/')}`, ); @@ -248,9 +303,20 @@ export async function checkDockerReadiness(): Promise { try { await createDockerContainerBuilder(config.dockerBuilderName); } catch (err) { - spinner.fail(`Failed to create buildx builder "${config.dockerBuilderName}".`); + const failText = `Failed to create buildx builder "${config.dockerBuilderName}".`; + const errorText = err instanceof Error ? err.message : String(err); + spinner.fail(failText); + captureOnboardingEvent({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'docker_readiness', + error_message: `${failText}\n${errorText}`, + }, + }); program.error( - `Could not create a docker-container buildx builder: ${err instanceof Error ? err.message : String(err)}\nYou can create one manually: docker buildx create --use --driver docker-container --name ${config.dockerBuilderName}`, + `Could not create a docker-container buildx builder: ${errorText}\nYou can create one manually: docker buildx create --use --driver docker-container --name ${config.dockerBuilderName}`, ); } @@ -512,3 +578,36 @@ export async function publishAllPlugins({ return { error: null }; } + +export function captureOnboardingEvent({ + name, + properties, +}: + | { + name: 'onboarding_step_completed'; + properties: { + step_name: + | 'init' + | 'check_onboarding' + | 'create_federated_graph' + | 'delete_federated_graph' + | 'run_router_send_metrics'; + entry_source: 'wgc'; + }; + } + | { + name: 'onboarding_step_failed'; + properties: { + step_name: + | 'init' + | 'check_onboarding' + | 'create_federated_graph' + | 'delete_federated_graph' + | 'run_router_send_metrics'; + entry_source: 'wgc'; + error_category: 'resource' | 'support_files' | 'docker_readiness' | 'router'; + error_message: string; + }; + }): void { + capture(name, properties); +} diff --git a/cli/test/demo/command.test.ts b/cli/test/demo/command.test.ts index 93c2892f12..f40af44dc0 100644 --- a/cli/test/demo/command.test.ts +++ b/cli/test/demo/command.test.ts @@ -23,6 +23,7 @@ vi.mock('../../src/commands/demo/util.js', async (importOriginal) => { publishAllPlugins: vi.fn(), runRouterContainer: vi.fn(), getDemoLogPath: vi.fn(), + captureOnboardingEvent: vi.fn(), }; }); @@ -266,4 +267,212 @@ describe('Demo command', () => { expect(demoUtil.runRouterContainer).toHaveBeenCalledOnce(); }); }); + + describe('event tracking', () => { + it('fires completed events on happy path', async () => { + keys.enter(); + + await runDemo(); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_completed', + properties: { step_name: 'init', entry_source: 'wgc' }, + }); + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_completed', + properties: { step_name: 'check_onboarding', entry_source: 'wgc' }, + }); + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_completed', + properties: { step_name: 'create_federated_graph', entry_source: 'wgc' }, + }); + }); + + it('fires delete_federated_graph completed when user deletes existing graph', async () => { + const overrides: PlatformOverrides = { + getFederatedGraphByName: () => ({ + response: { code: EnumStatusCode.OK }, + graph: new FederatedGraph({ + name: 'demo', + namespace: 'default', + routingURL: 'http://localhost:3002/graphql', + }), + subgraphs: [new Subgraph({ name: 'products', namespace: 'default' })], + }), + }; + + keys.enter(); + keys.press('d'); + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_completed', + properties: { step_name: 'delete_federated_graph', entry_source: 'wgc' }, + }); + }); + + it('fires init failed when whoAmI RPC fails', async () => { + const overrides: PlatformOverrides = { + whoAmI: () => ({ + response: { code: EnumStatusCode.ERR, details: 'Unauthorized' }, + }), + }; + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'init', + entry_source: 'wgc', + error_category: 'resource', + error_message: expect.any(String), + }, + }); + }); + + it('fires check_onboarding failed when user is not org owner', async () => { + const overrides: PlatformOverrides = { + getOnboarding: () => ({ + response: { code: EnumStatusCode.OK }, + enabled: false, + }), + }; + + keys.enter(); + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'check_onboarding', + entry_source: 'wgc', + error_category: 'resource', + error_message: expect.any(String), + }, + }); + }); + + it('fires check_onboarding failed when getOnboarding RPC errors', async () => { + const getOnboardingFn = vi + .fn() + .mockReturnValueOnce({ response: { code: EnumStatusCode.ERR, details: 'rpc error' } }) + .mockReturnValue({ response: { code: EnumStatusCode.OK }, enabled: true }); + + keys.enter(); + keys.press('Enter'); + + await runDemo({ getOnboarding: getOnboardingFn }); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'check_onboarding', + entry_source: 'wgc', + error_category: 'resource', + error_message: expect.any(String), + }, + }); + }); + + it('fires delete_federated_graph failed when graph deletion fails', async () => { + const overrides: PlatformOverrides = { + getFederatedGraphByName: () => ({ + response: { code: EnumStatusCode.OK }, + graph: new FederatedGraph({ + name: 'demo', + namespace: 'default', + routingURL: 'http://localhost:3002/graphql', + }), + subgraphs: [new Subgraph({ name: 'products', namespace: 'default' })], + }), + deleteFederatedGraph: () => ({ + response: { code: EnumStatusCode.ERR, details: 'deletion failed' }, + }), + }; + + keys.enter(); + keys.press('d'); + keys.press('Enter'); + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'delete_federated_graph', + entry_source: 'wgc', + error_category: 'resource', + error_message: expect.any(String), + }, + }); + }); + + it('fires run_router_send_metrics failed when deleteRouterToken RPC fails', async () => { + const deleteRouterTokenFn = vi + .fn() + .mockReturnValueOnce({ response: { code: EnumStatusCode.ERR, details: 'token error' } }) + .mockReturnValue({ response: { code: EnumStatusCode.OK } }); + + keys.enter(); + keys.press('r'); + + await runDemo({ deleteRouterToken: deleteRouterTokenFn }); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: expect.any(String), + }, + }); + }); + + it('fires run_router_send_metrics failed when createFederatedGraphToken RPC fails', async () => { + const createFederatedGraphTokenFn = vi + .fn() + .mockReturnValueOnce({ response: { code: EnumStatusCode.ERR, details: 'create token error' } }) + .mockReturnValue({ response: { code: EnumStatusCode.OK }, token: 'test-token' }); + + keys.enter(); + keys.press('r'); + + await runDemo({ createFederatedGraphToken: createFederatedGraphTokenFn }); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: expect.any(String), + }, + }); + }); + + it('fires run_router_send_metrics failed when router exits with error', async () => { + vi.mocked(demoUtil.runRouterContainer) + .mockResolvedValueOnce({ error: new Error('container exited') }) + .mockResolvedValueOnce({ error: null }); + + keys.enter(); + keys.press('r'); + + await runDemo(); + + expect(demoUtil.captureOnboardingEvent).toHaveBeenCalledWith({ + name: 'onboarding_step_failed', + properties: { + step_name: 'run_router_send_metrics', + entry_source: 'wgc', + error_category: 'router', + error_message: expect.any(String), + }, + }); + }); + }); }); diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx index f7b677f552..1735dad18b 100644 --- a/studio/src/components/onboarding/onboarding-provider.tsx +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -40,7 +40,7 @@ export const OnboardingContext = createContext({ resetSkipped: () => undefined, }); -const ONBOARDING_V1_LAST_STEP = 4; +const ONBOARDING_V1_LAST_STEP = 3; export const OnboardingProvider = ({ children }: { children: ReactNode }) => { const { onboarding: onboardingFlag, status: featureFlagStatus } = useContext(PostHogFeatureFlagContext); diff --git a/studio/src/components/onboarding/step-1.tsx b/studio/src/components/onboarding/step-1.tsx index 63a532edc2..67f637f46e 100644 --- a/studio/src/components/onboarding/step-1.tsx +++ b/studio/src/components/onboarding/step-1.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { usePostHog } from 'posthog-js/react'; import { OnboardingContainer } from './onboarding-container'; import { OnboardingNavigation } from './onboarding-navigation'; import { useMutation } from '@connectrpc/connect-query'; @@ -8,6 +9,7 @@ import { useRouter } from 'next/router'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { useToast } from '../ui/use-toast'; import { SubmitHandler, useZodForm } from '@/hooks/use-form'; +import { captureOnboardingEvent } from '@/lib/track'; import { Controller } from 'react-hook-form'; import { z } from 'zod'; import { Form } from '../ui/form'; @@ -33,10 +35,21 @@ const WhyListItem = ({ title, text }: { title: string; text: string }) => ( ); +const normalizeReferrer = (referrer: string | string[]): string => { + if (Array.isArray(referrer)) { + return referrer.join(' '); + } + + return referrer; +}; + export const Step1 = () => { const router = useRouter(); + const posthog = usePostHog(); const { toast } = useToast(); const { setStep, setSkipped, setOnboarding, onboarding } = useOnboarding(); + // Referrer can be `wgc` when onboarding is opened via `wgc demo` command + const referrer = normalizeReferrer(router.query.referrer || document.referrer); const form = useZodForm({ mode: 'onChange', @@ -49,10 +62,19 @@ export const Step1 = () => { const { mutate, isPending } = useMutation(createOnboarding, { onSuccess: (d) => { if (d.response?.code !== EnumStatusCode.OK) { + const description = d.response?.details ?? 'We had issues with storing your data. Please try again.'; toast({ - description: d.response?.details ?? 'We had issues with storing your data. Please try again.', + description, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'welcome', + error_category: 'resource', + error_message: description, + }, + }); return; } @@ -63,13 +85,31 @@ export const Step1 = () => { slack: formValues.channels.slack, email: formValues.channels.email, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_completed', + options: { + step_name: 'welcome', + channel: (Object.keys(formValues.channels) as Array).filter( + (key) => formValues.channels[key], + ), + }, + }); router.push('/onboarding/2'); }, onError: (error) => { + const description = error.details.toString() ?? 'We had issues with storing your data. Please try again.'; toast({ - description: error.details.toString() ?? 'We had issues with storing your data. Please try again.', + description, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'welcome', + error_category: 'resource', + error_message: description, + }, + }); }, }); @@ -79,7 +119,21 @@ export const Step1 = () => { useEffect(() => { setStep(1); - }, [setStep]); + }, [setStep, posthog, referrer]); + + useEffect(() => { + // We want to trigger the onboarding only for the first time when the onboarding record + // does not exist in the database yet. Users can navigate to this first step as well + // and in that case we want to ignore it. + if (onboarding) return; + + captureOnboardingEvent(posthog, { + name: 'onboarding_started', + options: { + entry_source: referrer, + }, + }); + }, [onboarding, referrer, posthog]); return ( @@ -156,7 +210,15 @@ export const Step1 = () => { { + captureOnboardingEvent(posthog, { + name: 'onboarding_skipped', + options: { + step_name: 'welcome', + }, + }); + setSkipped(); + }} forwardLabel="Start the tour" forward={{ onClick: form.handleSubmit(onSubmit), diff --git a/studio/src/components/onboarding/step-2.tsx b/studio/src/components/onboarding/step-2.tsx index 53cfcd67bc..ecf96381ba 100644 --- a/studio/src/components/onboarding/step-2.tsx +++ b/studio/src/components/onboarding/step-2.tsx @@ -5,7 +5,9 @@ import { getFederatedGraphByName } from '@wundergraph/cosmo-connect/dist/platfor import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { GetFederatedGraphByNameResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; import { CheckCircledIcon, InfoCircledIcon } from '@radix-ui/react-icons'; +import { usePostHog } from 'posthog-js/react'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { captureOnboardingEvent } from '@/lib/track'; import { OnboardingContainer } from './onboarding-container'; import { OnboardingNavigation } from './onboarding-navigation'; import { FederationAnimation } from './federation-animation'; @@ -115,6 +117,7 @@ const StatusText = ({ status, onRetry }: { status: 'pending' | 'ok' | 'fail' | ' export const Step2 = () => { const { setStep, setSkipped } = useOnboarding(); const router = useRouter(); + const posthog = usePostHog(); const [polling, dispatch] = useReducer(pollingReducer, { active: true, epoch: 0 }); const restartPolling = useCallback(() => dispatch({ type: 'RESTART' }), []); @@ -138,6 +141,19 @@ export const Step2 = () => { const status = getDemoGraphStatus({ data, isPolling: polling.active, isError }); + useEffect(() => { + if (status !== 'fail' && status !== 'error') return; + + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'create_graph', + error_category: 'resource', + error_message: status === 'error' ? 'Failed to fetch federated graph data' : 'Demo federated graph not created', + }, + }); + }, [status, posthog]); + return (
@@ -214,10 +230,26 @@ export const Step2 = () => { { + captureOnboardingEvent(posthog, { + name: 'onboarding_skipped', + options: { + step_name: 'create_graph', + }, + }); + setSkipped(); + }} backHref="/onboarding/1" forward={{ - onClick: () => router.push('/onboarding/3'), + onClick: () => { + captureOnboardingEvent(posthog, { + name: 'onboarding_step_completed', + options: { + step_name: 'create_graph', + }, + }); + router.push('/onboarding/3'); + }, disabled: status !== 'ok', }} /> diff --git a/studio/src/components/onboarding/step-3.tsx b/studio/src/components/onboarding/step-3.tsx index c565405b99..c470542520 100644 --- a/studio/src/components/onboarding/step-3.tsx +++ b/studio/src/components/onboarding/step-3.tsx @@ -2,6 +2,8 @@ import { motion } from 'framer-motion'; import { useEffect, useMemo, useReducer, useState } from 'react'; import { useOnboarding } from '@/hooks/use-onboarding'; import { useFireworks } from '@/hooks/use-fireworks'; +import { usePostHog } from 'posthog-js/react'; +import { captureOnboardingEvent } from '@/lib/track'; import { OnboardingContainer } from './onboarding-container'; import { OnboardingNavigation } from './onboarding-navigation'; import { StatusIcon, type OnboardingStatus } from './status-icon'; @@ -62,7 +64,11 @@ function pollingReducer( case 'METRICS_TIMEOUT': return { ...state, metricsTimedOut: true }; case 'RESTART_METRICS': - return { ...state, metricsTimedOut: false, metricsEpoch: state.metricsEpoch + 1 }; + return { + ...state, + metricsTimedOut: false, + metricsEpoch: state.metricsEpoch + 1, + }; } } @@ -120,6 +126,7 @@ export const Step3 = () => { const { toast } = useToast(); const { setStep, setSkipped, setOnboarding } = useOnboarding(); const currentOrg = useCurrentOrganization(); + const posthog = usePostHog(); const [polling, dispatch] = useReducer(pollingReducer, { routerTimedOut: false, @@ -190,6 +197,23 @@ export const Step3 = () => { setStep(3); }, [setStep]); + useEffect(() => { + if (!polling.metricsTimedOut || !polling.routerTimedOut) { + return; + } + + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'run_router_send_metrics', + error_category: polling.metricsTimedOut ? 'metrics' : 'router', + error_message: polling.metricsTimedOut + ? 'Metrics not detected within 5 minutes' + : 'Router not detected within 5 minutes', + }, + }); + }, [posthog, polling.routerTimedOut, polling.metricsTimedOut]); + const { mutate, isPending } = useMutation(finishOnboarding, { onSuccess: (d) => { if (d.response?.code !== EnumStatusCode.OK) { @@ -208,13 +232,28 @@ export const Step3 = () => { email: Boolean(prev?.email), })); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_completed', + options: { + step_name: 'run_router_send_metrics', + }, + }); setIsFinished(true); }, onError: (error) => { + const description = error.details.toString() ?? 'We had issues with finishing the onboarding. Please try again.'; toast({ - description: error.details.toString() ?? 'We had issues with finishing the onboarding. Please try again.', + description, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'run_router_send_metrics', + error_category: 'resource', + error_message: description, + }, + }); }, }); @@ -354,7 +393,15 @@ export const Step3 = () => { { + captureOnboardingEvent(posthog, { + name: 'onboarding_skipped', + options: { + step_name: 'run_router_send_metrics', + }, + }); + setSkipped(); + }} backHref="/onboarding/2" forward={{ onClick: () => mutate({}), diff --git a/studio/src/components/onboarding/step-finished.tsx b/studio/src/components/onboarding/step-finished.tsx index 570e8933d8..febd5a4717 100644 --- a/studio/src/components/onboarding/step-finished.tsx +++ b/studio/src/components/onboarding/step-finished.tsx @@ -1,9 +1,11 @@ import { useRouter } from 'next/router'; import { z } from 'zod'; +import { useCallback } from 'react'; import { useFieldArray } from 'react-hook-form'; import { ArrowRightIcon, Cross1Icon, ExternalLinkIcon, PlusIcon } from '@radix-ui/react-icons'; import { BookOpenIcon, UserPlusIcon } from '@heroicons/react/24/outline'; import { MdArrowOutward } from 'react-icons/md'; +import { usePostHog } from 'posthog-js/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { useMutation, useQuery } from '@connectrpc/connect-query'; import { @@ -12,6 +14,7 @@ import { } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { SubmitHandler, useZodForm } from '@/hooks/use-form'; +import { captureOnboardingEvent } from '@/lib/track'; import { cn } from '@/lib/utils'; import { docsBaseURL } from '@/lib/constants'; import { OnboardingContainer } from './onboarding-container'; @@ -41,13 +44,24 @@ const inviteSchema = z.object({ type InviteFormValues = z.infer; -const DocumentationLinkItem = ({ title, description, href }: { title: string; description: string; href: string }) => ( +const DocumentationLinkItem = ({ + title, + description, + href, + onClick, +}: { + title: string; + description: string; + href: string; + onClick: () => void; +}) => (
  • {title} @@ -84,10 +98,17 @@ const HubPromoLink = () => ( export function StepFinished() { const router = useRouter(); + const posthog = usePostHog(); const { toast } = useToast(); const { setStep } = useOnboarding(); const handleFinish = () => { + captureOnboardingEvent(posthog, { + name: 'onboarding_completed', + options: { + step_name: 'onboarding_users_invited_opt' + } + }) setStep(undefined); router.push('/'); }; @@ -118,19 +139,37 @@ export function StepFinished() { { onSuccess: (d) => { if (d.response?.code !== EnumStatusCode.OK) { + const description = d.response?.details ?? 'Could not invite members. Please try again.'; toast({ - description: d.response?.details ?? 'Could not invite members. Please try again.', + description, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'onboarding_users_invited_opt', + error_category: 'invites', + error_message: description, + }, + }); return; } if (d.invitationErrors.length > 0) { const failed = d.invitationErrors.map((e) => e.email).join(', '); + const description = `Some invitations failed: ${failed}`; toast({ - description: `Some invitations failed: ${failed}`, + description, duration: 5000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'onboarding_users_invited_opt', + error_category: 'invites', + error_message: description, + }, + }); return; } @@ -138,18 +177,43 @@ export function StepFinished() { description: `Invited ${emails.length} ${emails.length === 1 ? 'member' : 'members'}.`, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_completed', + options: { + step_name: 'onboarding_users_invited_opt', + users_invited: emails.length, + }, + }); form.reset({ members: [{ email: '' }] }); }, onError: () => { + const description = 'Could not invite members. Please try again.'; toast({ - description: 'Could not invite members. Please try again.', + description, duration: 3000, }); + captureOnboardingEvent(posthog, { + name: 'onboarding_step_failed', + options: { + step_name: 'onboarding_users_invited_opt', + error_category: 'invites', + error_message: description, + }, + }); }, }, ); }; + const trackDocumentationLinkClick = useCallback(() => { + captureOnboardingEvent(posthog, { + name: 'onboarding_step_completed', + options: { + step_name: 'onboarding_docs_visit_opt', + }, + }); + }, [posthog]); + return (
    @@ -264,16 +328,19 @@ export function StepFinished() { title="Introduction to Cosmo" description="What Cosmo is, the moving parts, and how federation fits together." href={`${docsBaseURL}/overview`} + onClick={trackDocumentationLinkClick} /> diff --git a/studio/src/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts index 65534a4b62..80285b3391 100644 --- a/studio/src/hooks/use-onboarding-navigation.ts +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -59,9 +59,12 @@ export const useOnboardingNavigation = () => { return; } + const params = new URLSearchParams(window.location.search); + const referrer = params.get('referrer'); const path = currentStep ? `/onboarding/${currentStep}` : `/onboarding/1`; + const pathWithParams = referrer ? `${path}?referrer=${referrer}` : path; initialRedirect.current = true; - Router.replace(path); + Router.replace(pathWithParams); }, [data, enabled, initialLoadSuccess, skipped, currentStep], ); diff --git a/studio/src/lib/track.ts b/studio/src/lib/track.ts index fd4c5dd3f7..040020fbe5 100644 --- a/studio/src/lib/track.ts +++ b/studio/src/lib/track.ts @@ -1,7 +1,7 @@ // Tracking. This will be available if the following scripts are embedded though CUSTOM_HEAD_SCRIPTS // Reo, PostHog -import posthog from 'posthog-js'; +import posthog, { type PostHog } from 'posthog-js'; declare global { interface Window { @@ -79,4 +79,79 @@ const identify = ({ }); }; -export { resetTracking, identify }; +/** + * IDs in this type are ordered. They correspond to [cosmo-onboarding-v1] onboarding version + */ +type OnboardingStepId = + | 'welcome' + | 'onboarding_comm_channel_set_opt' + | 'create_graph' + | 'run_router_send_metrics' + | 'onboarding_users_invited_opt' + | 'onboarding_docs_visit_opt' + | 'take_me_in_click_opt'; +type OnboardingTrackEvent = + | { + name: 'onboarding_started'; + options: { + /** can be [wgc] or referring URL */ + entry_source?: string; + }; + } + | { + name: 'onboarding_step_completed'; + options: { + step_name: Exclude; + }; + } + | { + name: 'onboarding_step_completed'; + options: { + step_name: 'welcome'; + channel: string[]; + }; + } + | { + name: 'onboarding_step_completed'; + options: { + step_name: 'onboarding_users_invited_opt'; + users_invited: number; + }; + } + | { + name: 'onboarding_skipped'; + options: { + step_name: OnboardingStepId; + }; + } + | { + name: 'onboarding_step_failed'; + options: { + step_name: OnboardingStepId; + /** can be [wgc] */ + entry_source?: string; + /** + * + [resource] - CRUD operation failures, such as onboarding record created + * in the database, updating communication channels, creating federated + * graph & plugin publish (CLI) + * + [router] - failures when running the router via CLI + * + [metrics] - sending metrics fails (CLI) or metrics are not detected + * within permitted time window (web) + * + [invites] - failed to send invites after finishing the onboarding + */ + error_category: 'resource' | 'router' | 'metrics' | 'invites'; + error_message: string; + }; + } + | { + name: 'onboarding_completed'; + options: { + step_name: 'take_me_in_click_opt'; + }; + }; + +const captureOnboardingEvent = (client: PostHog, event: OnboardingTrackEvent): void => { + client.capture(event.name, event.options); +}; + +export { resetTracking, identify, captureOnboardingEvent };