Skip to content

Commit

Permalink
feat: application overview backend (#6303)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Feb 22, 2024
1 parent 6246459 commit 3c4457a
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 4 deletions.
80 changes: 80 additions & 0 deletions src/lib/db/client-applications-store.ts
Expand Up @@ -7,6 +7,7 @@ import {
import { Logger, LogProvider } from '../logger';
import { IApplicationQuery } from '../types/query';
import { Db } from './db';
import { IApplicationOverview } from '../features/metrics/instance/models';

const COLUMNS = [
'app_name',
Expand Down Expand Up @@ -249,4 +250,83 @@ export default class ClientApplicationsStore

return mapRow(row);
}

async getApplicationOverview(
appName: string,
): Promise<IApplicationOverview> {
const query = this.db
.select([
'f.project',
'cme.environment',
'cme.feature_name',
'ci.instance_id',
'ci.sdk_version',
'ci.last_seen',
])
.from({ a: 'client_applications' })
.leftJoin('client_metrics_env as cme', 'cme.app_name', 'a.app_name')
.leftJoin('features as f', 'cme.feature_name', 'f.name')
.leftJoin('client_instances as ci', function () {
this.on('ci.app_name', '=', 'cme.app_name').andOn(
'ci.environment',
'=',
'cme.environment',
);
})
.where('a.app_name', appName);

const rows = await query;
if (!rows.length) {
throw new NotFoundError(`Could not find appName=${appName}`);
}

return this.mapApplicationOverviewData(rows);
}

mapApplicationOverviewData(rows: any[]): IApplicationOverview {
const featureCount = new Set(rows.map((row) => row.feature_name)).size;

const environments = rows.reduce((acc, row) => {
const { environment, instance_id, sdk_version, last_seen } = row;
let env = acc.find((e) => e.name === environment);
if (!env) {
env = {
name: environment,
instanceCount: 1,
sdks: sdk_version ? [sdk_version] : [],
lastSeen: last_seen,
uniqueInstanceIds: new Set([instance_id]),
};
acc.push(env);
} else {
env.uniqueInstanceIds.add(instance_id);
env.instanceCount = env.uniqueInstanceIds.size;
if (sdk_version && !env.sdks.includes(sdk_version)) {
env.sdks.push(sdk_version);
}
if (new Date(last_seen) > new Date(env.lastSeen)) {
env.lastSeen = last_seen;
}
}

return acc;
}, []);

environments.forEach((env) => {
delete env.uniqueInstanceIds;
env.sdks.sort();
});

return {
projects: [
...new Set(
rows
.filter((row) => row.project != null)
.map((row) => row.project),
),
],
featureCount,
environments,
};
}
}
8 changes: 7 additions & 1 deletion src/lib/features/metrics/instance/instance-service.ts
@@ -1,5 +1,5 @@
import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../../types/events';
import { IApplication } from './models';
import { IApplication, IApplicationOverview } from './models';
import { IUnleashStores } from '../../../types/stores';
import { IUnleashConfig } from '../../../types/option';
import { IEventStore } from '../../../types/stores/event-store';
Expand Down Expand Up @@ -212,6 +212,12 @@ export default class ClientInstanceService {
};
}

async getApplicationOverview(
appName: string,
): Promise<IApplicationOverview> {
return this.clientApplicationsStore.getApplicationOverview(appName);
}

async deleteApplication(appName: string): Promise<void> {
await this.clientInstanceStore.deleteForApplication(appName);
await this.clientApplicationsStore.delete(appName);
Expand Down
16 changes: 16 additions & 0 deletions src/lib/features/metrics/instance/models.ts
@@ -1,4 +1,6 @@
import { IClientInstance } from '../../../types/stores/client-instance-store';
import { ApplicationOverviewSchema } from '../../../openapi/spec/application-overview-schema';
import { ApplicationOverviewEnvironmentSchema } from '../../../openapi/spec/application-overview-environment-schema';

export interface IYesNoCount {
yes: number;
Expand Down Expand Up @@ -29,3 +31,17 @@ export interface IApplication {
environment?: string;
links?: Record<string, string>;
}

type IApplicationOverviewEnvironment = Omit<
ApplicationOverviewEnvironmentSchema,
'lastSeen'
> & {
lastSeen: Date;
};

export type IApplicationOverview = Omit<
ApplicationOverviewSchema,
'environments'
> & {
environments: IApplicationOverviewEnvironment[];
};
4 changes: 3 additions & 1 deletion src/lib/openapi/spec/application-overview-schema.ts
Expand Up @@ -33,7 +33,9 @@ export const applicationOverviewSchema = {
},
},
components: {
applicationOverviewEnvironmentSchema,
schemas: {
applicationOverviewEnvironmentSchema,
},
},
} as const;

