feat(ui/ingest): add analytics events (#14557)

This commit is contained in:
Aseem Bansal 2025-09-01 17:41:48 +05:30 committed by GitHub
parent 22e382d1be
commit 6044d8d298
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 216 additions and 20 deletions

View File

@ -194,4 +194,17 @@ yarn type-check
# Run tests
yarn test
# Run specific test file
yarn test path/to/file.test.tsx --run
```
## Writing Tests - Best Practices & Common Pitfalls
### Test Setup Essentials
**Always use the existing test infrastructure:**
- Use `TestPageContainer` from `@utils/test-utils/TestPageContainer` - it provides all necessary providers
- Use `MockedProvider` from `@apollo/client/testing` for GraphQL components
- Use Vitest with React Testing Library

View File

@ -75,6 +75,9 @@ export enum EventType {
CreateGlossaryEntityEvent,
CreateDomainEvent,
MoveDomainEvent,
IngestionTestConnectionEvent,
IngestionExecutionResultViewedEvent,
IngestionSourceConfigurationImpressionEvent,
CreateIngestionSourceEvent,
UpdateIngestionSourceEvent,
DeleteIngestionSourceEvent,
@ -610,18 +613,43 @@ export interface MoveDomainEvent extends BaseEvent {
// Managed Ingestion Events
export interface IngestionTestConnectionEvent extends BaseEvent {
type: EventType.IngestionTestConnectionEvent;
sourceType: string;
sourceUrn?: string;
outcome?: string;
}
export interface IngestionExecutionResultViewedEvent extends BaseEvent {
type: EventType.IngestionExecutionResultViewedEvent;
executionUrn: string;
executionStatus: string;
viewedSection: string;
}
export interface IngestionSourceConfigurationImpressionEvent extends BaseEvent {
type: EventType.IngestionSourceConfigurationImpressionEvent;
viewedSection: 'SELECT_TEMPLATE' | 'DEFINE_RECIPE' | 'CREATE_SCHEDULE' | 'NAME_SOURCE';
sourceType?: string;
sourceUrn?: string;
}
export interface CreateIngestionSourceEvent extends BaseEvent {
type: EventType.CreateIngestionSourceEvent;
sourceType: string;
sourceUrn?: string;
interval?: string;
numOwners?: number;
outcome?: string;
}
export interface UpdateIngestionSourceEvent extends BaseEvent {
type: EventType.UpdateIngestionSourceEvent;
sourceType: string;
sourceUrn: string;
interval?: string;
numOwners?: number;
outcome?: string;
}
export interface DeleteIngestionSourceEvent extends BaseEvent {
@ -630,6 +658,8 @@ export interface DeleteIngestionSourceEvent extends BaseEvent {
export interface ExecuteIngestionSourceEvent extends BaseEvent {
type: EventType.ExecuteIngestionSourceEvent;
sourceType?: string;
sourceUrn?: string;
}
// TODO: Find a way to use this event
@ -1216,4 +1246,7 @@ export type Event =
| WelcomeToDataHubModalInteractEvent
| WelcomeToDataHubModalExitEvent
| WelcomeToDataHubModalClickViewDocumentationEvent
| ProductTourButtonClickEvent;
| ProductTourButtonClickEvent
| IngestionTestConnectionEvent
| IngestionExecutionResultViewedEvent
| IngestionSourceConfigurationImpressionEvent;

View File

@ -256,6 +256,8 @@ export const IngestionSourceList = ({ showCreateModal, setShowCreateModal }: Pro
.then(() => {
analytics.event({
type: EventType.ExecuteIngestionSourceEvent,
sourceType: focusSource?.type,
sourceUrn: focusSource?.urn,
});
message.success({
content: `Successfully submitted ingestion execution request!`,
@ -308,6 +310,7 @@ export const IngestionSourceList = ({ showCreateModal, setShowCreateModal }: Pro
analytics.event({
type: EventType.UpdateIngestionSourceEvent,
sourceType: input.type,
sourceUrn: focusSourceUrn,
interval: input.schedule?.interval,
});
message.success({
@ -359,6 +362,7 @@ export const IngestionSourceList = ({ showCreateModal, setShowCreateModal }: Pro
analytics.event({
type: EventType.CreateIngestionSourceEvent,
sourceType: input.type,
sourceUrn: newSource.urn,
interval: input.schedule?.interval,
});
message.success({

View File

@ -1,10 +1,11 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Icon, Modal, Pill } from '@components';
import { message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Tab, Tabs } from '@components/components/Tabs/Tabs';
import analytics, { EventType } from '@app/analytics';
import { LogsTab } from '@app/ingestV2/executions/components/LogsTab';
import { RecipeTab } from '@app/ingestV2/executions/components/RecipeTab';
import { SummaryTab } from '@app/ingestV2/executions/components/SummaryTab';
@ -38,6 +39,30 @@ export const ExecutionDetailsModal = ({ urn, open, onClose }: Props) => {
const status = getIngestionSourceStatus(result);
const [selectedTab, setSelectedTab] = useState<TabType>(TabType.Summary);
const sendAnalyticsTabViewedEvent = useCallback(
(tab: TabType) => {
if (!result) return;
analytics.event({
type: EventType.IngestionExecutionResultViewedEvent,
executionUrn: urn,
executionStatus: status || 'UNKNOWN',
viewedSection: tab,
});
},
[result, urn, status],
);
const selectTab = (tab: TabType) => {
setSelectedTab(tab);
sendAnalyticsTabViewedEvent(tab);
};
useEffect(() => {
if (open) {
sendAnalyticsTabViewedEvent(TabType.Summary);
}
}, [open, urn, status, sendAnalyticsTabViewedEvent]);
const ResultIcon = status && getExecutionRequestStatusIcon(status);
const resultColor = status ? getExecutionRequestStatusDisplayColor(status) : 'gray';
const titlePill = status && ResultIcon && (
@ -106,7 +131,7 @@ export const ExecutionDetailsModal = ({ urn, open, onClose }: Props) => {
<Tabs
tabs={tabs}
selectedTab={selectedTab}
onChange={(tab) => setSelectedTab(tab as TabType)}
onChange={(tab) => selectTab(tab as TabType)}
getCurrentUrl={() => window.location.pathname}
scrollToTopOnChange
maxHeight="80vh"

View File

@ -272,6 +272,8 @@ export const IngestionSourceList = ({
const focusSource = finalSources.find((s) => s.urn === focusSourceUrn);
const isLastPage = totalSources <= pageSize * page;
// this is required when the ingestion source has not been created
const [selectedSourceType, setSelectedSourceType] = useState<string | undefined>(undefined);
useEffect(() => {
const sources = (data?.listIngestionSources?.ingestionSources || []) as IngestionSource[];
@ -392,8 +394,10 @@ export const IngestionSourceList = ({
analytics.event({
type: EventType.UpdateIngestionSourceEvent,
sourceType: input.type,
sourceUrn: focusSourceUrn,
interval: input.schedule?.interval,
numOwners: owners?.length,
outcome: shouldRun ? 'save_and_run' : 'save',
});
message.success({
content: `Successfully updated ingestion source!`,
@ -460,8 +464,10 @@ export const IngestionSourceList = ({
analytics.event({
type: EventType.CreateIngestionSourceEvent,
sourceType: input.type,
sourceUrn: newSource.urn,
interval: input.schedule?.interval,
numOwners: ownersToAdd?.length,
outcome: shouldRun ? 'save_and_run' : 'save',
});
message.success({
content: `Successfully created ingestion source!`,
@ -696,6 +702,8 @@ export const IngestionSourceList = ({
return Promise.resolve();
}}
selectedSource={focusSource}
selectedSourceType={selectedSourceType}
setSelectedSourceType={setSelectedSourceType}
loading={isModalWaiting}
/>
{isViewingRecipe && <RecipeViewerModal recipe={focusSource?.config?.recipe} onCancel={onCancel} />}

View File

@ -39,7 +39,15 @@ const ControlsContainer = styled.div`
/**
* The step for defining a recipe
*/
export const DefineRecipeStep = ({ state, updateState, goTo, prev, ingestionSources }: StepProps) => {
export const DefineRecipeStep = ({
state,
updateState,
goTo,
prev,
ingestionSources,
selectedSource,
setSelectedSourceType,
}: StepProps) => {
const existingRecipeJson = state.config?.recipe;
const existingRecipeYaml = existingRecipeJson && jsonToYaml(existingRecipeJson);
const { type } = state;
@ -96,6 +104,7 @@ export const DefineRecipeStep = ({ state, updateState, goTo, prev, ingestionSour
updateState(newState);
goTo(IngestionSourceBuilderStep.CREATE_SCHEDULE);
setSelectedSourceType?.(newState.type);
};
if (type && CONNECTORS_WITH_FORM.has(type)) {
@ -109,6 +118,7 @@ export const DefineRecipeStep = ({ state, updateState, goTo, prev, ingestionSour
setStagedRecipe={setStagedRecipeYml}
onClickNext={onClickNext}
goToPrevious={prev}
selectedSource={selectedSource}
/>
);
}

View File

@ -2,9 +2,10 @@ import { LoadingOutlined } from '@ant-design/icons';
import { Modal } from '@components';
import { Spin, Steps } from 'antd';
import { isEqual } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import analytics, { EventType } from '@app/analytics';
import { CreateScheduleStep } from '@app/ingestV2/source/builder/CreateScheduleStep';
import { DefineRecipeStep } from '@app/ingestV2/source/builder/DefineRecipeStep';
import { NameSourceStep } from '@app/ingestV2/source/builder/NameSourceStep';
@ -58,6 +59,8 @@ type Props = {
sourceRefetch?: () => Promise<any>;
selectedSource?: IngestionSource;
loading?: boolean;
selectedSourceType?: string;
setSelectedSourceType?: (sourceType: string) => void;
};
export const IngestionSourceBuilderModal = ({
@ -68,6 +71,8 @@ export const IngestionSourceBuilderModal = ({
sourceRefetch,
selectedSource,
loading,
selectedSourceType,
setSelectedSourceType,
}: Props) => {
const isEditing = initialState !== undefined;
const titleText = isEditing ? 'Edit Data Source' : 'Connect Data Source';
@ -84,20 +89,46 @@ export const IngestionSourceBuilderModal = ({
const ingestionSources = JSON.parse(JSON.stringify(sourcesJson)); // TODO: replace with call to server once we have access to dynamic list of sources
// Reset the ingestion builder modal state when the modal is re-opened.
const prevInitialState = useRef(initialState);
useEffect(() => {
if (!isEqual(prevInitialState.current, initialState)) {
setIngestionBuilderState(initialState || {});
}
prevInitialState.current = initialState;
}, [initialState]);
const sendAnalyticsStepViewedEvent = useCallback(
(step: IngestionSourceBuilderStep) => {
if (open) {
analytics.event({
type: EventType.IngestionSourceConfigurationImpressionEvent,
viewedSection: step,
sourceType: selectedSource?.type || selectedSourceType,
sourceUrn: selectedSource?.urn,
});
}
},
[selectedSource?.type, selectedSource?.urn, selectedSourceType, open],
);
// Reset the step stack to the initial step when the modal is re-opened.
useEffect(() => setStepStack([initialStep]), [initialStep]);
// Reset the modal state when initialState changes or modal opens
const prevInitialState = useRef(initialState);
const prevOpen = useRef(open);
useEffect(() => {
const stateChanged = !isEqual(prevInitialState.current, initialState);
const modalOpened = !prevOpen.current && open;
if (stateChanged) {
setIngestionBuilderState(initialState || {});
setStepStack([initialStep]);
setSelectedSourceType?.('');
prevInitialState.current = initialState;
}
// Fire event when modal opens
if (modalOpened) {
setStepStack([initialStep]); // Ensure correct step when modal opens
sendAnalyticsStepViewedEvent(initialStep);
}
prevOpen.current = open;
}, [initialState, initialStep, open, sendAnalyticsStepViewedEvent, setSelectedSourceType]);
const goTo = (step: IngestionSourceBuilderStep) => {
setStepStack([...stepStack, step]);
sendAnalyticsStepViewedEvent(step);
};
const prev = () => {
@ -151,6 +182,8 @@ export const IngestionSourceBuilderModal = ({
ingestionSources={ingestionSources}
sourceRefetch={sourceRefetch}
selectedSource={selectedSource}
selectedSourceType={selectedSourceType}
setSelectedSourceType={setSelectedSourceType}
/>
</Spin>
</Modal>

View File

@ -14,6 +14,8 @@ import { CSV, LOOKER, LOOK_ML } from '@app/ingestV2/source/builder/constants';
import { SourceBuilderState, SourceConfig } from '@app/ingestV2/source/builder/types';
import { Button } from '@src/alchemy-components';
import { IngestionSource } from '@types';
export const ControlsContainer = styled.div`
display: flex;
justify-content: space-between;
@ -61,13 +63,23 @@ interface Props {
isEditing: boolean;
displayRecipe: string;
sourceConfigs?: SourceConfig;
selectedSource?: IngestionSource;
setStagedRecipe: (recipe: string) => void;
onClickNext: () => void;
goToPrevious?: () => void;
}
function RecipeBuilder(props: Props) {
const { state, isEditing, displayRecipe, sourceConfigs, setStagedRecipe, onClickNext, goToPrevious } = props;
const {
state,
isEditing,
displayRecipe,
sourceConfigs,
setStagedRecipe,
onClickNext,
goToPrevious,
selectedSource,
} = props;
const { type } = state;
const [isViewingForm, setIsViewingForm] = useState(true);
const [hideDocsHint, setHideDocsHint] = useState(false);
@ -119,6 +131,7 @@ function RecipeBuilder(props: Props) {
{isViewingForm && (
<RecipeForm
state={state}
selectedSource={selectedSource}
isEditing={isEditing}
displayRecipe={displayRecipe}
sourceConfigs={sourceConfigs}

View File

@ -16,6 +16,7 @@ import { jsonToYaml } from '@app/ingestV2/source/utils';
import { RequiredFieldForm } from '@app/shared/form/RequiredFieldForm';
import { useListSecretsQuery } from '@graphql/ingestion.generated';
import { IngestionSource } from '@types';
export const ControlsContainer = styled.div`
display: flex;
@ -102,10 +103,20 @@ interface Props {
setStagedRecipe: (recipe: string) => void;
onClickNext: () => void;
goToPrevious?: () => void;
selectedSource?: IngestionSource;
}
function RecipeForm(props: Props) {
const { state, isEditing, displayRecipe, sourceConfigs, setStagedRecipe, onClickNext, goToPrevious } = props;
const {
state,
isEditing,
displayRecipe,
sourceConfigs,
setStagedRecipe,
onClickNext,
goToPrevious,
selectedSource,
} = props;
const { type } = state;
const version = state.config?.version;
const { fields, advancedFields, filterFields, filterSectionTooltip, advancedSectionTooltip, defaultOpenSections } =
@ -171,6 +182,7 @@ function RecipeForm(props: Props) {
recipe={displayRecipe}
sourceConfigs={sourceConfigs}
version={version}
selectedSource={selectedSource}
/>
</TestConnectionWrapper>
)}

View File

@ -2,6 +2,7 @@ import { CheckCircleOutlined } from '@ant-design/icons';
import { message } from 'antd';
import React, { useEffect, useState } from 'react';
import analytics, { EventType } from '@app/analytics';
import { EXECUTION_REQUEST_STATUS_FAILURE, EXECUTION_REQUEST_STATUS_RUNNING } from '@app/ingestV2/executions/constants';
import TestConnectionModal from '@app/ingestV2/source/builder/RecipeForm/TestConnection/TestConnectionModal';
import { TestConnectionResult } from '@app/ingestV2/source/builder/RecipeForm/TestConnection/types';
@ -13,6 +14,7 @@ import {
useCreateTestConnectionRequestMutation,
useGetIngestionExecutionRequestLazyQuery,
} from '@graphql/ingestion.generated';
import { ExecutionRequestResult, IngestionSource } from '@types';
export function getRecipeJson(recipeYaml: string) {
// Convert the recipe into it's json representation, and catch + report exceptions while we do it.
@ -29,18 +31,33 @@ export function getRecipeJson(recipeYaml: string) {
return recipeJson;
}
export function getSourceTypeFromRecipeJson(recipeJson: string) {
const recipe = JSON.parse(recipeJson);
return recipe.source.type;
}
export function getBasicConnectivityFromResult(result: ExecutionRequestResult) {
if (!result?.structuredReport?.serializedValue) {
return false;
}
const resultJson = JSON.parse(result.structuredReport.serializedValue);
return resultJson?.basic_connectivity?.capable;
}
interface Props {
recipe: string;
sourceConfigs?: SourceConfig;
version?: string | null;
selectedSource?: IngestionSource;
}
function TestConnectionButton(props: Props) {
const { recipe, sourceConfigs, version } = props;
const { recipe, sourceConfigs, version, selectedSource } = props;
const [isLoading, setIsLoading] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [pollingInterval, setPollingInterval] = useState<null | NodeJS.Timeout>(null);
const [testConnectionResult, setTestConnectionResult] = useState<null | TestConnectionResult>(null);
const [hasEmittedAnalytics, setHasEmittedAnalytics] = useState(false);
const [createTestConnectionRequest, { data: requestData }] = useCreateTestConnectionRequestMutation();
const [getIngestionExecutionRequest, { data: resultData, loading }] = useGetIngestionExecutionRequestLazyQuery();
@ -63,6 +80,18 @@ function TestConnectionButton(props: Props) {
if (!loading && resultData) {
const result = resultData.executionRequest?.result;
if (result && result.status !== EXECUTION_REQUEST_STATUS_RUNNING) {
const recipeJson = getRecipeJson(recipe);
if (recipeJson && !hasEmittedAnalytics) {
const basicConnectivity = getBasicConnectivityFromResult(result);
analytics.event({
type: EventType.IngestionTestConnectionEvent,
sourceType: getSourceTypeFromRecipeJson(recipeJson),
sourceUrn: selectedSource?.urn,
outcome: basicConnectivity ? 'completed' : 'failed',
});
setHasEmittedAnalytics(true);
}
if (result.status === EXECUTION_REQUEST_STATUS_FAILURE) {
message.error(
'Something went wrong with your connection test. Please check your recipe and try again.',
@ -77,7 +106,7 @@ function TestConnectionButton(props: Props) {
setIsLoading(false);
}
}
}, [resultData, pollingInterval, loading]);
}, [resultData, pollingInterval, loading, recipe, selectedSource?.urn, hasEmittedAnalytics]);
useEffect(() => {
if (!isModalVisible && pollingInterval) {
@ -88,6 +117,7 @@ function TestConnectionButton(props: Props) {
function testConnection() {
const recipeJson = getRecipeJson(recipe);
if (recipeJson) {
setHasEmittedAnalytics(false);
createTestConnectionRequest({ variables: { input: { recipe: recipeJson, version } } })
.then((res) =>
getIngestionExecutionRequest({

View File

@ -82,7 +82,14 @@ function SourceOption({ source, onClick }: SourceOptionProps) {
/**
* Component responsible for selecting the mechanism for constructing a new Ingestion Source
*/
export const SelectTemplateStep = ({ state, updateState, goTo, cancel, ingestionSources }: StepProps) => {
export const SelectTemplateStep = ({
state,
updateState,
goTo,
cancel,
ingestionSources,
setSelectedSourceType,
}: StepProps) => {
const [searchFilter, setSearchFilter] = useState('');
const onSelectTemplate = (type: string) => {
@ -93,6 +100,7 @@ export const SelectTemplateStep = ({ state, updateState, goTo, cancel, ingestion
};
updateState(newState);
goTo(IngestionSourceBuilderStep.DEFINE_RECIPE);
setSelectedSourceType?.(type);
};
const filteredSources = ingestionSources.filter(

View File

@ -38,6 +38,10 @@ export type StepProps = {
isEditing: boolean;
sourceRefetch?: () => Promise<any>;
selectedSource?: IngestionSource;
// This is not same as selectedSource
// This is required when the ingestion source has not been created
selectedSourceType?: string;
setSelectedSourceType?: (sourceType: string) => void;
};
export type StringMapEntryInput = {

View File

@ -55,6 +55,9 @@ public enum DataHubUsageEventType {
CREATE_GLOSSARY_ENTITY_EVENT("CreateGlossaryEntityEvent"),
CREATE_DOMAIN_EVENT("CreateDomainEvent"),
MOVE_DOMAIN_EVENT("MoveDomainEvent"),
INGESTION_TEST_CONNECTION_EVENT("IngestionTestConnectionEvent"),
INGESTION_EXECUTION_RESULT_VIEWED_EVENT("IngestionExecutionResultViewedEvent"),
INGESTION_SOURCE_CONFIGURATION_IMPRESSION_EVENT("IngestionSourceConfigurationImpressionEvent"),
CREATE_INGESTION_SOURCE_EVENT("CreateIngestionSourceEvent"),
UPDATE_INGESTION_SOURCE_EVENT("UpdateIngestionSourceEvent"),
DELETE_INGESTION_SOURCE_EVENT("DeleteIngestionSourceEvent"),