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

@ -0,0 +1,143 @@
# Analytics Module
This module provides analytics tracking functionality for the Twenty application.
## Usage
### Tracking Events
The `AnalyticsService` provides a `createAnalyticsContext` method that returns an object with a `track` method. The `track` method is used to track events.
```typescript
import { Injectable } from '@nestjs/common';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
@Injectable()
export class MyService {
constructor(private readonly analyticsService: AnalyticsService) {}
async doSomething() {
// Create an analytics context
const analytics = this.analyticsService.createAnalyticsContext({
workspaceId: 'workspace-id',
userId: 'user-id',
});
// Track an event
// The event name will be autocompleted
// The properties will be type-checked based on the event name
analytics.track(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
}
}
```
### Adding New Events
To add a new event:
1. Create a new file in the `src/engine/core-modules/analytics/utils/events/track` directory
2. Define the event name, schema, and type
3. Register the event using the `registerEvent` function
4. Update the `TrackEventName` and `TrackEventProperties` types in `src/engine/core-modules/analytics/utils/events/event-types.ts`
Example:
```typescript
// src/engine/core-modules/analytics/utils/events/track/my-feature/my-event.ts
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const MY_EVENT = 'My Event' as const;
export const myEventSchema = z.object({
event: z.literal(MY_EVENT),
properties: z.object({
myProperty: z.string(),
}),
});
export type MyEventTrackEvent = z.infer<typeof myEventSchema>;
registerEvent(MY_EVENT, myEventSchema);
```
Then update the `events.type.ts` file:
```typescript
// src/engine/core-modules/analytics/types/events.type.ts
import { MY_EVENT, MyEventTrackEvent } from '../utils/events/track/my-feature/my-event';
// Add to the union type
export type TrackEventName =
| typeof MY_EVENT
// ... other event names;
// Add to the TrackEvents interface
export interface TrackEvents {
[MY_EVENT]: MyEventTrackEvent;
// ... other event types
}
// The TrackEventProperties type will automatically use the new event
export type TrackEventProperties<T extends TrackEventName> = T extends keyof TrackEvents
? TrackEvents[T]['properties']
: object;
```
## API
### AnalyticsService
#### createAnalyticsContext(context?)
Creates an analytics context with the given user ID and workspace ID.
- `context` (optional): An object with `userId` and `workspaceId` properties
Returns an object with the following methods:
- `track<T extends TrackEventName>(event: T, properties: TrackEventProperties<T>)`: Tracks an event with the given name and properties
- `pageview(name: string, properties: Partial<PageviewProperties>)`: Tracks a pageview with the given name and properties
### Types
#### TrackEventName
A union type of all registered event names, plus `string` for backward compatibility.
#### TrackEventProperties<T>
A mapped type that maps each event name to its corresponding properties type. It uses the `TrackEvents` interface to provide a more maintainable and type-safe way to map event names to their properties.
```typescript
// Define the mapping between event names and their event types
export interface TrackEvents {
[EVENT_NAME_1]: Event1Type;
[EVENT_NAME_2]: Event2Type;
// ... other event types
}
// Use the mapping to extract properties for each event type
export type TrackEventProperties<T extends TrackEventName> = T extends keyof TrackEvents
? TrackEvents[T]['properties']
: object;
```
This approach makes it easier to add new events without having to modify a complex nested conditional type.
#### PageviewProperties
A type that defines the structure of pageview properties:
```typescript
type PageviewProperties = {
href: string;
locale: string;
pathname: string;
referrer: string;
sessionId: string;
timeZone: string;
userAgent: string;
};
```

View File

@ -0,0 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class AnalyticsException extends CustomException {
constructor(message: string, code: AnalyticsExceptionCode) {
super(message, code);
}
}
export enum AnalyticsExceptionCode {
INVALID_TYPE = 'INVALID_TYPE',
INVALID_INPUT = 'INVALID_INPUT',
}

View File

@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { ClickhouseService } from 'src/engine/core-modules/analytics/services/clickhouse.service';
import { AnalyticsResolver } from './analytics.resolver';
import { AnalyticsService } from './analytics.service';
import { AnalyticsService } from './services/analytics.service';
@Module({
providers: [AnalyticsResolver, AnalyticsService],
providers: [AnalyticsResolver, AnalyticsService, ClickhouseService],
imports: [JwtModule],
exports: [AnalyticsService],
})

View File

