Skip to content

Commit 728cc31

Browse files
feat(adoption-insights): add grouping configuration in insights endpoints (#488)
1 parent 46ec84f commit 728cc31

11 files changed

Lines changed: 345 additions & 49 deletions

File tree

workspaces/adoption-insights/plugins/adoption-insights-backend/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ If you want to run the entire project, including the frontend, run `yarn dev` fr
5656
| `end_date` | string (YYYY-MM-DD) | Yes | Fetch events up to this date. |
5757
| `limit` | integer | No | Limit the number of events returned (default: `3`). |
5858
| `kind` | string | No | Filter the entities by kind. |
59+
| `grouping` | string | No | Group API endpoint `(active_users,top_plugins and top_searches)` response by `hourly`, `daily`, `weekly`, and `monthly`. |
5960
| `format` | string | No | Response format, either `json` (default) or `csv`. |
6061

6162
## Example Request

workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,4 +267,167 @@ describe('BaseAdapter', () => {
267267
expect(result).toEqual({ data: usersCount });
268268
});
269269
});
270+
271+
describe('modifyDateInObject', () => {
272+
it('should modify the date in the given object', async () => {
273+
const object = {
274+
date: '2025-03-02 23:30:00',
275+
count: 100,
276+
};
277+
const db = new PostgresAdapter(mockKnex, logger);
278+
expect(db.modifyDateInObject(object)).toEqual({
279+
count: 100,
280+
date: '2025-03-02 23:30:00 UTC',
281+
});
282+
});
283+
it('should return the original object if the date is not present in the given object', async () => {
284+
const object = {
285+
count: 100,
286+
grouping: 'daily',
287+
};
288+
const db = new PostgresAdapter(mockKnex, logger);
289+
expect(db.modifyDateInObject(object)).toEqual(object);
290+
});
291+
});
292+
293+
describe('getResponseData', () => {
294+
it('should return the object wrapped in data and date should be converted to timestamp', async () => {
295+
const object = [
296+
{
297+
date: '2025-03-02 23:30:00',
298+
count: 100,
299+
},
300+
];
301+
const db = new PostgresAdapter(mockKnex, logger);
302+
expect(db.getResponseData(object)).toEqual({
303+
data: [
304+
{
305+
...object[0],
306+
date: '2025-03-02 23:30:00 UTC',
307+
},
308+
],
309+
});
310+
});
311+
312+
it('should handle the custom path', async () => {
313+
jest
314+
.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions')
315+
.mockReturnValue({
316+
timeZone: 'Asia/Kolkata',
317+
} as Intl.ResolvedDateTimeFormatOptions);
318+
const object = [
319+
{
320+
trend: [
321+
{
322+
date: '2025-03-02T18:00:00.000Z',
323+
count: 100,
324+
},
325+
],
326+
},
327+
];
328+
const db = new PostgresAdapter(mockKnex, logger);
329+
expect(db.getResponseData(object, 'trend')).toEqual({
330+
data: [
331+
{
332+
trend: [
333+
{
334+
...object[0].trend[0],
335+
date: '2025-03-02 23:30:00 GMT+5:30',
336+
},
337+
],
338+
},
339+
],
340+
});
341+
});
342+
});
343+
344+
describe('getResponseWithGrouping', () => {
345+
it('should return data and grouping strategy information', () => {
346+
const data = [
347+
{
348+
date: '2025-03-02T18:00:00.000Z',
349+
count: 1,
350+
},
351+
];
352+
353+
const db = new PostgresAdapter(mockKnex, logger);
354+
db.setFilters({
355+
start_date: new Date('2025-03-02').toISOString(),
356+
end_date: new Date('2025-03-05').toISOString(),
357+
});
358+
expect(db.getResponseWithGrouping(data)).toEqual({
359+
grouping: 'daily',
360+
data,
361+
});
362+
});
363+
364+
it('should honour the grouping strategy passed by the user', () => {
365+
const data = [
366+
{
367+
date: '2025-03-02T18:00:00.000Z',
368+
count: 1,
369+
},
370+
];
371+
372+
const db = new PostgresAdapter(mockKnex, logger);
373+
db.setFilters({
374+
start_date: new Date('2025-03-02').toISOString(),
375+
end_date: new Date('2025-03-05').toISOString(),
376+
grouping: 'hourly',
377+
});
378+
expect(db.getResponseWithGrouping(data)).toEqual({
379+
grouping: 'hourly',
380+
data: [
381+
{
382+
...data[0],
383+
date: '2025-03-02 23:30:00 GMT+5:30',
384+
},
385+
],
386+
});
387+
});
388+
389+
it('should work with the custom path', () => {
390+
const data = [
391+
{
392+
trend: [
393+
{
394+
date: '2025-03-02T18:00:00.000Z',
395+
count: 1,
396+
},
397+
],
398+
},
399+
];
400+
401+
const db = new PostgresAdapter(mockKnex, logger);
402+
db.setFilters({
403+
start_date: new Date('2025-03-02').toISOString(),
404+
end_date: new Date('2025-03-05').toISOString(),
405+
grouping: 'hourly',
406+
});
407+
408+
expect(db.getResponseWithGrouping(data, 'trend')).toEqual({
409+
grouping: 'hourly',
410+
data: [
411+
{
412+
trend: [
413+
{
414+
...data[0].trend[0],
415+
date: '2025-03-02 23:30:00 GMT+5:30',
416+
},
417+
],
418+
},
419+
],
420+
});
421+
});
422+
});
423+
424+
describe('ensureFiltersSet', () => {
425+
it('should throw error if the filters are not', () => {
426+
const db = new PostgresAdapter(mockKnex, logger);
427+
428+
expect(() => db.ensureFiltersSet()).toThrow(
429+
'Filters must be set using setFilters() before calling methods',
430+
);
431+
});
432+
});
270433
});

workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.ts

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import { Knex } from 'knex';
1717
import { Filters, EventDatabase, UserConfig } from '../event-database';
1818
import { Event } from '../../models/Event';
1919
import { LoggerService } from '@backstage/backend-plugin-api';
20+
import {
21+
DailyUser,
22+
Grouping,
23+
ResponseData,
24+
ResponseWithGrouping,
25+
} from '../../types/event';
26+
import { convertToLocalTimezone } from '../../utils/date';
2027

2128
export abstract class BaseDatabaseAdapter implements EventDatabase {
2229
protected db: Knex;
@@ -129,14 +136,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
129136
.groupBy('ge.date')
130137
.orderBy('ge.date');
131138

132-
return query.then(data => {
133-
return {
134-
...(data.length > 0
135-
? { grouping: this.getDynamicDateGrouping(true) }
136-
: {}),
137-
data,
138-
};
139-
});
139+
return query.then(data => this.getResponseWithGrouping<DailyUser[]>(data));
140140
}
141141

142142
async getUsers(): Promise<Knex.QueryBuilder> {
@@ -156,9 +156,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
156156
return query.then(result => {
157157
const { licensedUsers } = this.config!;
158158
result[0] = { ...result[0], licensed_users: licensedUsers } as any;
159-
return {
160-
data: result,
161-
};
159+
return this.getResponseData(result);
162160
});
163161
}
164162

@@ -182,7 +180,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
182180
.orderBy('count', 'desc')
183181
.limit(Number(limit) || 3);
184182

185-
return query.then(data => ({ data }));
183+
return query.then(data => this.getResponseData(data, 'last_used'));
186184
}
187185

188186
async getTopSearches(): Promise<Knex.QueryBuilder> {
@@ -200,7 +198,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
200198
.orderBy('date', 'asc')
201199
.limit(Number(limit) || 3);
202200

203-
return query.then(data => ({ data }));
201+
return query.then(data => this.getResponseWithGrouping(data));
204202
}
205203

206204
async getTopTechDocsViews(): Promise<Knex.QueryBuilder> {
@@ -221,7 +219,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
221219
.groupByRaw('entityRef')
222220
.limit(Number(limit) || 3);
223221

224-
return query.then(data => ({ data }));
222+
return query.then(data => this.getResponseData(data, 'last_used'));
225223
}
226224

227225
async getTopCatalogEntitiesViews(): Promise<Knex.QueryBuilder> {
@@ -248,7 +246,7 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
248246
if (kind) {
249247
query.andWhere(db.raw(`attributes->>'kind' = ?`, [kind]));
250248
}
251-
return query.then(data => ({ data }));
249+
return query.then(data => this.getResponseData(data, 'last_used'));
252250
}
253251

