feat(analytics): add clickhouse (#11174)

This commit is contained in:
Antoine Moreaux
2025-04-16 18:33:10 +02:00
committed by GitHub
parent b6901a49bf
commit 587281a541
66 changed files with 1858 additions and 244 deletions

View File

@ -63,6 +63,11 @@ export type Analytics = {
success: Scalars['Boolean']['output'];
};
export enum AnalyticsType {
PAGEVIEW = 'PAGEVIEW',
TRACK = 'TRACK'
}
export type ApiConfig = {
__typename?: 'ApiConfig';
mutationMaximumAffectedRecords: Scalars['Float']['output'];
@ -155,7 +160,8 @@ export type BillingEndTrialPeriodOutput = {
export type BillingMeteredProductUsageOutput = {
__typename?: 'BillingMeteredProductUsageOutput';
includedFreeQuantity: Scalars['Float']['output'];
freeTierQuantity: Scalars['Float']['output'];
freeTrialQuantity: Scalars['Float']['output'];
periodEnd: Scalars['DateTime']['output'];
periodStart: Scalars['DateTime']['output'];
productKey: BillingProductKey;
@ -474,6 +480,10 @@ export type CreateServerlessFunctionInput = {
};
export type CreateWorkflowVersionStepInput = {
/** Next step ID */
nextStepId?: InputMaybe<Scalars['String']['input']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['String']['input']>;
/** New step type */
stepType: Scalars['String']['input'];
/** Workflow version ID */
@ -598,6 +608,12 @@ export type FeatureFlag = {
workspaceId: Scalars['String']['output'];
};
export type FeatureFlagDto = {
__typename?: 'FeatureFlagDTO';
key: FeatureFlagKey;
value: Scalars['Boolean']['output'];
};
export enum FeatureFlagKey {
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
@ -970,8 +986,9 @@ export type Mutation = {
syncRemoteTable: RemoteTable;
syncRemoteTableSchemaChanges: RemoteTable;
track: Analytics;
trackAnalytics: Analytics;
unsyncRemoteTable: RemoteTable;
updateLabPublicFeatureFlag: FeatureFlag;
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field;
updateOneObject: Object;
updateOneRemoteServer: RemoteServer;
@ -1252,6 +1269,14 @@ export type MutationTrackArgs = {
};
export type MutationTrackAnalyticsArgs = {
event?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
properties?: InputMaybe<Scalars['JSON']['input']>;
type: AnalyticsType;
};
export type MutationUnsyncRemoteTableArgs = {
input: RemoteTableInput;
};
@ -2462,7 +2487,7 @@ export type Workspace = {
defaultRole?: Maybe<Role>;
deletedAt?: Maybe<Scalars['DateTime']['output']>;
displayName?: Maybe<Scalars['String']['output']>;
featureFlags?: Maybe<Array<FeatureFlag>>;
featureFlags?: Maybe<Array<FeatureFlagDto>>;
hasValidEnterpriseKey: Scalars['Boolean']['output'];
id: Scalars['UUID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>;

View File

@ -55,6 +55,11 @@ export type Analytics = {
success: Scalars['Boolean'];
};
export enum AnalyticsType {
PAGEVIEW = 'PAGEVIEW',
TRACK = 'TRACK'
}
export type ApiConfig = {
__typename?: 'ApiConfig';
mutationMaximumAffectedRecords: Scalars['Float'];
@ -899,6 +904,7 @@ export type Mutation = {
submitFormStep: Scalars['Boolean'];
switchToYearlyInterval: BillingUpdateOutput;
track: Analytics;
trackAnalytics: Analytics;
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field;
updateOneObject: Object;
@ -1139,6 +1145,14 @@ export type MutationTrackArgs = {
};
export type MutationTrackAnalyticsArgs = {
event?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
properties?: InputMaybe<Scalars['JSON']>;
type: AnalyticsType;
};
export type MutationUpdateLabPublicFeatureFlagArgs = {
input: UpdateLabPublicFeatureFlagInput;
};
@ -2403,6 +2417,16 @@ export type GetTimelineThreadsFromPersonIdQueryVariables = Exact<{
export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } };
export type TrackAnalyticsMutationVariables = Exact<{
type: AnalyticsType;
event?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
properties?: InputMaybe<Scalars['JSON']>;
}>;
export type TrackAnalyticsMutation = { __typename?: 'Mutation', trackAnalytics: { __typename?: 'Analytics', success: boolean } };
export type TrackMutationVariables = Exact<{
action: Scalars['String'];
payload: Scalars['JSON'];
@ -3326,6 +3350,42 @@ export function useGetTimelineThreadsFromPersonIdLazyQuery(baseOptions?: Apollo.
export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdQuery>;
export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType<typeof useGetTimelineThreadsFromPersonIdLazyQuery>;
export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult<GetTimelineThreadsFromPersonIdQuery, GetTimelineThreadsFromPersonIdQueryVariables>;
export const TrackAnalyticsDocument = gql`
mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) {
trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) {
success
}
}
`;
export type TrackAnalyticsMutationFn = Apollo.MutationFunction<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
/**
* __useTrackAnalyticsMutation__
*
* To run a mutation, you first call `useTrackAnalyticsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useTrackAnalyticsMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [trackAnalyticsMutation, { data, loading, error }] = useTrackAnalyticsMutation({
* variables: {
* type: // value for 'type'
* event: // value for 'event'
* name: // value for 'name'
* properties: // value for 'properties'
* },
* });
*/
export function useTrackAnalyticsMutation(baseOptions?: Apollo.MutationHookOptions<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>(TrackAnalyticsDocument, options);
}
export type TrackAnalyticsMutationHookResult = ReturnType<typeof useTrackAnalyticsMutation>;
export type TrackAnalyticsMutationResult = Apollo.MutationResult<TrackAnalyticsMutation>;
export type TrackAnalyticsMutationOptions = Apollo.BaseMutationOptions<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
export const TrackDocument = gql`
mutation Track($action: String!, $payload: JSON!) {
track(action: $action, payload: $payload) {

View File

@ -1,8 +1,18 @@
import { gql } from '@apollo/client';
export const TRACK = gql`
mutation Track($action: String!, $payload: JSON!) {
track(action: $action, payload: $payload) {
export const TRACK_ANALYTICS = gql`
mutation TrackAnalytics(
$type: AnalyticsType!
$event: String
$name: String
$properties: JSON
) {
trackAnalytics(
type: $type
event: $event
name: $name
properties: $properties
) {
success
}
}

View File

@ -5,23 +5,76 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { useEventTracker } from '../useEventTracker';
import { ANALYTICS_COOKIE_NAME, useEventTracker } from '../useEventTracker';
import { AnalyticsType } from '~/generated/graphql';
// Mock document.cookie
Object.defineProperty(document, 'cookie', {
writable: true,
value: `${ANALYTICS_COOKIE_NAME}=exampleId`,
});
const mocks: MockedResponse[] = [
{
request: {
query: gql`
mutation Track($action: String!, $payload: JSON!) {
track(action: $action, payload: $payload) {
mutation TrackAnalytics(
$type: AnalyticsType!
$event: String
$name: String
$properties: JSON
) {
trackAnalytics(
type: $type
event: $event
name: $name
properties: $properties
) {
success
}
}
`,
variables: {
action: 'exampleType',
payload: {
type: AnalyticsType['TRACK'],
event: 'Example Event',
properties: {
foo: 'bar',
},
},
},
result: jest.fn(() => ({
data: {
track: {
success: true,
},
},
})),
},
{
request: {
query: gql`
mutation TrackAnalytics(
$type: AnalyticsType!
$event: String
$name: String
$properties: JSON
) {
trackAnalytics(
type: $type
event: $event
name: $name
properties: $properties
) {
success
}
}
`,
variables: {
type: AnalyticsType['PAGEVIEW'],
name: 'Example',
properties: {
sessionId: 'exampleId',
pathname: '',
pathname: '/example/path',
userAgent: '',
timeZone: '',
locale: '',
@ -50,24 +103,45 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useEventTracker', () => {
it('should make the call to track the event', async () => {
const eventType = 'exampleType';
const eventData = {
sessionId: 'exampleId',
pathname: '',
userAgent: '',
timeZone: '',
locale: '',
href: '',
referrer: '',
const payload = {
event: 'Example Event',
properties: {
foo: 'bar',
},
};
const { result } = renderHook(() => useEventTracker(), {
wrapper: Wrapper,
});
act(() => {
result.current(eventType, eventData);
result.current(AnalyticsType['TRACK'], payload);
});
await waitFor(() => {
expect(mocks[0].result).toHaveBeenCalled();
});
});
it('should make the call to track a pageview', async () => {
const payload = {
name: 'Example',
properties: {
sessionId: 'exampleId',
pathname: '/example/path',
userAgent: '',
timeZone: '',
locale: '',
href: '',
referrer: '',
},
};
const { result } = renderHook(() => useEventTracker(), {
wrapper: Wrapper,
});
act(() => {
result.current(AnalyticsType['PAGEVIEW'], payload);
});
await waitFor(() => {
expect(mocks[1].result).toHaveBeenCalled();
});
});
});

View File

@ -1,14 +1,11 @@
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { useTrackMutation } from '~/generated/graphql';
export interface EventData {
pathname: string;
userAgent: string;
timeZone: string;
locale: string;
href: string;
referrer: string;
}
import {
AnalyticsType,
MutationTrackAnalyticsArgs,
useTrackAnalyticsMutation,
} from '~/generated/graphql';
export const ANALYTICS_COOKIE_NAME = 'analyticsCookie';
export const getSessionId = (): string => {
const cookie: { [key: string]: string } = {};
@ -28,16 +25,22 @@ export const setSessionId = (domain?: string): void => {
};
export const useEventTracker = () => {
const [createEventMutation] = useTrackMutation();
const [createEventMutation] = useTrackAnalyticsMutation();
return useCallback(
(eventAction: string, eventPayload: EventData) => {
(
type: AnalyticsType,
payload: Omit<MutationTrackAnalyticsArgs, 'type'>,
) => {
createEventMutation({
variables: {
action: eventAction,
payload: {
sessionId: getSessionId(),
...eventPayload,
type,
...payload,
properties: {
...payload.properties,
...(type === AnalyticsType['PAGEVIEW']
? { sessionId: getSessionId() }
: {}),
},
},
});

View File

@ -27,6 +27,8 @@ import { isDefined } from 'twenty-shared/utils';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { AnalyticsType } from '~/generated/graphql';
import { getPageTitleFromPath } from '~/utils/title-utils';
// TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook
@ -174,13 +176,16 @@ export const PageChangeEffect = () => {
useEffect(() => {
setTimeout(() => {
setSessionId();
eventTracker('pageview', {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: window.location.href,
referrer: document.referrer,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
eventTracker(AnalyticsType['PAGEVIEW'], {
name: getPageTitleFromPath(location.pathname),
properties: {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: window.location.href,
referrer: document.referrer,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
}, 500);
}, [eventTracker, location.pathname]);

View File

@ -1,7 +1,7 @@
import { getOperationName } from '@apollo/client/utilities';
import { graphql, GraphQLQuery, http, HttpResponse } from 'msw';
import { TRACK } from '@/analytics/graphql/queries/track';
import { TRACK_ANALYTICS } from '@/analytics/graphql/queries/track';
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
@ -110,10 +110,10 @@ export const graphqlMocks = {
});
},
),
graphql.mutation(getOperationName(TRACK) ?? '', () => {
graphql.mutation(getOperationName(TRACK_ANALYTICS) ?? '', () => {
return HttpResponse.json({
data: {
track: { success: 1, __typename: 'TRACK' },
track: { success: 1, __typename: 'TRACK_ANALYTICS' },
},
});
}),