@ -1,18 +1,31 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import {
AnalyticsException,
AnalyticsExceptionCode,
} from 'src/engine/core-modules/analytics/analytics.exception';
import { AnalyticsResolver } from './analytics.resolver';
import { AnalyticsService } from './analytics.service';
import { AnalyticsService } from './services/analytics.service';
describe('AnalyticsResolver', () => {
let resolver: AnalyticsResolver;
let analyticsService: jest.Mocked<AnalyticsService>;
beforeEach(async () => {
analyticsService = {
createAnalyticsContext: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
AnalyticsResolver,
{
provide: AnalyticsService,
useValue: {},
useValue: analyticsService,
},
],
}).compile();
@ -23,4 +36,71 @@ describe('AnalyticsResolver', () => {
it('should be defined', () => {
expect(resolver).toBeDefined();
});
it('should handle a valid pageview input', async () => {
const mockPageview = jest.fn().mockResolvedValue('Pageview created');
analyticsService.createAnalyticsContext.mockReturnValue({
pageview: mockPageview,
track: jest.fn(),
});
const input = {
type: 'pageview' as const,
name: 'Test Page',
properties: {},
};
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-1' } as Workspace,
{ id: 'user-1' } as User,
);
expect(analyticsService.createAnalyticsContext).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
});
expect(mockPageview).toHaveBeenCalledWith('Test Page', {});
expect(result).toBe('Pageview created');
});
it('should handle a valid track input', async () => {
const mockTrack = jest.fn().mockResolvedValue('Track created');
analyticsService.createAnalyticsContext.mockReturnValue({
track: mockTrack,
pageview: jest.fn(),
});
const input = {
type: 'track' as const,
event: 'Custom Domain Activated' as const,
properties: {},
};
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-2' } as Workspace,
{ id: 'user-2' } as User,
);
expect(analyticsService.createAnalyticsContext).toHaveBeenCalledWith({
workspaceId: 'workspace-2',
userId: 'user-2',
});
expect(mockTrack).toHaveBeenCalledWith('Custom Domain Activated', {});
expect(result).toBe('Track created');
});
it('should throw an AnalyticsException for invalid input', async () => {
const invalidInput = { type: 'invalid' };
await expect(
resolver.trackAnalytics(invalidInput as any, undefined, undefined),
).rejects.toThrowError(
new AnalyticsException(
'Invalid analytics input',
AnalyticsExceptionCode.INVALID_TYPE,
),
);
});
});

View File

@ -4,26 +4,63 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
AnalyticsException,
AnalyticsExceptionCode,
} from 'src/engine/core-modules/analytics/analytics.exception';
import { AnalyticsService } from './analytics.service';
import { CreateAnalyticsInput } from './dtos/create-analytics.input';
import { AnalyticsService } from './services/analytics.service';
import {
CreateAnalyticsInput,
CreateAnalyticsInputV2,
isPageviewAnalyticsInput,
isTrackAnalyticsInput,
} from './dtos/create-analytics.input';
import { Analytics } from './entities/analytics.entity';
@Resolver(() => Analytics)
export class AnalyticsResolver {
constructor(private readonly analyticsService: AnalyticsService) {}
// deprecated
@Mutation(() => Analytics)
track(
@Args() createAnalyticsInput: CreateAnalyticsInput,
@Args() _createAnalyticsInput: CreateAnalyticsInput,
@AuthWorkspace() _workspace: Workspace | undefined,
@AuthUser({ allowUndefined: true }) _user: User | undefined,
) {
return { success: true };
}
@Mutation(() => Analytics)
async trackAnalytics(
@Args()
createAnalyticsInput: CreateAnalyticsInputV2,
@AuthWorkspace() workspace: Workspace | undefined,
@AuthUser({ allowUndefined: true }) user: User | undefined,
) {
return this.analyticsService.create(
createAnalyticsInput,
user?.id,
workspace?.id,
const analyticsContext = this.analyticsService.createAnalyticsContext({
workspaceId: workspace?.id,
userId: user?.id,
});
if (isPageviewAnalyticsInput(createAnalyticsInput)) {
return analyticsContext.pageview(
createAnalyticsInput.name,
createAnalyticsInput.properties ?? {},
);
}
if (isTrackAnalyticsInput(createAnalyticsInput)) {
return analyticsContext.track(
createAnalyticsInput.event,
createAnalyticsInput.properties ?? {},
);
}
throw new AnalyticsException(
'Invalid analytics input',
AnalyticsExceptionCode.INVALID_TYPE,
);
}
}

View File

@ -1,37 +0,0 @@
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AnalyticsService } from './analytics.service';
describe('AnalyticsService', () => {
let service: AnalyticsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AnalyticsService,
{
provide: TwentyConfigService,
useValue: {},
},
{
provide: JwtWrapperService,
useValue: {},
},
{
provide: HttpService,
useValue: {},
},
],
}).compile();
service = module.get<AnalyticsService>(AnalyticsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,53 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
type CreateEventInput = {
action: string;
payload: object;
};
@Injectable()
export class AnalyticsService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async create(
createEventInput: CreateEventInput,
userId: string | null | undefined,
workspaceId: string | null | undefined,
) {
if (!this.twentyConfigService.get('ANALYTICS_ENABLED')) {
return { success: true };
}
let _data;
switch (createEventInput.action) {
case 'pageview':
_data = {
timestamp: new Date().toISOString(),
version: '1',
userId: userId,
workspaceId: workspaceId,
...createEventInput.payload,
};
break;
default:
_data = {
action: createEventInput.action,
timestamp: new Date().toISOString(),
version: '1',
userId: userId,
workspaceId: workspaceId,
payload: {
...createEventInput.payload,
},
};
break;
}
// TODO: send event to clickhouse
return { success: true };
}
}

