Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions apps/builder/ANALYTICS_FEATURE_FLAG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,29 @@ The analytics feature flag controls:

### Tracked Events

When analytics is enabled, the following user interactions **within the UI Builder** are tracked:

- **Ecosystem Selection**: When users select blockchain ecosystems (EVM, Solana, etc.) in the builder
- **Network Selection**: When users choose specific networks within ecosystems in the builder
- **Export Actions**: When users click the "Export" button to generate standalone applications
- **Wizard Progress**: Each step progression through the form builder wizard interface
- **Sidebar Interactions**: Import/Export button clicks in the storage sidebar of the builder
When analytics is enabled, the following GA4 events are sent from **within the UI Builder**. All
events are fired from `src/hooks/useBuilderAnalytics.ts` (builder-specific) or the shared
`useAnalytics` hook from `@openzeppelin/ui-react` (`page_view`, `network_selected`).

Every string parameter is normalised to `"unknown"` when the value is missing or empty, so GA4
event-scoped custom dimensions are never dropped for lack of a value.

| Event | Parameters | Fires when | Call site |
| ---------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `page_view` | `page_title`, `page_location` (GA defaults) | Once on initial load, automatically by `gtag('config')`. No manual `trackPageView` calls. | `AnalyticsProvider` in `App.tsx` |
| `ecosystem_selected` | `ecosystem` | User picks an ecosystem in the chain selector, or a deep link changes the ecosystem | `ChainSelector.tsx`, `useBuilderLifecycle.ts` |
| `network_selected` | `network_id`, `ecosystem` | User picks a network, or a deep link resolves a network | `ChainSelector.tsx`, `useBuilderLifecycle.ts` |
| `wizard_step` | `step_number` (1-indexed), `step_name`, `network_id`, `ecosystem` | Once per Next/Back click; describes the step being entered | `Common/WizardLayout.tsx` |
| `export_clicked` | `export_type`, `network_id`, `ecosystem` | Once after an app export succeeds | `UIBuilder/hooks/useCompleteStepState.ts` |
| `sidebar_interaction` | `action` (`import` \| `export`), `network_id`, `ecosystem` | Once per sidebar Import/Export click | `Sidebar/AppSidebar/MainActions.tsx` |
| `transaction_executed` | `network_id`, `ecosystem`, `execution_method` | A transaction succeeds from the form preview | `StepFormCustomization/FormPreview.tsx` |
| `contract_ui_created` | `network_id`, `ecosystem`, `total_records` | Once when a new Contract UI record is first persisted by auto-save | `UIBuilder/hooks/builder/useAutoSave.ts` |
| `relayer_service_configured` | `network_id`, `ecosystem` | Once per mount when relayer URL, API key and relayer selection are all set | `StepFormCustomization/components/RelayerConfiguration/index.tsx` |
| `uikit_changed` | `network_id`, `ecosystem`, `uikit_name` | User selects a UI kit in builder settings | `StepFormCustomization/components/UiKitSettings.tsx` |
| `address_book_opened` | `network_id`, `ecosystem` | Once when the address book dialog opens (not on network changes while open) | `AddressBook/AddressBookDialog.tsx` |

GA4 event-scoped custom dimensions to register: `ecosystem`, `network_id`, `step_name`,
`step_number`, `export_type`, `action`, `execution_method`, `uikit_name`, `total_records`.

**Important:** These analytics only track user behavior within the builder tool itself. The standalone applications that users export do not contain any analytics tracking.

Expand Down
8 changes: 6 additions & 2 deletions apps/builder/src/components/Common/WizardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { ReactNode } from 'react';
import { Button } from '@openzeppelin/ui-components';
import { cn } from '@openzeppelin/ui-utils';

import type { AnalyticsNetworkContext } from '../../hooks/useBuilderAnalytics';
import { useBuilderAnalytics } from '../../hooks/useBuilderAnalytics';

