Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions redisinsight/api/src/constants/app-events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum AppAnalyticsEvents {
Initialize = 'analytics.initialize',
Track = 'analytics.track',
Page = 'analytics.page',
}

export enum AppRedisInstanceEvents {
Expand Down
47 changes: 47 additions & 0 deletions redisinsight/api/src/modules/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
Body,
Controller, Post, UsePipes, ValidationPipe,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator';
import { SendEventDto } from 'src/modules/analytics/dto/analytics.dto';
import { AnalyticsService } from 'src/modules/analytics/analytics.service';

@ApiTags('Analytics')
@Controller('analytics')
@UsePipes(new ValidationPipe({ transform: true }))
export class AnalyticsController {
constructor(private service: AnalyticsService) {}

@Post('send-event')
@ApiEndpoint({
description: 'Send telemetry event',
statusCode: 204,
responses: [
{
status: 204,
},
],
})
async sendEvent(
@Body() dto: SendEventDto,
): Promise<void> {
return this.service.sendEvent(dto);
}

@Post('send-page')
@ApiEndpoint({
description: 'Send telemetry page',
statusCode: 204,
responses: [
{
status: 204,
},
],
})
async sendPage(
@Body() dto: SendEventDto,
): Promise<void> {
return this.service.sendPage(dto);
}
}
4 changes: 4 additions & 0 deletions redisinsight/api/src/modules/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { AnalyticsService } from 'src/modules/analytics/analytics.service';
import { AnalyticsController } from './analytics.controller';

@Module({
providers: [
AnalyticsService,
],
controllers: [
AnalyticsController,
],
})
export class AnalyticsModule {}
115 changes: 115 additions & 0 deletions redisinsight/api/src/modules/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
} from './analytics.service';

let mockAnalyticsTrack;
let mockAnalyticsPage;
jest.mock(
'analytics-node',
() => jest.fn()
.mockImplementation(() => ({
track: mockAnalyticsTrack,
page: mockAnalyticsPage,
})),
);

Expand Down Expand Up @@ -121,11 +123,124 @@ describe('AnalyticsService', () => {
nonTracking: true,
});

expect(mockAnalyticsTrack).toHaveBeenCalledWith({
anonymousId: NON_TRACKING_ANONYMOUS_ID,
integrations: { Amplitude: { session_id: sessionId } },
event: TelemetryEvents.ApplicationStarted,
properties: {
anonymousId: mockAnonymousId,
buildType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
appVersion: mockAppVersion,
},
});
});
it('should send event for non tracking with regular payload', async () => {
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);

await service.sendEvent({
event: TelemetryEvents.ApplicationStarted,
eventData: {},
nonTracking: true,
});

expect(mockAnalyticsTrack).toHaveBeenCalledWith({
anonymousId: mockAnonymousId,
integrations: { Amplitude: { session_id: sessionId } },
event: TelemetryEvents.ApplicationStarted,
properties: {
anonymousId: undefined,
buildType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
appVersion: mockAppVersion,
},
});
});
});