View File

@ -1,8 +1,27 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { ArgsType, Field, registerEnumType } from '@nestjs/graphql';
import { IsNotEmpty, IsObject, IsString } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { TrackEventName } from 'src/engine/core-modules/analytics/types/events.type';
import { PageviewProperties } from 'src/engine/core-modules/analytics/utils/events/pageview/pageview';
enum AnalyticsType {
PAGEVIEW = 'pageview',
TRACK = 'track',
}
registerEnumType(AnalyticsType, {
name: 'AnalyticsType',
});
// deprecated
@ArgsType()
export class CreateAnalyticsInput {
@Field({ description: 'Type of the event' })
@ -10,7 +29,41 @@ export class CreateAnalyticsInput {
@IsString()
action: string;
@Field(() => graphqlTypeJson, { description: 'Event payload in JSON format' })
@Field(() => GraphQLJSON, { description: 'Event payload in JSON format' })
@IsObject()
payload: JSON;
}
@ArgsType()
export class CreateAnalyticsInputV2 {
@Field(() => AnalyticsType)
@IsEnum(AnalyticsType)
type: 'pageview' | 'track';
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
name?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
event?: TrackEventName;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
properties?: PageviewProperties | Record<string, unknown>;
}
export function isPageviewAnalyticsInput(
input: CreateAnalyticsInputV2,
): input is CreateAnalyticsInputV2 & { name: string } {
return input.type === 'pageview' && !!input.name;
}
export function isTrackAnalyticsInput(
input: CreateAnalyticsInputV2,
): input is CreateAnalyticsInputV2 & { event: TrackEventName } {
return input.type === 'track' && !!input.event;
}

View File

@ -0,0 +1,127 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AnalyticsContextMock } from 'test/utils/analytics-context.mock';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ClickhouseService } from 'src/engine/core-modules/analytics/services/clickhouse.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import { AnalyticsService } from './analytics.service';
describe('AnalyticsService', () => {
let service: AnalyticsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AnalyticsService,
useValue: {
createAnalyticsContext: AnalyticsContextMock,
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue(true),
},
},
{
provide: ClickhouseService,
useValue: {
pushEvent: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
],
}).compile();
service = module.get<AnalyticsService>(AnalyticsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createAnalyticsContext', () => {
const mockUserIdAndWorkspaceId = {
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
};
it('should create a valid context object', () => {
const context = service.createAnalyticsContext(mockUserIdAndWorkspaceId);
expect(context).toHaveProperty('track');
expect(context).toHaveProperty('pageview');
});
it('should call track with correct parameters', async () => {
const trackSpy = jest.fn().mockResolvedValue({ success: true });
const mockContext = AnalyticsContextMock({
track: trackSpy,
});
jest
.spyOn(service, 'createAnalyticsContext')
.mockReturnValue(mockContext);
const context = service.createAnalyticsContext(mockUserIdAndWorkspaceId);
await context.track(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
expect(trackSpy).toHaveBeenCalledWith(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
});
it('should call pageview with correct parameters', async () => {
const pageviewSpy = jest.fn().mockResolvedValue({ success: true });
const mockContext = AnalyticsContextMock({
pageview: pageviewSpy,
});
jest
.spyOn(service, 'createAnalyticsContext')
.mockReturnValue(mockContext);
const context = service.createAnalyticsContext(mockUserIdAndWorkspaceId);
const testPageviewProperties = {
href: '/test-url',
locale: '',
pathname: '',
referrer: '',
sessionId: '',
timeZone: '',
userAgent: '',
};
await context.pageview('page-view', testPageviewProperties);
expect(pageviewSpy).toHaveBeenCalledWith(
'page-view',
testPageviewProperties,
);
});
it('should return success when track is called', async () => {
const context = service.createAnalyticsContext(mockUserIdAndWorkspaceId);
const result = await context.track(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
expect(result).toEqual({ success: true });
});
it('should return success when pageview is called', async () => {
const context = service.createAnalyticsContext(mockUserIdAndWorkspaceId);
const result = await context.pageview('page-view', {});
expect(result).toEqual({ success: true });
});
});
});

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import {
makePageview,
makeTrackEvent,
} from 'src/engine/core-modules/analytics/utils/analytics.utils';
import { ClickhouseService } from 'src/engine/core-modules/analytics/services/clickhouse.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
TrackEventName,
TrackEventProperties,
} from 'src/engine/core-modules/analytics/types/events.type';
import { PageviewProperties } from 'src/engine/core-modules/analytics/utils/events/pageview/pageview';
import {
AnalyticsException,
AnalyticsExceptionCode,
} from 'src/engine/core-modules/analytics/analytics.exception';
@Injectable()
export class AnalyticsService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly clickhouseService: ClickhouseService,
) {}
createAnalyticsContext(context?: {
workspaceId?: string | null | undefined;
userId?: string | null | undefined;
}) {
const userIdAndWorkspaceId = context
? {
...(context.userId ? { userId: context.userId } : {}),
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
}
: {};
return {
track: <T extends TrackEventName>(
event: T,
properties: TrackEventProperties<T>,
) =>
this.preventAnalyticsIfDisabled(() =>
this.clickhouseService.pushEvent({
...userIdAndWorkspaceId,
...makeTrackEvent(event, properties),
}),
),
pageview: (name: string, properties: Partial<PageviewProperties>) =>
this.preventAnalyticsIfDisabled(() =>
this.clickhouseService.pushEvent({
...userIdAndWorkspaceId,
...makePageview(name, properties),
}),
),
};
}
private preventAnalyticsIfDisabled(
sendEventOrPageviewFunction: () => Promise<{ success: boolean }>,
) {
if (!this.twentyConfigService.get('ANALYTICS_ENABLED')) {
return { success: true };
}
try {
return sendEventOrPageviewFunction();
} catch (err) {
return new AnalyticsException(err, AnalyticsExceptionCode.INVALID_INPUT);
}
}
}