254252
async getTopPluginViews(): Promise<Knex.QueryBuilder> {
@@ -291,8 +289,8 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
291289
'plugin_id',
292290
db.raw(`
293291
json(${this.getJsonAggregationQuery('date', 'count')}) AS trend,
294-
(SELECT count FROM trend_data td WHERE td.plugin_id = t.plugin_id ORDER BY date LIMIT 1) AS first_count,
295-
(SELECT count FROM trend_data td WHERE td.plugin_id = t.plugin_id ORDER BY date DESC LIMIT 1) AS last_count
292+
COALESCE((SELECT count FROM trend_data td WHERE td.plugin_id = t.plugin_id ORDER BY date LIMIT 1),0) AS first_count,
293+
COALESCE((SELECT count FROM trend_data td WHERE td.plugin_id = t.plugin_id ORDER BY date DESC LIMIT 1),0) AS last_count
296294
`),
297295
])
298296
.from('trend_data AS t')
@@ -320,20 +318,20 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
320318
.orderBy('p.visit_count', 'desc')
321319
.limit(limit);
322320

323-
return query.then(data => ({
324-
...(data.length > 0
325-
? { grouping: this.getDynamicDateGrouping(true) }
326-
: {}),
327-
data: this.transformJson(data, 'trend'),
328-
}));
321+
return query.then(data => {
322+
return this.getResponseWithGrouping(
323+
this.transformJson(data, 'trend'),
324+
'trend',
325+
);
326+
});
329327
}
330328

331329
abstract getDate(): string;
332330
abstract getLastUsedDate(): string;
333331
abstract isJsonSupported(): boolean;
334332
abstract isPartitionSupported(): boolean;
335333
abstract getDateBetweenQuery(): string;
336-
abstract getDynamicDateGrouping(onlyText?: boolean): string;
334+
abstract getDynamicDateGrouping(onlyText?: boolean): Grouping | string;
337335
abstract getFormatedDate(column: string): string;
338336
abstract getJsonAggregationQuery(...args: any[]): string;
339337

@@ -351,7 +349,68 @@ export abstract class BaseDatabaseAdapter implements EventDatabase {
351349
}));
352350
}
353351

354-
private ensureFiltersSet() {
352+
modifyDateInObject<T extends any>(
353+
obj: T & { [key: string]: string | number },
354+
datePath: string = 'date',
355+
) {
356+
if (obj[datePath]) {
357+
return {
358+
...obj,
359+
[datePath]: convertToLocalTimezone(obj[datePath] as string),
360+
};
361+
}
362+
return obj;
363+
}
364+
365+
getResponseData<T extends any[]>(
366+
data: T,
367+
datePath: string = 'date',
368+
): ResponseData<T> {
369+
return {
370+
data: data.map(d => {
371+
if (Array.isArray(d[datePath])) {
372+
return {
373+
...d,
374+
[datePath]: d[datePath].map((dp: any) =>
375+
this.modifyDateInObject(dp),
376+
),
377+
};
378+
}
379+
return this.modifyDateInObject(d, datePath);
380+
}) as T,
381+
};
382+
}
383+
384+
getResponseWithGrouping = <T extends any[]>(
385+
data: T,
386+
datePath: string = 'date',
387+
): ResponseWithGrouping<T> => {
388+
const grouping = this.getDynamicDateGrouping(true) as Grouping;
389+
390+
if (grouping === 'hourly') {
391+
return {
392+
grouping,
393+
data: data.map(d => {
394+
if (Array.isArray(d[datePath])) {
395+
return {
396+
...d,
397+
[datePath]: d[datePath].map((dp: any) =>
398+
this.modifyDateInObject(dp),
399+
),
400+
};
401+
}
402+
return this.modifyDateInObject(d, datePath);
403+
}) as T,
404+
};
405+
}
406+
407+
return {
408+
grouping,
409+
data,
410+
};
411+
};
412+
413+
ensureFiltersSet() {
355414
if (!this.filters) {
356415
throw new Error(
357416
'Filters must be set using setFilters() before calling methods.',

0 commit comments

Comments
 (0)