describe('sendPage', () => {
beforeEach(() => {
mockAnalyticsPage = jest.fn();
service.initialize({
anonymousId: mockAnonymousId,
sessionId,
appType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
appVersion: mockAppVersion,
});
});
it('should send page with anonymousId if permission are granted', async () => {
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);

await service.sendPage({
event: TelemetryEvents.ApplicationStarted,
eventData: {},
nonTracking: false,
});

expect(mockAnalyticsPage).toHaveBeenCalledWith({
anonymousId: mockAnonymousId,
integrations: { Amplitude: { session_id: sessionId } },
name: TelemetryEvents.ApplicationStarted,
properties: {
buildType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
appVersion: mockAppVersion,
},
});
});
it('should not send page if permission are not granted', async () => {
settingsService.getAppSettings.mockResolvedValue(mockAppSettingsWithoutPermissions);

await service.sendPage({
event: 'SOME_EVENT',
eventData: {},
nonTracking: false,
});

expect(mockAnalyticsPage).not.toHaveBeenCalled();
});
it('should send page for non tracking events event if permission are not granted', async () => {
settingsService.getAppSettings.mockResolvedValue(mockAppSettingsWithoutPermissions);

await service.sendPage({
event: TelemetryEvents.ApplicationStarted,
eventData: {},
nonTracking: true,
});

expect(mockAnalyticsPage).toHaveBeenCalledWith({
anonymousId: NON_TRACKING_ANONYMOUS_ID,
integrations: { Amplitude: { session_id: sessionId } },
name: TelemetryEvents.ApplicationStarted,
properties: {
anonymousId: mockAnonymousId,
buildType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
appVersion: mockAppVersion,
},
});
});
it('should send page for non tracking events with regular payload', async () => {
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);

await service.sendPage({
event: TelemetryEvents.ApplicationStarted,
eventData: {},
nonTracking: true,
});

expect(mockAnalyticsPage).toHaveBeenCalledWith({
anonymousId: mockAnonymousId,
integrations: { Amplitude: { session_id: sessionId } },
name: TelemetryEvents.ApplicationStarted,
properties: {
anonymousId: undefined,
buildType: AppType.Electron,
controlNumber: mockControlNumber,
controlGroup: mockControlGroup,
Expand Down
58 changes: 49 additions & 9 deletions redisinsight/api/src/modules/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AppAnalyticsEvents } from 'src/constants';
import config from 'src/utils/config';
import { SettingsService } from 'src/modules/settings/settings.service';

export const NON_TRACKING_ANONYMOUS_ID = 'UNSET';
export const NON_TRACKING_ANONYMOUS_ID = '00000000-0000-0000-0000-000000000001';
const ANALYTICS_CONFIG = config.get('analytics');

export interface ITelemetryEvent {
Expand Down Expand Up @@ -73,21 +73,52 @@ export class AnalyticsService {
// The `nonTracking` argument can be set to True to mark an event that doesn't track the specific
// user in any way. When `nonTracking` is True, the event is sent regardless of whether the user's permission
// for analytics is granted or not.
// If permissions not granted anonymousId includes "UNSET" value without any user identifiers.
// If permissions not granted
// anonymousId will includes "00000000-0000-0000-0000-000000000001" value without any user identifiers.
const { event, eventData, nonTracking } = payload;
const isAnalyticsGranted = !!get(
// todo: define how to fetch userId?
await this.settingsService.getAppSettings('1'),
'agreements.analytics',
false,
);
const isAnalyticsGranted = await this.checkIsAnalyticsGranted();

if (isAnalyticsGranted || nonTracking) {
this.analytics.track({
anonymousId: this.anonymousId,
anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId,
integrations: { Amplitude: { session_id: this.sessionId } },
event,
properties: {
...eventData,
anonymousId: !isAnalyticsGranted && nonTracking ? this.anonymousId : undefined,
buildType: this.appType,
controlNumber: this.controlNumber,
controlGroup: this.controlGroup,
appVersion: this.appVersion,
},
});
}
} catch (e) {
// continue regardless of error
}
}

@OnEvent(AppAnalyticsEvents.Page)
async sendPage(payload: ITelemetryEvent) {
try {
// The event is reported only if the user's permission is granted.
// The anonymousId is also sent along with the event.
//
// The `nonTracking` argument can be set to True to mark an event that doesn't track the specific
// user in any way. When `nonTracking` is True, the event is sent regardless of whether the user's permission
// for analytics is granted or not.
// If permissions not granted anonymousId includes "UNSET" value without any user identifiers.
const { event, eventData, nonTracking } = payload;
const isAnalyticsGranted = await this.checkIsAnalyticsGranted();

if (isAnalyticsGranted || nonTracking) {
this.analytics.page({
name: event,
anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId,
integrations: { Amplitude: { session_id: this.sessionId } },
properties: {
...eventData,
anonymousId: !isAnalyticsGranted && nonTracking ? this.anonymousId : undefined,
buildType: this.appType,
controlNumber: this.controlNumber,
controlGroup: this.controlGroup,
Expand All @@ -99,4 +130,13 @@ export class AnalyticsService {
// continue regardless of error
}
}

private async checkIsAnalyticsGranted() {
return !!get(
// todo: define how to fetch userId?
await this.settingsService.getAppSettings('1'),
'agreements.analytics',
false,
);
}
}
39 changes: 39 additions & 0 deletions redisinsight/api/src/modules/analytics/dto/analytics.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';

export class SendEventDto {
@ApiProperty({
description: 'Telemetry event name.',
type: String,
example: 'APPLICATION_UPDATED',
})
@IsDefined()
@IsNotEmpty()
@IsString()
event: string;

@ApiPropertyOptional({
description: 'Telemetry event data.',
type: Object,
example: { length: 5 },
})
@IsOptional()
@ValidateNested()
eventData: Object = {};

@ApiPropertyOptional({
description: 'Does not track the specific user in any way?',
type: Boolean,
example: false,
})
@IsOptional()
@IsBoolean()
nonTracking: boolean = false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
describe,
deps,
Joi,
generateInvalidDataTestCases,
validateInvalidDataTestCase,
getMainCheckFn, _,
} from '../deps';
const { server, request, constants } = deps;

// endpoint to test
const endpoint = () =>
request(server).post('/analytics/send-event');

// input data schema
const dataSchema = Joi.object({
event: Joi.string().required(),
eventData: Joi.object().allow(null),
}).strict();

const validInputData = {
event: constants.TEST_ANALYTICS_EVENT,
eventData: constants.TEST_ANALYTICS_EVENT_DATA,
};

const mainCheckFn = getMainCheckFn(endpoint);

describe('POST /analytics/send-event', () => {
describe('Main', () => {
describe('Validation', () => {
generateInvalidDataTestCases(dataSchema, validInputData).map(
validateInvalidDataTestCase(endpoint, dataSchema),
);
});

describe('Common', () => {
[
{
name: 'Should send telemetry event',
data: {
event: constants.TEST_ANALYTICS_EVENT,
eventData: constants.TEST_ANALYTICS_EVENT_DATA,
},
statusCode: 204,
},
].map(mainCheckFn);
});
});
});
Loading