View File

@ -0,0 +1,159 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import {
makePageview,
makeTrackEvent,
} from 'src/engine/core-modules/analytics/utils/analytics.utils';
import { ClickhouseService } from './clickhouse.service';
// Mock the createClient function from @clickhouse/client
jest.mock('@clickhouse/client', () => ({
createClient: jest.fn().mockReturnValue({
insert: jest.fn().mockResolvedValue({}),
}),
}));
describe('ClickhouseService', () => {
let service: ClickhouseService;
let twentyConfigService: TwentyConfigService;
let exceptionHandlerService: ExceptionHandlerService;
let mockClickhouseClient: { insert: jest.Mock };
const mockPageview = makePageview('Home', {
href: 'https://example.com/test',
locale: 'en-US',
pathname: '/test',
referrer: 'https://example.com',
sessionId: 'test-session-id',
timeZone: 'UTC',
userAgent: 'test-user-agent',
});
const mockEvent = makeTrackEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
beforeEach(async () => {
jest.clearAllMocks();
mockClickhouseClient = {
insert: jest.fn().mockResolvedValue({}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ClickhouseService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn((key) => {
if (key === 'ANALYTICS_ENABLED') return true;
if (key === 'CLICKHOUSE_URL') return 'http://localhost:8123';
return null;
}),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
],
}).compile();
service = module.get<ClickhouseService>(ClickhouseService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
exceptionHandlerService = module.get<ExceptionHandlerService>(
ExceptionHandlerService,
);
// Set the mock client
// @ts-expect-error accessing private property for testing
service.clickhouseClient = mockClickhouseClient;
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('constructor', () => {
it('should not initialize clickhouse client when analytics is disabled', async () => {
jest.spyOn(twentyConfigService, 'get').mockImplementation((key) => {
if (key === 'ANALYTICS_ENABLED') return false;
});
const newModule: TestingModule = await Test.createTestingModule({
providers: [
ClickhouseService,
{
provide: TwentyConfigService,
useValue: twentyConfigService,
},
{
provide: ExceptionHandlerService,
useValue: exceptionHandlerService,
},
],
}).compile();
const newService = newModule.get<ClickhouseService>(ClickhouseService);
// @ts-expect-error accessing private property for testing
expect(newService.clickhouseClient).toBeUndefined();
});
});
describe('pushEvent', () => {
it('should insert event into clickhouse and return success', async () => {
const result = await service.pushEvent(mockEvent);
expect(result).toEqual({ success: true });
const { type: _type, ...rest } = mockEvent;
expect(mockClickhouseClient.insert).toHaveBeenCalledWith({
table: 'events',
values: [rest],
format: 'JSONEachRow',
});
});
it('should insert pageview into clickhouse and return success', async () => {
const result = await service.pushEvent(mockPageview);
expect(result).toEqual({ success: true });
const { type: _type, ...rest } = mockPageview;
expect(mockClickhouseClient.insert).toHaveBeenCalledWith({
table: 'pageview',
values: [rest],
format: 'JSONEachRow',
});
});
it('should return success when clickhouse client is not defined', async () => {
// @ts-expect-error accessing private property for testing
service.clickhouseClient = undefined;
const result = await service.pushEvent(mockEvent);
expect(result).toEqual({ success: true });
});
it('should handle errors and return failure', async () => {
const testError = new Error('Test error');
mockClickhouseClient.insert.mockRejectedValueOnce(testError);
const result = await service.pushEvent(mockEvent);
expect(result).toEqual({ success: false });
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledWith([
testError,
]);
});
});
});

View File

@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { ClickHouseClient, createClient } from '@clickhouse/client';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
makePageview,
makeTrackEvent,
} from 'src/engine/core-modules/analytics/utils/analytics.utils';
@Injectable()
export class ClickhouseService {
private clickhouseClient: ClickHouseClient | undefined;
constructor(
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly twentyConfigService: TwentyConfigService,
) {
if (twentyConfigService.get('ANALYTICS_ENABLED')) {
this.clickhouseClient = createClient({
url: twentyConfigService.get('CLICKHOUSE_URL'),
compression: {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
}
}
async pushEvent(
data: (
| ReturnType<typeof makeTrackEvent>
| ReturnType<typeof makePageview>
) & { userId?: string | null; workspaceId?: string | null },
) {
try {
if (!this.clickhouseClient) {
return { success: true };
}
const { type, ...rest } = data;
await this.clickhouseClient.insert({
table: type === 'page' ? 'pageview' : 'events',
values: [rest],
format: 'JSONEachRow',
});
return { success: true };
} catch (err) {
this.exceptionHandlerService.captureExceptions([err]);
return { success: false };
}
}
}

View File

@ -0,0 +1,2 @@
export type AnalyticsCommonPropertiesType = 'timestamp' | 'version';
export type IdentifierType = 'workspaceId' | 'userId';

View File

@ -0,0 +1,72 @@
import {
WEBHOOK_RESPONSE_EVENT,
WebhookResponseTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/webhook/webhook-response';
import {
SERVERLESS_FUNCTION_EXECUTED_EVENT,
ServerlessFunctionExecutedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/serverless-function/serverless-function-executed';
import {
CUSTOM_DOMAIN_DEACTIVATED_EVENT,
CustomDomainDeactivatedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-deactivated';
import {
CUSTOM_DOMAIN_ACTIVATED_EVENT,
CustomDomainActivatedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import {
WORKSPACE_ENTITY_CREATED_EVENT,
WorkspaceEntityCreatedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/workspace-entity/workspace-entity-created';
import {
USER_SIGNUP_EVENT,
UserSignupTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/user/user-signup';
import {
MONITORING_EVENT,
MonitoringTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/monitoring/monitoring';
import {
OBJECT_RECORD_CREATED_EVENT,
ObjectRecordCreatedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-created';
import {
OBJECT_RECORD_UPDATED_EVENT,
ObjectRecordUpdatedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-updated';
import {
OBJECT_RECORD_DELETED_EVENT,
ObjectRecordDeletedTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-delete';
// Define all track event names
export type TrackEventName =
| typeof CUSTOM_DOMAIN_ACTIVATED_EVENT
| typeof CUSTOM_DOMAIN_DEACTIVATED_EVENT
| typeof SERVERLESS_FUNCTION_EXECUTED_EVENT
| typeof WEBHOOK_RESPONSE_EVENT
| typeof WORKSPACE_ENTITY_CREATED_EVENT
| typeof MONITORING_EVENT
| typeof OBJECT_RECORD_CREATED_EVENT
| typeof OBJECT_RECORD_UPDATED_EVENT
| typeof OBJECT_RECORD_DELETED_EVENT
| typeof USER_SIGNUP_EVENT;
// Map event names to their corresponding event types
export interface TrackEvents {
[CUSTOM_DOMAIN_ACTIVATED_EVENT]: CustomDomainActivatedTrackEvent;
[CUSTOM_DOMAIN_DEACTIVATED_EVENT]: CustomDomainDeactivatedTrackEvent;
[SERVERLESS_FUNCTION_EXECUTED_EVENT]: ServerlessFunctionExecutedTrackEvent;
[WEBHOOK_RESPONSE_EVENT]: WebhookResponseTrackEvent;
[WORKSPACE_ENTITY_CREATED_EVENT]: WorkspaceEntityCreatedTrackEvent;
[USER_SIGNUP_EVENT]: UserSignupTrackEvent;
[MONITORING_EVENT]: MonitoringTrackEvent;
[OBJECT_RECORD_DELETED_EVENT]: ObjectRecordDeletedTrackEvent;
[OBJECT_RECORD_CREATED_EVENT]: ObjectRecordCreatedTrackEvent;
[OBJECT_RECORD_UPDATED_EVENT]: ObjectRecordUpdatedTrackEvent;
}
export type TrackEventProperties<T extends TrackEventName> =
T extends keyof TrackEvents
? TrackEvents[T]['properties']
: Record<string, unknown>;

View File

@ -0,0 +1,50 @@
import { format } from 'date-fns';
import { AnalyticsCommonPropertiesType } from 'src/engine/core-modules/analytics/types/common.type';
import {
PageviewProperties,
pageviewSchema,
} from 'src/engine/core-modules/analytics/utils/events/pageview/pageview';
import {
TrackEventName,
TrackEventProperties,
} from 'src/engine/core-modules/analytics/types/events.type';
import {
eventsRegistry,
GenericTrackEvent,
} from 'src/engine/core-modules/analytics/utils/events/track/track';
const common = (): Record<AnalyticsCommonPropertiesType, string> => ({
timestamp: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
version: '1',
});
export function makePageview(
name: string,
properties: Partial<PageviewProperties> = {},
) {
return pageviewSchema.parse({
type: 'page',
name,
...common(),
properties,
});
}
export function makeTrackEvent<T extends TrackEventName>(
event: T,
properties: TrackEventProperties<T>,
): GenericTrackEvent<T> {
const schema = eventsRegistry.get(event);
if (!schema) {
throw new Error(`Schema for event ${event} is not implemented`);
}
return schema.parse({
type: 'track',
event,
properties,
...common(),
});
}

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const baseEventSchema = z
.object({
timestamp: z.string(),
userId: z.string().nullish(),
workspaceId: z.string().nullish(),
version: z.string(),
})
.strict();

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { baseEventSchema } from 'src/engine/core-modules/analytics/utils/events/common/base-schemas';
export const pageviewSchema = baseEventSchema.extend({
type: z.literal('page'),
name: z.string(),
properties: z.object({
href: z.string(),
locale: z.string(),
pathname: z.string(),
referrer: z.string(),
sessionId: z.string(),
timeZone: z.string(),
userAgent: z.string(),
}),
});
export type PageviewProperties = z.infer<typeof pageviewSchema>['properties'];

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const CUSTOM_DOMAIN_ACTIVATED_EVENT = 'Custom Domain Activated' as const;
export const customDomainActivatedSchema = z
.object({
event: z.literal(CUSTOM_DOMAIN_ACTIVATED_EVENT),
properties: z.object({}).strict(),
})
.strict();
export type CustomDomainActivatedTrackEvent = z.infer<
typeof customDomainActivatedSchema
>;
registerEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, customDomainActivatedSchema);

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const CUSTOM_DOMAIN_DEACTIVATED_EVENT =
'Custom Domain Deactivated' as const;
export const customDomainDeactivatedSchema = z
.object({
event: z.literal(CUSTOM_DOMAIN_DEACTIVATED_EVENT),
properties: z.object({}).strict(),
})
.strict();
export type CustomDomainDeactivatedTrackEvent = z.infer<
typeof customDomainDeactivatedSchema
>;
registerEvent(CUSTOM_DOMAIN_DEACTIVATED_EVENT, customDomainDeactivatedSchema);

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const MONITORING_EVENT = 'Monitoring' as const;
export const monitoringSchema = z
.object({
event: z.literal(MONITORING_EVENT),
properties: z
.object({
eventName: z.string(),
connectedAccountId: z.string().optional(),
messageChannelId: z.string().optional(),
message: z.string().optional(),
})
.strict(),
})
.strict();
export type MonitoringTrackEvent = z.infer<typeof monitoringSchema>;
registerEvent(MONITORING_EVENT, monitoringSchema);

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const OBJECT_RECORD_CREATED_EVENT = 'Object Record Created' as const;
export const objectRecordCreatedSchema = z.object({
event: z.literal(OBJECT_RECORD_CREATED_EVENT),
properties: z.object({}).passthrough(),
});
export type ObjectRecordCreatedTrackEvent = z.infer<
typeof objectRecordCreatedSchema
>;
registerEvent(OBJECT_RECORD_CREATED_EVENT, objectRecordCreatedSchema);

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const OBJECT_RECORD_DELETED_EVENT = 'Object Record Deleted' as const;
export const objectRecordDeletedSchema = z.object({
event: z.literal(OBJECT_RECORD_DELETED_EVENT),
properties: z.object({}).passthrough(),
});
export type ObjectRecordDeletedTrackEvent = z.infer<
typeof objectRecordDeletedSchema
>;
registerEvent(OBJECT_RECORD_DELETED_EVENT, objectRecordDeletedSchema);

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const OBJECT_RECORD_UPDATED_EVENT = 'Object Record Updated' as const;
export const objectRecordUpdatedSchema = z.object({
event: z.literal(OBJECT_RECORD_UPDATED_EVENT),
properties: z.object({}).passthrough(),
});
export type ObjectRecordUpdatedTrackEvent = z.infer<
typeof objectRecordUpdatedSchema
>;
registerEvent(OBJECT_RECORD_UPDATED_EVENT, objectRecordUpdatedSchema);

View File

@ -0,0 +1,29 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const SERVERLESS_FUNCTION_EXECUTED_EVENT =
'Serverless Function Executed' as const;
export const serverlessFunctionExecutedSchema = z
.object({
event: z.literal(SERVERLESS_FUNCTION_EXECUTED_EVENT),
properties: z
.object({
duration: z.number(),
status: z.enum(['IDLE', 'SUCCESS', 'ERROR']),
errorType: z.string().optional(),
functionId: z.string(),
functionName: z.string(),
})
.strict(),
})
.strict();
export type ServerlessFunctionExecutedTrackEvent = z.infer<
typeof serverlessFunctionExecutedSchema
>;
registerEvent(
SERVERLESS_FUNCTION_EXECUTED_EVENT,
serverlessFunctionExecutedSchema,
);

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
import { baseEventSchema } from 'src/engine/core-modules/analytics/utils/events/common/base-schemas';
export const genericTrackSchema = baseEventSchema.extend({
type: z.literal('track'),
event: z.string(),
properties: z.any(),
});
export type GenericTrackEvent<E extends string = string> = {
type: 'track';
event: E;
properties: any;
timestamp: string;
version: string;
userId?: string;
workspaceId?: string;
};
export const eventsRegistry = new Map<string, z.ZodSchema<any>>();
export function registerEvent<E extends string, S extends z.ZodObject<any>>(
event: E,
schema: S,
): void {
eventsRegistry.set(event, genericTrackSchema.merge(schema));
}

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const USER_SIGNUP_EVENT = 'User Signup' as const;
export const userSignupSchema = z
.object({
event: z.literal(USER_SIGNUP_EVENT),
properties: z.object({}).strict(),
})
.strict();
export type UserSignupTrackEvent = z.infer<typeof userSignupSchema>;
registerEvent(USER_SIGNUP_EVENT, userSignupSchema);

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const WEBHOOK_RESPONSE_EVENT = 'Webhook Response' as const;
export const webhookResponseSchema = z
.object({
event: z.literal(WEBHOOK_RESPONSE_EVENT),
properties: z
.object({
status: z.number().optional(),
success: z.boolean(),
url: z.string(),
webhookId: z.string(),
eventName: z.string(),
})
.strict(),
})
.strict();
export type WebhookResponseTrackEvent = z.infer<typeof webhookResponseSchema>;
registerEvent(WEBHOOK_RESPONSE_EVENT, webhookResponseSchema);

View File

@ -0,0 +1,20 @@
import { z } from 'zod';
import { registerEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
export const WORKSPACE_ENTITY_CREATED_EVENT =
'Workspace Entity Created' as const;
export const workspaceEntityCreatedSchema = z
.object({
event: z.literal(WORKSPACE_ENTITY_CREATED_EVENT),
properties: z.object({
name: z.string(),
}),
})
.strict();
export type WorkspaceEntityCreatedTrackEvent = z.infer<
typeof workspaceEntityCreatedSchema
>;
registerEvent(WORKSPACE_ENTITY_CREATED_EVENT, workspaceEntityCreatedSchema);

View File

@ -0,0 +1,54 @@
import { GenericTrackEvent } from 'src/engine/core-modules/analytics/utils/events/track/track';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-created';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-deactivated';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-updated';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/object-record/object-record-delete';
export const fixtures: Array<GenericTrackEvent> = [
{
type: 'track',
event: CUSTOM_DOMAIN_ACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
{
type: 'track',
event: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
{
type: 'track',
event: OBJECT_RECORD_CREATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
{
type: 'track',
event: OBJECT_RECORD_UPDATED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
{
type: 'track',
event: OBJECT_RECORD_DELETED_EVENT,
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-9e3b-46d4-a556-88b9ddc2b034',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
properties: {},
},
];

View File

@ -24,6 +24,8 @@ import { handleException } from 'src/engine/core-modules/exception-handler/http-
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
@Controller('cloudflare')
@UseFilters(AuthRestApiExceptionFilter)
@ -34,6 +36,7 @@ export class CloudflareController {
private readonly domainManagerService: DomainManagerService,
private readonly customDomainService: CustomDomainService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly analyticsService: AnalyticsService,
) {}
@Post('custom-hostname-webhooks')
@ -57,6 +60,10 @@ export class CloudflareController {
if (!workspace) return;
const analytics = this.analyticsService.createAnalyticsContext({
workspaceId: workspace.id,
});
const customDomainDetails =
await this.customDomainService.getCustomDomainDetails(
req.body.data.data.hostname,
@ -83,6 +90,8 @@ export class CloudflareController {
...workspace,
...workspaceUpdated,
});
await analytics.track(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
}
return res.status(200).send();

View File

@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Request, Response } from 'express';
import { Repository } from 'typeorm';
import { AnalyticsContextMock } from 'test/utils/analytics-context.mock';
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
@ -12,6 +13,7 @@ import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handl
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
describe('CloudflareController - customHostnameWebhooks', () => {
let controller: CloudflareController;
@ -61,6 +63,12 @@ describe('CloudflareController - customHostnameWebhooks', () => {
get: jest.fn(),
},
},
{
provide: AnalyticsService,
useValue: {
createAnalyticsContext: AnalyticsContextMock,
},
},
],
}).compile();

View File

@ -5,9 +5,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module';
@Module({
imports: [TypeOrmModule.forFeature([Workspace], 'core')],
imports: [AnalyticsModule, TypeOrmModule.forFeature([Workspace], 'core')],
providers: [DomainManagerService, CustomDomainService],
exports: [DomainManagerService, CustomDomainService],
controllers: [CloudflareController],

View File

@ -2,10 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import Cloudflare from 'cloudflare';
import { CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { AnalyticsContextMock } from 'test/utils/analytics-context.mock';
import { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('cloudflare');
@ -25,6 +27,12 @@ describe('CustomDomainService', () => {
get: jest.fn(),
},
},
{
provide: AnalyticsService,
useValue: {
createAnalyticsContext: AnalyticsContextMock,
},
},
{
provide: DomainManagerService,
useValue: {

View File

@ -463,6 +463,18 @@ export class ConfigVariables {
@IsBoolean()
ANALYTICS_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AnalyticsConfig,
description: 'Clickhouse host for analytics',
})
@IsOptional()
@IsUrl({
require_tld: false,
allow_underscores: true,
})
@ValidateIf((env) => env.ANALYTICS_ENABLED === true)
CLICKHOUSE_URL: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging,
description: 'Enable or disable telemetry logging',

View File

@ -5,7 +5,6 @@ import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@ -21,6 +20,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
describe('UserWorkspaceService', () => {
let service: UserWorkspaceService;

View File

@ -20,7 +20,7 @@ import { In, Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { AnalyticsService } from 'src/engine/core-modules/analytics/analytics.service';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import {
AuthException,
AuthExceptionCode,

View File

@ -25,6 +25,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
describe('WorkspaceService', () => {
let service: WorkspaceService;
@ -74,8 +75,13 @@ describe('WorkspaceService', () => {
deleteSubscriptions: jest.fn(),
},
},
{
provide: AnalyticsService,
useValue: {
createAnalyticsContext: jest.fn(),
},
},
...[
WorkspaceManagerService,
WorkspaceManagerService,
UserWorkspaceService,
UserService,

View File

@ -44,6 +44,9 @@ import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
import { AnalyticsService } from 'src/engine/core-modules/analytics/services/analytics.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/analytics/utils/events/track/custom-domain/custom-domain-deactivated';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -67,6 +70,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly domainManagerService: DomainManagerService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly permissionsService: PermissionsService,
private readonly analyticsService: AnalyticsService,
private readonly customDomainService: CustomDomainService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
@ -413,6 +417,17 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
workspace.isCustomDomainEnabled = isCustomDomainWorking;
await this.workspaceRepository.save(workspace);
const analytics = this.analyticsService.createAnalyticsContext({
workspaceId: workspace.id,
});
analytics.track(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
{},
);
}
return customDomainDetails;

View File

@ -24,6 +24,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
@ -54,6 +55,7 @@ import { WorkspaceService } from './services/workspace.service';
TypeORMModule,
PermissionsModule,
WorkspaceCacheStorageModule,
AnalyticsModule,
RoleModule,
],
services: [WorkspaceService],