Add Telemetry (#466)

* Telemetry v1

* Add package-lock.json to gitignore
This commit is contained in:
Félix Malfait 2023-06-29 17:36:48 -07:00 committed by GitHub
parent 74ea2718ca
commit eb7fb2ba8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1281 additions and 14329 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
**/**/.env **/**/.env
.DS_Store .DS_Store
node_modules/ node_modules/
# yarn is the recommended package manager across the project
.package-lock.json

View File

@ -1,13 +1,16 @@
--- ---
sidebar_class_name: coming-soon
sidebar_custom_props: sidebar_custom_props:
icon: TbChartDots icon: TbChartDots
--- ---
# Data collected # Data collected
... We record page view events using a unique identifier for each workspace/user.
Additionally we also collect the workspace's domain.
We do not set any cookie for telemetry.
We do not collect any email, first name, last name, phone number, date of birth, address, username, etc.
# Opting-out of telemetry # Opting-out of telemetry
... Opting out is simple. To do this, edit your .env on the server side and include the following:
```
IS_TELEMETRY_ENABLED=false
```

13378
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/components/RequireAuth'; import { RequireAuth } from '@/auth/components/RequireAuth';
import { RequireNotAuth } from '@/auth/components/RequireNotAuth'; import { RequireNotAuth } from '@/auth/components/RequireNotAuth';
import { useTrackPageView } from '@/analytics/hooks/useTrackPageView';
import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys'; import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys';
import { DefaultLayout } from '@/ui/layout/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/DefaultLayout';
import { Index } from '~/pages/auth/Index'; import { Index } from '~/pages/auth/Index';
@ -18,6 +19,8 @@ export function App() {
useGoToHotkeys('o', '/opportunities'); useGoToHotkeys('o', '/opportunities');
useGoToHotkeys('s', '/settings/profile'); useGoToHotkeys('s', '/settings/profile');
useTrackPageView();
return ( return (
<DefaultLayout> <DefaultLayout>
<Routes> <Routes>

View File

@ -641,6 +641,12 @@ export type EnumPipelineProgressableTypeFilter = {
notIn?: InputMaybe<Array<PipelineProgressableType>>; notIn?: InputMaybe<Array<PipelineProgressableType>>;
}; };
export type Event = {
__typename?: 'Event';
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean'];
};
export type IntNullableFilter = { export type IntNullableFilter = {
equals?: InputMaybe<Scalars['Int']>; equals?: InputMaybe<Scalars['Int']>;
gt?: InputMaybe<Scalars['Int']>; gt?: InputMaybe<Scalars['Int']>;
@ -676,6 +682,7 @@ export type LoginToken = {
export type Mutation = { export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
challenge: LoginToken; challenge: LoginToken;
createEvent: Event;
createOneComment: Comment; createOneComment: Comment;
createOneCommentThread: CommentThread; createOneCommentThread: CommentThread;
createOneCompany: Company; createOneCompany: Company;
@ -700,6 +707,12 @@ export type MutationChallengeArgs = {
}; };
export type MutationCreateEventArgs = {
data: Scalars['JSON'];
type: Scalars['String'];
};
export type MutationCreateOneCommentArgs = { export type MutationCreateOneCommentArgs = {
data: CommentCreateInput; data: CommentCreateInput;
}; };
@ -1657,6 +1670,14 @@ export type DeleteCompaniesMutationVariables = Exact<{
export type DeleteCompaniesMutation = { __typename?: 'Mutation', deleteManyCompany: { __typename?: 'AffectedRows', count: number } }; export type DeleteCompaniesMutation = { __typename?: 'Mutation', deleteManyCompany: { __typename?: 'AffectedRows', count: number } };
export type CreateEventMutationVariables = Exact<{
type: Scalars['String'];
data: Scalars['JSON'];
}>;
export type CreateEventMutation = { __typename?: 'Mutation', createEvent: { __typename?: 'Event', success: boolean } };
export type GetPipelinesQueryVariables = Exact<{ export type GetPipelinesQueryVariables = Exact<{
where?: InputMaybe<PipelineWhereInput>; where?: InputMaybe<PipelineWhereInput>;
}>; }>;
@ -2422,6 +2443,40 @@ export function useDeleteCompaniesMutation(baseOptions?: Apollo.MutationHookOpti
export type DeleteCompaniesMutationHookResult = ReturnType<typeof useDeleteCompaniesMutation>; export type DeleteCompaniesMutationHookResult = ReturnType<typeof useDeleteCompaniesMutation>;
export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>; export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>;
export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions<DeleteCompaniesMutation, DeleteCompaniesMutationVariables>; export type DeleteCompaniesMutationOptions = Apollo.BaseMutationOptions<DeleteCompaniesMutation, DeleteCompaniesMutationVariables>;
export const CreateEventDocument = gql`
mutation CreateEvent($type: String!, $data: JSON!) {
createEvent(type: $type, data: $data) {
success
}
}
`;
export type CreateEventMutationFn = Apollo.MutationFunction<CreateEventMutation, CreateEventMutationVariables>;
/**
* __useCreateEventMutation__
*
* To run a mutation, you first call `useCreateEventMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateEventMutation` 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 [createEventMutation, { data, loading, error }] = useCreateEventMutation({
* variables: {
* type: // value for 'type'
* data: // value for 'data'
* },
* });
*/
export function useCreateEventMutation(baseOptions?: Apollo.MutationHookOptions<CreateEventMutation, CreateEventMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateEventMutation, CreateEventMutationVariables>(CreateEventDocument, options);
}
export type CreateEventMutationHookResult = ReturnType<typeof useCreateEventMutation>;
export type CreateEventMutationResult = Apollo.MutationResult<CreateEventMutation>;
export type CreateEventMutationOptions = Apollo.BaseMutationOptions<CreateEventMutation, CreateEventMutationVariables>;
export const GetPipelinesDocument = gql` export const GetPipelinesDocument = gql`
query GetPipelines($where: PipelineWhereInput) { query GetPipelines($where: PipelineWhereInput) {
findManyPipeline(where: $where) { findManyPipeline(where: $where) {

View File

@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useCreateEventMutation } from '~/generated/graphql';
import { useIsTelemetryEnabled } from './useIsTelemetryEnabled';
interface EventLocation {
pathname: string;
}
export interface EventData {
location: EventLocation;
}
export function useEventTracker() {
const telemetryEnabled = useIsTelemetryEnabled();
const [createEventMutation] = useCreateEventMutation();
return useCallback(
(eventType: string, eventData: EventData) => {
if (telemetryEnabled) {
createEventMutation({
variables: {
type: eventType,
data: eventData,
},
});
}
},
[createEventMutation, telemetryEnabled],
);
}

View File

@ -0,0 +1,4 @@
export function useIsTelemetryEnabled() {
// TODO: replace by clientConfig
return process.env.IS_TELEMETRY_ENABLED !== 'false';
}

View File

@ -0,0 +1,7 @@
import { EventData, useEventTracker } from './useEventTracker';
export function useTrackEvent(eventType: string, eventData: EventData) {
const eventTracker = useEventTracker();
return eventTracker(eventType, eventData);
}

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEventTracker } from './useEventTracker';
export function useTrackPageView() {
const location = useLocation();
const eventTracker = useEventTracker();
useEffect(() => {
eventTracker('pageview', {
location: {
pathname: location.pathname,
},
});
}, [location, eventTracker]);
}

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_EVENT = gql`
mutation CreateEvent($type: String!, $data: JSON!) {
createEvent(type: $type, data: $data) {
success
}
}
`;

View File

@ -2,6 +2,7 @@ import { getOperationName } from '@apollo/client/utilities';
import { graphql } from 'msw'; import { graphql } from 'msw';
import { GET_COMPANIES } from '@/companies/services'; import { GET_COMPANIES } from '@/companies/services';
import { CREATE_EVENT } from '@/analytics/services';
import { GET_PEOPLE, UPDATE_PERSON } from '@/people/services'; import { GET_PEOPLE, UPDATE_PERSON } from '@/people/services';
import { import {
SEARCH_COMPANY_QUERY, SEARCH_COMPANY_QUERY,
@ -102,4 +103,11 @@ export const graphqlMocks = [
}), }),
); );
}), }),
graphql.mutation(getOperationName(CREATE_EVENT) ?? '', (req, res, ctx) => {
return res(
ctx.data({
createEvent: { success: 1, __typename: 'Event' },
}),
);
}),
]; ];

View File

@ -31,6 +31,7 @@
"@casl/ability": "^6.5.0", "@casl/ability": "^6.5.0",
"@casl/prisma": "^1.4.0", "@casl/prisma": "^1.4.0",
"@nestjs/apollo": "^11.0.5", "@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^9.0.0", "@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2", "@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0", "@nestjs/core": "^9.0.0",
@ -43,6 +44,7 @@
"@paljs/plugins": "^5.3.3", "@paljs/plugins": "^5.3.3",
"@prisma/client": "^4.13.0", "@prisma/client": "^4.13.0",
"apollo-server-express": "^3.12.0", "apollo-server-express": "^3.12.0",
"axios": "^1.4.0",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",

View File

@ -10,6 +10,8 @@ import { GraphQLError } from 'graphql';
import { PrismaModule } from './database/prisma.module'; import { PrismaModule } from './database/prisma.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AbilityModule } from './ability/ability.module'; import { AbilityModule } from './ability/ability.module';
import { EventModule } from './core/analytics/event.module';
import GraphQLJSON from 'graphql-type-json';
@Module({ @Module({
imports: [ imports: [
@ -21,6 +23,7 @@ import { AbilityModule } from './ability/ability.module';
context: ({ req }) => ({ req }), context: ({ req }) => ({ req }),
driver: ApolloDriver, driver: ApolloDriver,
autoSchemaFile: true, autoSchemaFile: true,
resolvers: { JSON: GraphQLJSON },
plugins: [ApolloServerPluginLandingPageLocalDefault()], plugins: [ApolloServerPluginLandingPageLocalDefault()],
formatError: (error: GraphQLError) => { formatError: (error: GraphQLError) => {
error.extensions.stacktrace = undefined; error.extensions.stacktrace = undefined;
@ -31,6 +34,7 @@ import { AbilityModule } from './ability/ability.module';
HealthModule, HealthModule,
AbilityModule, AbilityModule,
CoreModule, CoreModule,
EventModule,
], ],
providers: [AppService], providers: [AppService],
}) })

View File

@ -0,0 +1,15 @@
import { ArgsType, Field } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { IsNotEmpty, IsString, IsObject } from 'class-validator';
@ArgsType()
export class CreateEventInput {
@Field({ description: 'Type of the event' })
@IsNotEmpty()
@IsString()
type: string;
@Field(() => GraphQLJSON, { description: 'Event data in JSON format' })
@IsObject()
data: JSON;
}

View File

@ -0,0 +1,9 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
@ObjectType()
export class Event {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EventService } from './event.service';
import { EventResolver } from './event.resolver';
import { HttpModule } from '@nestjs/axios';
@Module({
providers: [EventResolver, EventService],
imports: [HttpModule],
})
export class EventModule {}

View File

@ -0,0 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EventResolver } from './event.resolver';
import { EventService } from './event.service';
describe('EventResolver', () => {
let resolver: EventResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EventResolver, EventService],
}).compile();
resolver = module.get<EventResolver>(EventResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

View File

@ -0,0 +1,24 @@
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { EventService } from './event.service';
import { Event } from './event.entity';
import { CreateEventInput } from './dto/create-event.input';
import { OptionalJwtAuthGuard } from 'src/guards/optional-jwt.auth.guard';
import { UseGuards } from '@nestjs/common';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { User, Workspace } from '@prisma/client';
import { AuthUser } from 'src/decorators/auth-user.decorator';
@UseGuards(OptionalJwtAuthGuard)
@Resolver(() => Event)
export class EventResolver {
constructor(private readonly eventService: EventService) {}
@Mutation(() => Event)
createEvent(
@Args() createEventInput: CreateEventInput,
@AuthWorkspace() workspace: Workspace | undefined,
@AuthUser() user: User | undefined,
) {
return this.eventService.create(createEventInput, user, workspace);
}
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EventService } from './event.service';
describe('EventService', () => {
let service: EventService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EventService],
}).compile();
service = module.get<EventService>(EventService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { CreateEventInput } from './dto/create-event.input';
import { HttpService } from '@nestjs/axios';
import { anonymize } from 'src/utils/anonymize';
import { User, Workspace } from '@prisma/client';
@Injectable()
export class EventService {
constructor(private readonly httpService: HttpService) {}
create(
createEventInput: CreateEventInput,
user: User | undefined,
workspace: Workspace | undefined,
) {
if (process.env.IS_TELEMETRY_ENABLED === 'false') {
return;
}
const data = {
type: createEventInput.type,
data: {
userUUID: user ? anonymize(user.id) : undefined,
workspaceUUID: workspace ? anonymize(workspace.id) : undefined,
workspaceDomain: workspace ? workspace.domainName : undefined,
...createEventInput.data,
},
};
this.httpService
.post('https://t.twenty.com/api/v1/s2s/event?noToken', data)
.subscribe({
error: () => null,
});
return { success: true };
}
}

View File

@ -6,6 +6,7 @@ import { PersonModule } from './person/person.module';
import { PipelineModule } from './pipeline/pipeline.module'; import { PipelineModule } from './pipeline/pipeline.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { WorkspaceModule } from './workspace/workspace.module'; import { WorkspaceModule } from './workspace/workspace.module';
import { EventModule } from './analytics/event.module';
@Module({ @Module({
imports: [ imports: [
@ -16,6 +17,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
PersonModule, PersonModule,
PipelineModule, PipelineModule,
WorkspaceModule, WorkspaceModule,
EventModule,
], ],
exports: [ exports: [
AuthModule, AuthModule,
@ -25,6 +27,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
PersonModule, PersonModule,
PipelineModule, PipelineModule,
WorkspaceModule, WorkspaceModule,
EventModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { getRequest } from 'src/utils/extract-request';
@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard(['jwt']) {
constructor() {
super();
}
getRequest(context: ExecutionContext) {
const request = getRequest(context);
return request;
}
handleRequest(err, user, info) {
if (err || info) return null;
return user;
}
}

View File

@ -0,0 +1,9 @@
import crypto from 'crypto';
export function anonymize(input) {
if (process.env.IS_TELEMETRY_ANONYMIZATION_ENABLED === 'false') {
return input;
}
// md5 shorter than sha-256 and collisions are not a security risk in this use-case
return crypto.createHash('md5').update(input).digest('hex');
}

File diff suppressed because it is too large Load Diff