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
24 changed files with 1281 additions and 14329 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
**/**/.env
.DS_Store
node_modules/
node_modules/
# yarn is the recommended package manager across the project
.package-lock.json
+8 -5
View File
@@ -1,13 +1,16 @@
---
sidebar_class_name: coming-soon
sidebar_custom_props:
icon: TbChartDots
---
# 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 is simple. To do this, edit your .env on the server side and include the following:
```
IS_TELEMETRY_ENABLED=false
```
-13378
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/components/RequireAuth';
import { RequireNotAuth } from '@/auth/components/RequireNotAuth';
import { useTrackPageView } from '@/analytics/hooks/useTrackPageView';
import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys';
import { DefaultLayout } from '@/ui/layout/DefaultLayout';
import { Index } from '~/pages/auth/Index';
@@ -18,6 +19,8 @@ export function App() {
useGoToHotkeys('o', '/opportunities');
useGoToHotkeys('s', '/settings/profile');
useTrackPageView();
return (
<DefaultLayout>
<Routes>
+55
View File
@@ -641,6 +641,12 @@ export type EnumPipelineProgressableTypeFilter = {
notIn?: InputMaybe<Array<PipelineProgressableType>>;
};
export type Event = {
__typename?: 'Event';
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean'];
};
export type IntNullableFilter = {
equals?: InputMaybe<Scalars['Int']>;
gt?: InputMaybe<Scalars['Int']>;
@@ -676,6 +682,7 @@ export type LoginToken = {
export type Mutation = {
__typename?: 'Mutation';
challenge: LoginToken;
createEvent: Event;
createOneComment: Comment;
createOneCommentThread: CommentThread;
createOneCompany: Company;
@@ -700,6 +707,12 @@ export type MutationChallengeArgs = {
};
export type MutationCreateEventArgs = {
data: Scalars['JSON'];
type: Scalars['String'];
};
export type MutationCreateOneCommentArgs = {
data: CommentCreateInput;
};
@@ -1657,6 +1670,14 @@ export type DeleteCompaniesMutationVariables = Exact<{
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<{
where?: InputMaybe<PipelineWhereInput>;
}>;
@@ -2422,6 +2443,40 @@ export function useDeleteCompaniesMutation(baseOptions?: Apollo.MutationHookOpti
export type DeleteCompaniesMutationHookResult = ReturnType<typeof useDeleteCompaniesMutation>;
export type DeleteCompaniesMutationResult = Apollo.MutationResult<DeleteCompaniesMutation>;
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`
query GetPipelines($where: PipelineWhereInput) {
findManyPipeline(where: $where) {
@@ -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],
);
}
@@ -0,0 +1,4 @@
export function useIsTelemetryEnabled() {
// TODO: replace by clientConfig
return process.env.IS_TELEMETRY_ENABLED !== 'false';
}
@@ -0,0 +1,7 @@
import { EventData, useEventTracker } from './useEventTracker';
export function useTrackEvent(eventType: string, eventData: EventData) {
const eventTracker = useEventTracker();
return eventTracker(eventType, eventData);
}
@@ -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]);
}
@@ -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
}
}
`;
+8
View File
@@ -2,6 +2,7 @@ import { getOperationName } from '@apollo/client/utilities';
import { graphql } from 'msw';
import { GET_COMPANIES } from '@/companies/services';
import { CREATE_EVENT } from '@/analytics/services';
import { GET_PEOPLE, UPDATE_PERSON } from '@/people/services';
import {
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' },
}),
);
}),
];
+2
View File
@@ -31,6 +31,7 @@
"@casl/ability": "^6.5.0",
"@casl/prisma": "^1.4.0",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
@@ -43,6 +44,7 @@
"@paljs/plugins": "^5.3.3",
"@prisma/client": "^4.13.0",
"apollo-server-express": "^3.12.0",
"axios": "^1.4.0",
"bcrypt": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
+4
View File
@@ -10,6 +10,8 @@ import { GraphQLError } from 'graphql';
import { PrismaModule } from './database/prisma.module';
import { HealthModule } from './health/health.module';
import { AbilityModule } from './ability/ability.module';
import { EventModule } from './core/analytics/event.module';
import GraphQLJSON from 'graphql-type-json';
@Module({
imports: [
@@ -21,6 +23,7 @@ import { AbilityModule } from './ability/ability.module';
context: ({ req }) => ({ req }),
driver: ApolloDriver,
autoSchemaFile: true,
resolvers: { JSON: GraphQLJSON },
plugins: [ApolloServerPluginLandingPageLocalDefault()],
formatError: (error: GraphQLError) => {
error.extensions.stacktrace = undefined;
@@ -31,6 +34,7 @@ import { AbilityModule } from './ability/ability.module';
HealthModule,
AbilityModule,
CoreModule,
EventModule,
],
providers: [AppService],
})
@@ -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;
}
@@ -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;
}
+10
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 {}
@@ -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();
});
});
@@ -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);
}
}
@@ -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();
});
});
@@ -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 };
}
}
+3
View File
@@ -6,6 +6,7 @@ import { PersonModule } from './person/person.module';
import { PipelineModule } from './pipeline/pipeline.module';
import { AuthModule } from './auth/auth.module';
import { WorkspaceModule } from './workspace/workspace.module';
import { EventModule } from './analytics/event.module';
@Module({
imports: [
@@ -16,6 +17,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
PersonModule,
PipelineModule,
WorkspaceModule,
EventModule,
],
exports: [
AuthModule,
@@ -25,6 +27,7 @@ import { WorkspaceModule } from './workspace/workspace.module';
PersonModule,
PipelineModule,
WorkspaceModule,
EventModule,
],
})
export class CoreModule {}
@@ -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;
}
}
+9
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');
}
+964 -945
View File
File diff suppressed because it is too large Load Diff