Skip to content

Commit bb30d7a

Browse files
authored
Merge pull request #2578 from RedisInsight/feature/RI-4865_Send_frontend_events_in_batches
#RI-4865 - send frontend events in batches
2 parents 0bd19dc + 6aeb01d commit bb30d7a

File tree

38 files changed

+423
-533
lines changed

38 files changed

+423
-533
lines changed

redisinsight/api/src/constants/app-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum AppAnalyticsEvents {
22
Initialize = 'analytics.initialize',
33
Track = 'analytics.track',
4+
Page = 'analytics.page',
45
}
56

67
export enum AppRedisInstanceEvents {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
Body,
3+
Controller, Post, UsePipes, ValidationPipe,
4+
} from '@nestjs/common';
5+
import { ApiTags } from '@nestjs/swagger';
6+
import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator';
7+
import { SendEventDto } from 'src/modules/analytics/dto/analytics.dto';
8+
import { AnalyticsService } from 'src/modules/analytics/analytics.service';
9+
10+
@ApiTags('Analytics')
11+
@Controller('analytics')
12+
@UsePipes(new ValidationPipe({ transform: true }))
13+
export class AnalyticsController {
14+
constructor(private service: AnalyticsService) {}
15+
16+
@Post('send-event')
17+
@ApiEndpoint({
18+
description: 'Send telemetry event',
19+
statusCode: 204,
20+
responses: [
21+
{
22+
status: 204,
23+
},
24+
],
25+
})
26+
async sendEvent(
27+
@Body() dto: SendEventDto,
28+
): Promise<void> {
29+
return this.service.sendEvent(dto);
30+
}
31+
32+
@Post('send-page')
33+
@ApiEndpoint({
34+
description: 'Send telemetry page',
35+
statusCode: 204,
36+
responses: [
37+
{
38+
status: 204,
39+
},
40+
],
41+
})
42+
async sendPage(
43+
@Body() dto: SendEventDto,
44+
): Promise<void> {
45+
return this.service.sendPage(dto);
46+
}
47+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { Module } from '@nestjs/common';
22
import { AnalyticsService } from 'src/modules/analytics/analytics.service';
3+
import { AnalyticsController } from './analytics.controller';
34

45
@Module({
56
providers: [
67
AnalyticsService,
78
],
9+
controllers: [
10+
AnalyticsController,
11+
],
812
})
913
export class AnalyticsModule {}

redisinsight/api/src/modules/analytics/analytics.service.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
} from './analytics.service';
1515

1616
let mockAnalyticsTrack;
17+
let mockAnalyticsPage;
1718
jest.mock(
1819
'analytics-node',
1920
() => jest.fn()
2021
.mockImplementation(() => ({
2122
track: mockAnalyticsTrack,
23+
page: mockAnalyticsPage,
2224
})),
2325
);
2426

@@ -121,11 +123,124 @@ describe('AnalyticsService', () => {
121123
nonTracking: true,
122124
});
123125