Expand Down
29 changes: 27 additions & 2 deletions src/lib/routes/admin-api/metrics.ts
Expand Up @@ -16,12 +16,23 @@ import {
import { CreateApplicationSchema } from '../../openapi/spec/create-application-schema';
import { IAuthRequest } from '../unleash-types';
import { extractUserIdFromUser } from '../../util';
import { IFlagResolver, serializeDates } from '../../types';
import { NotFoundError } from '../../error';
import {
ApplicationOverviewSchema,
applicationOverviewSchema,
} from '../../openapi/spec/application-overview-schema';
import { OpenApiService } from '../../services';

class MetricsController extends Controller {
private logger: Logger;

private clientInstanceService: ClientInstanceService;

private flagResolver: IFlagResolver;

private openApiService: OpenApiService;

constructor(
config: IUnleashConfig,
{
Expand All @@ -33,6 +44,8 @@ class MetricsController extends Controller {
this.logger = config.getLogger('/admin-api/metrics.ts');

this.clientInstanceService = clientInstanceService;
this.openApiService = openApiService;
this.flagResolver = config.flagResolver;

// deprecated routes
this.get('/seen-toggles', this.deprecated);
Expand Down Expand Up @@ -195,9 +208,21 @@ class MetricsController extends Controller {
}
async getApplicationOverview(
req: Request,
res: Response<ApplicationSchema>,
res: Response<ApplicationOverviewSchema>,
): Promise<void> {
throw new Error('Not implemented');
if (!this.flagResolver.isEnabled('sdkReporting')) {
throw new NotFoundError();
}
const { appName } = req.params;
const overview =
await this.clientInstanceService.getApplicationOverview(appName);

this.openApiService.respondWithValidation(
200,
res,
applicationOverviewSchema.$id,
serializeDates(overview),
);
}
}
export default MetricsController;
2 changes: 2 additions & 0 deletions src/lib/types/stores/client-applications-store.ts
@@ -1,5 +1,6 @@
import { Store } from './store';
import { IApplicationQuery } from '../query';
import { IApplicationOverview } from '../../features/metrics/instance/models';

export interface IClientApplicationUsage {
project: string;
Expand Down Expand Up @@ -28,4 +29,5 @@ export interface IClientApplicationsStore
getAppsForStrategy(query: IApplicationQuery): Promise<IClientApplication[]>;
getUnannounced(): Promise<IClientApplication[]>;
setUnannouncedToAnnounced(): Promise<IClientApplication[]>;
getApplicationOverview(appName: string): Promise<IApplicationOverview>;
}
136 changes: 136 additions & 0 deletions src/test/e2e/api/admin/applications.e2e.test.ts
@@ -0,0 +1,136 @@
import dbInit, { ITestDb } from '../../helpers/database-init';
import {
IUnleashTest,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import {
ApiTokenType,
IApiToken,
} from '../../../../lib/types/models/api-token';

let app: IUnleashTest;
let db: ITestDb;
let defaultToken: IApiToken;

const metrics = {
appName: 'appName',
instanceId: 'instanceId',
bucket: {
start: '2016-11-03T07:16:43.572Z',
stop: '2016-11-03T07:16:53.572Z',
toggles: {
'toggle-name-1': {
yes: 123,
no: 321,
variants: {
'variant-1': 123,
'variant-2': 321,
},
},
'toggle-name-2': {
yes: 123,
no: 321,
variants: {
'variant-1': 123,
'variant-2': 321,
},
},
'toggle-name-3': {
yes: 123,
no: 321,
variants: {
'variant-1': 123,
'variant-2': 321,
},
},
},
},
};

beforeAll(async () => {
db = await dbInit('applications_serial', getLogger, {});
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
sdkReporting: true,
},
},
},
db.rawDatabase,
);

defaultToken =
await app.services.apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.CLIENT,
projects: ['default'],
environment: 'default',
tokenName: 'tester',
});
});

afterEach(async () => {
await Promise.all([
db.stores.clientMetricsStoreV2.deleteAll(),
db.stores.clientInstanceStore.deleteAll(),
db.stores.featureToggleStore.deleteAll(),
]);
});

afterAll(async () => {
await app.destroy();
await db.destroy();
});

test('should show correct number of total', async () => {
await Promise.all([
app.createFeature('toggle-name-1'),
app.createFeature('toggle-name-2'),
app.createFeature('toggle-name-3'),
app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: metrics.instanceId,
strategies: ['default'],
sdkVersion: 'unleash-client-test',
started: Date.now(),
interval: 10,
}),
app.request.post('/api/client/register').send({
appName: metrics.appName,
instanceId: 'another-instance',
strategies: ['default'],
sdkVersion: 'unleash-client-test2',
started: Date.now(),
interval: 10,
}),
]);
await app.services.clientInstanceService.bulkAdd();
await app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send(metrics)
.expect(202);

await app.services.clientMetricsServiceV2.bulkAdd();

const { body } = await app.request
.get(`/api/admin/metrics/applications/${metrics.appName}/overview`)
.expect(200);

const expected = {
projects: ['default'],
environments: [
{
instanceCount: 2,
name: 'default',
sdks: ['unleash-client-test', 'unleash-client-test2'],
},
],
featureCount: 3,
};

expect(body).toMatchObject(expected);
});
5 changes: 5 additions & 0 deletions src/test/fixtures/fake-client-applications-store.ts
Expand Up @@ -4,6 +4,7 @@ import {
} from '../../lib/types/stores/client-applications-store';
import NotFoundError from '../../lib/error/notfound-error';
import { IApplicationQuery } from '../../lib/types/query';
import { IApplicationOverview } from '../../lib/features/metrics/instance/models';

export default class FakeClientApplicationsStore
implements IClientApplicationsStore
Expand Down Expand Up @@ -78,4 +79,8 @@ export default class FakeClientApplicationsStore
await this.delete(details.appName);
return this.bulkUpsert([details]);
}

getApplicationOverview(appName: string): Promise<IApplicationOverview> {
throw new Error('Method not implemented.');
}
}

0 comments on commit 3c4457a

Please sign in to comment.