export interface WizardStep {
Expand All @@ -20,6 +21,8 @@ interface WizardLayoutProps {
isWidgetExpanded?: boolean;
currentStepIndex: number;
onStepChange: (index: number) => void;
/** Network context attached to `wizard_step` analytics events. */
analyticsContext?: AnalyticsNetworkContext;
}

export function WizardLayout({
Expand All @@ -29,6 +32,7 @@ export function WizardLayout({
isWidgetExpanded = false,
currentStepIndex,
onStepChange,
analyticsContext,
}: WizardLayoutProps) {
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
Expand All @@ -43,7 +47,7 @@ export function WizardLayout({
const nextStep = steps[nextStepIndex];

// Track wizard step progression
trackWizardStep(nextStepIndex + 1, nextStep.id); // Step numbers are 1-indexed for analytics
trackWizardStep(nextStepIndex + 1, nextStep.id, analyticsContext); // Step numbers are 1-indexed for analytics

onStepChange(nextStepIndex);
};
Expand All @@ -54,7 +58,7 @@ export function WizardLayout({
const prevStep = steps[prevStepIndex];

// Track wizard step progression (going backwards)
trackWizardStep(prevStepIndex + 1, prevStep.id); // Step numbers are 1-indexed for analytics
trackWizardStep(prevStepIndex + 1, prevStep.id, analyticsContext); // Step numbers are 1-indexed for analytics

onStepChange(prevStepIndex);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { WizardLayout, type WizardStep } from '../WizardLayout';

const mockTrackWizardStep = vi.fn();

vi.mock('../../../hooks/useBuilderAnalytics', () => ({
useBuilderAnalytics: () => ({
trackWizardStep: mockTrackWizardStep,
}),
}));

const steps: WizardStep[] = [
{ id: 'chain', title: 'Chain', component: <div>chain</div> },
{ id: 'contract', title: 'Contract', component: <div>contract</div> },
{ id: 'function', title: 'Function', component: <div>function</div> },
{ id: 'customize', title: 'Customize', component: <div>customize</div> },
{ id: 'complete', title: 'Complete', component: <div>complete</div> },
];

const analyticsContext = { networkId: 'ethereum-mainnet', ecosystem: 'evm' };

describe('WizardLayout analytics', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('fires wizard_step exactly once per Next click with the entered step and context', () => {
const onStepChange = vi.fn();
render(
<WizardLayout
steps={steps}
currentStepIndex={1}
onStepChange={onStepChange}
analyticsContext={analyticsContext}
/>
);

fireEvent.click(screen.getByRole('button', { name: /next/i }));

expect(mockTrackWizardStep).toHaveBeenCalledTimes(1);
expect(mockTrackWizardStep).toHaveBeenCalledWith(3, 'function', analyticsContext);
expect(onStepChange).toHaveBeenCalledWith(2);
});

it('fires wizard_step exactly once per Back click with the entered step and context', () => {
const onStepChange = vi.fn();
render(
<WizardLayout
steps={steps}
currentStepIndex={3}
onStepChange={onStepChange}
analyticsContext={analyticsContext}
/>
);

fireEvent.click(screen.getByRole('button', { name: /back/i }));

expect(mockTrackWizardStep).toHaveBeenCalledTimes(1);
expect(mockTrackWizardStep).toHaveBeenCalledWith(3, 'function', analyticsContext);
expect(onStepChange).toHaveBeenCalledWith(2);
});

it('does not fire wizard_step on render or when no navigation happens', () => {
render(<WizardLayout steps={steps} currentStepIndex={1} onStepChange={vi.fn()} />);

expect(mockTrackWizardStep).not.toHaveBeenCalled();
});

it('passes undefined context when none is provided so the hook applies fallbacks', () => {
render(<WizardLayout steps={steps} currentStepIndex={1} onStepChange={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /next/i }));

expect(mockTrackWizardStep).toHaveBeenCalledWith(3, 'function', undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SidebarButton } from '@openzeppelin/ui-components';
import { useContractUIStorage } from '../../../contexts/useContractUIStorage';
import { useBuilderAnalytics } from '../../../hooks/useBuilderAnalytics';
import { AddressBookDialog } from '../../AddressBook/AddressBookDialog';
import { useUIBuilderStore } from '../../UIBuilder/hooks/useUIBuilderStore';
import { recordHasMeaningfulContent } from '../../UIBuilder/utils/meaningfulContent';

interface MainActionsProps {
Expand All @@ -31,17 +32,21 @@ export default function MainActions({
}: MainActionsProps) {
const { exportContractUIs, contractUIs } = useContractUIStorage();
const { trackSidebarInteraction } = useBuilderAnalytics();
const analyticsContext = useUIBuilderStore((s) => ({
networkId: s.selectedNetworkConfigId,
ecosystem: s.selectedEcosystem,
}));

const handleExport = async () => {
// Track sidebar export interaction
trackSidebarInteraction('export');
trackSidebarInteraction('export', analyticsContext);

await exportContractUIs(); // Export all configurations
};

const handleImport = () => {
// Track sidebar import interaction
trackSidebarInteraction('import');
trackSidebarInteraction('import', analyticsContext);

onShowImportDialog();
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { Ecosystem } from '@openzeppelin/ui-types';

import { uiBuilderStore } from '../../../UIBuilder/hooks/uiBuilderStore';
import MainActions from '../MainActions';

const mockTrackSidebarInteraction = vi.fn();
const mockExportContractUIs = vi.fn().mockResolvedValue(undefined);

vi.mock('../../../../hooks/useBuilderAnalytics', () => ({
useBuilderAnalytics: () => ({
trackSidebarInteraction: mockTrackSidebarInteraction,
}),
}));

vi.mock('../../../../contexts/useContractUIStorage', () => ({
useContractUIStorage: () => ({
exportContractUIs: mockExportContractUIs,
contractUIs: [
{
id: 'r1',
title: 'Saved UI',
ecosystem: 'evm',
networkId: 'ethereum-mainnet',
contractAddress: '0x0000000000000000000000000000000000000001',
functionId: 'transfer',
formConfig: { fields: [{ id: 'f1' }] },
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
}),
}));

vi.mock('../../../AddressBook/AddressBookDialog', () => ({
AddressBookDialog: () => null,
}));

vi.mock('../../../UIBuilder/utils/meaningfulContent', () => ({
recordHasMeaningfulContent: () => true,
}));

describe('MainActions analytics', () => {
beforeEach(() => {
vi.clearAllMocks();
uiBuilderStore.updateState(() => ({
selectedNetworkConfigId: 'stellar-testnet',
selectedEcosystem: 'stellar' as Ecosystem,
}));
});

it('fires sidebar_interaction import exactly once with the selected network context', () => {
const onShowImportDialog = vi.fn();
render(<MainActions onShowImportDialog={onShowImportDialog} />);

fireEvent.click(screen.getByRole('button', { name: /import/i }));

expect(mockTrackSidebarInteraction).toHaveBeenCalledTimes(1);
expect(mockTrackSidebarInteraction).toHaveBeenCalledWith('import', {
networkId: 'stellar-testnet',
ecosystem: 'stellar',
});
expect(onShowImportDialog).toHaveBeenCalledTimes(1);
});

it('fires sidebar_interaction export exactly once with the selected network context', () => {
render(<MainActions onShowImportDialog={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /export/i }));

expect(mockTrackSidebarInteraction).toHaveBeenCalledTimes(1);
expect(mockTrackSidebarInteraction).toHaveBeenCalledWith('export', {
networkId: 'stellar-testnet',
ecosystem: 'stellar',
});
expect(mockExportContractUIs).toHaveBeenCalledTimes(1);
});

it('passes null network id when no network has been selected yet', () => {
uiBuilderStore.updateState(() => ({ selectedNetworkConfigId: null }));
render(<MainActions onShowImportDialog={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /import/i }));

expect(mockTrackSidebarInteraction).toHaveBeenCalledWith('import', {
networkId: null,
ecosystem: 'stellar',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ export function useCompleteStepState() {
);

// Track successful export action
trackExportAction('react-vite'); // Default export type - could be made dynamic based on template choice
// Default export type - could be made dynamic based on template choice
trackExportAction('react-vite', {
networkId: networkConfig.id,
ecosystem: networkConfig.ecosystem,
});

if (result.data instanceof Blob) {
downloadBlob(result.data, result.fileName);
Expand Down
4 changes: 4 additions & 0 deletions apps/builder/src/components/UIBuilder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ export function UIBuilder() {
isWidgetExpanded={state.isWidgetVisible}
currentStepIndex={state.currentStepIndex}
onStepChange={handleStepChange}
analyticsContext={{
networkId: state.selectedNetworkConfigId,
ecosystem: state.selectedEcosystem,
}}
/>
</div>
</div>
Expand Down
Loading
Loading