126+
expect(mockAnalyticsTrack).toHaveBeenCalledWith({
127+
anonymousId: NON_TRACKING_ANONYMOUS_ID,
128+
integrations: { Amplitude: { session_id: sessionId } },
129+
event: TelemetryEvents.ApplicationStarted,
130+
properties: {
131+
anonymousId: mockAnonymousId,
132+
buildType: AppType.Electron,
133+
controlNumber: mockControlNumber,
134+
controlGroup: mockControlGroup,
135+
appVersion: mockAppVersion,
136+
},
137+
});
138+
});
139+
it('should send event for non tracking with regular payload', async () => {
140+
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);
141+
142+
await service.sendEvent({
143+
event: TelemetryEvents.ApplicationStarted,
144+
eventData: {},
145+
nonTracking: true,
146+
});
147+
124148
expect(mockAnalyticsTrack).toHaveBeenCalledWith({
125149
anonymousId: mockAnonymousId,
126150
integrations: { Amplitude: { session_id: sessionId } },
127151
event: TelemetryEvents.ApplicationStarted,
128152
properties: {
153+
anonymousId: undefined,
154+
buildType: AppType.Electron,
155+
controlNumber: mockControlNumber,
156+
controlGroup: mockControlGroup,
157+
appVersion: mockAppVersion,
158+
},
159+
});
160+
});
161+
});
162+
163+
describe('sendPage', () => {
164+
beforeEach(() => {
165+
mockAnalyticsPage = jest.fn();
166+
service.initialize({
167+
anonymousId: mockAnonymousId,
168+
sessionId,
169+
appType: AppType.Electron,
170+
controlNumber: mockControlNumber,
171+
controlGroup: mockControlGroup,
172+
appVersion: mockAppVersion,
173+
});
174+
});
175+
it('should send page with anonymousId if permission are granted', async () => {
176+
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);
177+
178+
await service.sendPage({
179+
event: TelemetryEvents.ApplicationStarted,
180+
eventData: {},
181+
nonTracking: false,
182+
});
183+
184+
expect(mockAnalyticsPage).toHaveBeenCalledWith({
185+
anonymousId: mockAnonymousId,
186+
integrations: { Amplitude: { session_id: sessionId } },
187+
name: TelemetryEvents.ApplicationStarted,
188+
properties: {
189+
buildType: AppType.Electron,
190+
controlNumber: mockControlNumber,
191+
controlGroup: mockControlGroup,
192+
appVersion: mockAppVersion,
193+
},
194+
});
195+
});
196+
it('should not send page if permission are not granted', async () => {
197+
settingsService.getAppSettings.mockResolvedValue(mockAppSettingsWithoutPermissions);
198+
199+
await service.sendPage({
200+
event: 'SOME_EVENT',
201+
eventData: {},
202+
nonTracking: false,
203+
});
204+
205+
expect(mockAnalyticsPage).not.toHaveBeenCalled();
206+
});
207+
it('should send page for non tracking events event if permission are not granted', async () => {
208+
settingsService.getAppSettings.mockResolvedValue(mockAppSettingsWithoutPermissions);
209+
210+
await service.sendPage({
211+
event: TelemetryEvents.ApplicationStarted,
212+
eventData: {},
213+
nonTracking: true,
214+
});
215+
216+
expect(mockAnalyticsPage).toHaveBeenCalledWith({
217+
anonymousId: NON_TRACKING_ANONYMOUS_ID,
218+
integrations: { Amplitude: { session_id: sessionId } },
219+
name: TelemetryEvents.ApplicationStarted,
220+
properties: {
221+
anonymousId: mockAnonymousId,
222+
buildType: AppType.Electron,
223+
controlNumber: mockControlNumber,
224+
controlGroup: mockControlGroup,
225+
appVersion: mockAppVersion,
226+
},
227+
});
228+
});
229+
it('should send page for non tracking events with regular payload', async () => {
230+
settingsService.getAppSettings.mockResolvedValue(mockAppSettings);
231+
232+
await service.sendPage({
233+
event: TelemetryEvents.ApplicationStarted,
234+
eventData: {},
235+
nonTracking: true,
236+
});
237+
238+
expect(mockAnalyticsPage).toHaveBeenCalledWith({
239+
anonymousId: mockAnonymousId,
240+
integrations: { Amplitude: { session_id: sessionId } },
241+
name: TelemetryEvents.ApplicationStarted,
242+
properties: {
243+
anonymousId: undefined,
129244
buildType: AppType.Electron,
130245
controlNumber: mockControlNumber,
131246
controlGroup: mockControlGroup,

redisinsight/api/src/modules/analytics/analytics.service.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { AppAnalyticsEvents } from 'src/constants';
66
import config from 'src/utils/config';
77
import { SettingsService } from 'src/modules/settings/settings.service';
88

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

1212
export interface ITelemetryEvent {
@@ -73,21 +73,52 @@ export class AnalyticsService {
7373
// The `nonTracking` argument can be set to True to mark an event that doesn't track the specific
7474
// user in any way. When `nonTracking` is True, the event is sent regardless of whether the user's permission
7575
// for analytics is granted or not.
76-
// If permissions not granted anonymousId includes "UNSET" value without any user identifiers.
76+
// If permissions not granted
77+
// anonymousId will includes "00000000-0000-0000-0000-000000000001" value without any user identifiers.
7778
const { event, eventData, nonTracking } = payload;
78-
const isAnalyticsGranted = !!get(
79-
// todo: define how to fetch userId?
80-
await this.settingsService.getAppSettings('1'),
81-
'agreements.analytics',
82-
false,
83-
);
79+
const isAnalyticsGranted = await this.checkIsAnalyticsGranted();
80+
8481
if (isAnalyticsGranted || nonTracking) {
8582
this.analytics.track({
86-
anonymousId: this.anonymousId,
83+
anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId,
8784
integrations: { Amplitude: { session_id: this.sessionId } },
8885
event,
8986
properties: {
9087
...eventData,
88+
anonymousId: !isAnalyticsGranted && nonTracking ? this.anonymousId : undefined,
89+
buildType: this.appType,
90+
controlNumber: this.controlNumber,
91+
controlGroup: this.controlGroup,
92+
appVersion: this.appVersion,
93+
},
94+
});
95+
}
96+
} catch (e) {
97+
// continue regardless of error
98+
}
99+
}
100+
101+
@OnEvent(AppAnalyticsEvents.Page)
102+
async sendPage(payload: ITelemetryEvent) {
103+
try {
104+
// The event is reported only if the user's permission is granted.
105+
// The anonymousId is also sent along with the event.
106+
//
107+
// The `nonTracking` argument can be set to True to mark an event that doesn't track the specific
108+
// user in any way. When `nonTracking` is True, the event is sent regardless of whether the user's permission
109+
// for analytics is granted or not.
110+
// If permissions not granted anonymousId includes "UNSET" value without any user identifiers.
111+
const { event, eventData, nonTracking } = payload;
112+
const isAnalyticsGranted = await this.checkIsAnalyticsGranted();
113+
114+
if (isAnalyticsGranted || nonTracking) {
115+
this.analytics.page({
116+
name: event,
117+
anonymousId: !isAnalyticsGranted && nonTracking ? NON_TRACKING_ANONYMOUS_ID : this.anonymousId,
118+
integrations: { Amplitude: { session_id: this.sessionId } },
119+
properties: {
120+
...eventData,
121+
anonymousId: !isAnalyticsGranted && nonTracking ? this.anonymousId : undefined,
91122
buildType: this.appType,
92123
controlNumber: this.controlNumber,
93124
controlGroup: this.controlGroup,
@@ -99,4 +130,13 @@ export class AnalyticsService {
99130
// continue regardless of error
100131
}
101132
}
133+
134+
private async checkIsAnalyticsGranted() {
135+
return !!get(
136+
// todo: define how to fetch userId?
137+
await this.settingsService.getAppSettings('1'),
138+
'agreements.analytics',
139+
false,
140+
);
141+
}
102142
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import {
3+
IsBoolean,
4+
IsDefined,
5+
IsNotEmpty,
6+
IsOptional,
7+
IsString,
8+
ValidateNested,
9+
} from 'class-validator';
10+
11+
export class SendEventDto {
12+
@ApiProperty({
13+
description: 'Telemetry event name.',
14+
type: String,
15+
example: 'APPLICATION_UPDATED',
16+
})
17+
@IsDefined()
18+
@IsNotEmpty()
19+
@IsString()
20+
event: string;
21+
22+
@ApiPropertyOptional({
23+
description: 'Telemetry event data.',
24+
type: Object,
25+
example: { length: 5 },
26+
})
27+
@IsOptional()
28+
@ValidateNested()
29+
eventData: Object = {};
30+
31+
@ApiPropertyOptional({
32+
description: 'Does not track the specific user in any way?',
33+
type: Boolean,
34+
example: false,
35+
})
36+
@IsOptional()
37+
@IsBoolean()
38+
nonTracking: boolean = false;
39+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
describe,
3+
deps,
4+
Joi,
5+
generateInvalidDataTestCases,
6+
validateInvalidDataTestCase,
7+
getMainCheckFn, _,
8+
} from '../deps';
9+
const { server, request, constants } = deps;
10+
11+
// endpoint to test
12+
const endpoint = () =>
13+
request(server).post('/analytics/send-event');
14+
15+
// input data schema
16+
const dataSchema = Joi.object({
17+
event: Joi.string().required(),
18+
eventData: Joi.object().allow(null),
19+
}).strict();
20+
21+
const validInputData = {
22+
event: constants.TEST_ANALYTICS_EVENT,
23+
eventData: constants.TEST_ANALYTICS_EVENT_DATA,
24+
};
25+
26+
const mainCheckFn = getMainCheckFn(endpoint);
27+
28+
describe('POST /analytics/send-event', () => {
29+
describe('Main', () => {
30+
describe('Validation', () => {
31+
generateInvalidDataTestCases(dataSchema, validInputData).map(
32+
validateInvalidDataTestCase(endpoint, dataSchema),
33+
);
34+
});
35+
36+
describe('Common', () => {
37+
[
38+
{
39+
name: 'Should send telemetry event',
40+
data: {
41+
event: constants.TEST_ANALYTICS_EVENT,
42+
eventData: constants.TEST_ANALYTICS_EVENT_DATA,
43+
},
44+
statusCode: 204,
45+
},
46+
].map(mainCheckFn);
47+
});
48+
});
49+
});

0 commit comments

Comments
 (0)