1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

Feat/stats service (#2211)

Introduces an instance stats service exposing usage metrics of the Unleash installation.
This commit is contained in:
Ivar Conradi Østhus 2022-10-25 13:10:27 +02:00 committed by GitHub
parent 1bdd516fc7
commit cf4fc2303b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 896 additions and 37 deletions

View File

@ -0,0 +1,11 @@
import AdminMenu from '../menu/AdminMenu';
import { InstanceStats } from './InstanceStats/InstanceStats';
export const InstanceAdmin = () => {
return (
<div>
<AdminMenu />
<InstanceStats />
</div>
);
};

View File

@ -0,0 +1,89 @@
import { Download } from '@mui/icons-material';
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@mui/material';
import { Box } from '@mui/system';
import { VFC } from 'react';
import { useInstanceStats } from '../../../../hooks/api/getters/useInstanceStats/useInstanceStats';
import { formatApiPath } from '../../../../utils/formatPath';
import { PageContent } from '../../../common/PageContent/PageContent';
import { PageHeader } from '../../../common/PageHeader/PageHeader';
export const InstanceStats: VFC = () => {
const { stats, loading } = useInstanceStats();
let versionTitle;
let version;
if (stats?.versionEnterprise) {
versionTitle = 'Unleash Enterprise version';
version = stats.versionEnterprise;
} else {
versionTitle = 'Unleash OSS version';
version = stats?.versionOSS;
}
const rows = [
{ title: 'Instance Id', value: stats?.instanceId },
{ title: versionTitle, value: version },
{ title: 'Users', value: stats?.users },
{ title: 'Feature toggles', value: stats?.featureToggles },
{ title: 'Projects', value: stats?.projects },
{ title: 'Environments', value: stats?.environments },
{ title: 'Roles', value: stats?.roles },
{ title: 'Groups', value: stats?.groups },
{ title: 'Context fields', value: stats?.contextFields },
{ title: 'Strategies', value: stats?.strategies },
];
if (stats?.versionEnterprise) {
rows.push(
{ title: 'SAML enabled', value: stats?.SAMLenabled ? 'Yes' : 'No' },
{ title: 'OIDC enabled', value: stats?.OIDCenabled ? 'Yes' : 'No' }
);
}
return (
<PageContent header={<PageHeader title="Instance Statistics" />}>
<Box sx={{ display: 'grid', gap: 4 }}>
<Table aria-label="Instance statistics">
<TableHead>
<TableRow>
<TableCell>Field</TableCell>
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => (
<TableRow key={row.title}>
<TableCell component="th" scope="row">
{row.title}
</TableCell>
<TableCell align="right">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<span style={{ textAlign: 'center' }}>
<Button
startIcon={<Download />}
aria-label="Download instance statistics"
color="primary"
variant="contained"
target="_blank"
href={formatApiPath(
'/api/admin/instance-admin/statistics/csv'
)}
>
Download
</Button>
</span>
</Box>
</PageContent>
);
};

View File

@ -135,6 +135,19 @@ function AdminMenu() {
</NavLink> </NavLink>
} }
/> />
<Tab
value="/admin/instance"
label={
<NavLink
to="/admin/instance"
style={({ isActive }) =>
createNavLinkStyle({ isActive, theme })
}
>
Instance stats
</NavLink>
}
/>
{isBilling && ( {isBilling && (
<Tab <Tab
value="/admin/billing" value="/admin/billing"

View File

@ -454,6 +454,16 @@ exports[`returns all baseRoutes 1`] = `
"title": "Single sign-on", "title": "Single sign-on",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"menu": {
"adminSettings": true,
},
"parent": "/admin",
"path": "/admin/instance",
"title": "Instance stats",
"type": "protected",
},
{ {
"component": [Function], "component": [Function],
"flag": "embedProxyFrontend", "flag": "embedProxyFrontend",

View File

@ -59,6 +59,7 @@ import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { CorsAdmin } from 'component/admin/cors'; import { CorsAdmin } from 'component/admin/cors';
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink'; import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
import { Profile } from 'component/user/Profile/Profile'; import { Profile } from 'component/user/Profile/Profile';
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
export const routes: IRoute[] = [ export const routes: IRoute[] = [
// Splash // Splash
@ -499,6 +500,14 @@ export const routes: IRoute[] = [
type: 'protected', type: 'protected',
menu: { adminSettings: true }, menu: { adminSettings: true },
}, },
{
path: '/admin/instance',
parent: '/admin',
title: 'Instance stats',
component: InstanceAdmin,
type: 'protected',
menu: { adminSettings: true },
},
{ {
path: '/admin/cors', path: '/admin/cors',
parent: '/admin', parent: '/admin',

View File

@ -0,0 +1,52 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
interface InstanceStats {
instanceId: string;
timestamp: Date;
versionOSS: string;
versionEnterprise?: string;
users: number;
featureToggles: number;
projects: number;
contextFields: number;
roles: number;
groups: number;
environments: number;
segments: number;
strategies: number;
SAMLenabled: boolean;
OIDCenabled: boolean;
}
export interface IInstanceStatsResponse {
stats?: InstanceStats;
refetchGroup: () => void;
loading: boolean;
error?: Error;
}
export const useInstanceStats = (): IInstanceStatsResponse => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/instance-admin/statistics`),
fetcher
);
return useMemo(
() => ({
stats: data,
loading: !error && !data,
refetchGroup: () => mutate(),
error,
}),
[data, error, mutate]
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Instance Stats'))
.then(res => res.json());
};

View File

@ -107,8 +107,10 @@
"helmet": "^5.0.0", "helmet": "^5.0.0",
"ip": "^1.1.8", "ip": "^1.1.8",
"joi": "^17.3.0", "joi": "^17.3.0",
"js-sha256": "^0.9.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json-schema-to-ts": "2.5.5", "json-schema-to-ts": "2.5.5",
"json2csv": "^5.0.7",
"knex": "^2.0.0", "knex": "^2.0.0",
"log4js": "^6.0.0", "log4js": "^6.0.0",
"make-fetch-happen": "^10.1.2", "make-fetch-happen": "^10.1.2",

View File

@ -141,6 +141,7 @@ exports[`should create default config 1`] = `
}, },
"strategySegmentsLimit": 5, "strategySegmentsLimit": 5,
"ui": { "ui": {
"environment": "Open Source",
"flags": { "flags": {
"E": true, "E": true,
"ENABLE_DARK_MODE_SUPPORT": false, "ENABLE_DARK_MODE_SUPPORT": false,

View File

@ -100,7 +100,9 @@ function loadClientCachingOptions(
function loadUI(options: IUnleashOptions): IUIConfig { function loadUI(options: IUnleashOptions): IUIConfig {
const uiO = options.ui || {}; const uiO = options.ui || {};
const ui: IUIConfig = {}; const ui: IUIConfig = {
environment: 'Open Source',
};
ui.flags = { ui.flags = {
E: true, E: true,

View File

@ -119,5 +119,11 @@ class ContextFieldStore implements IContextFieldStore {
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
return this.db(TABLE).where({ name }).del(); return this.db(TABLE).where({ name }).del();
} }
async count(): Promise<number> {
return this.db(TABLE)
.count('*')
.then((res) => Number(res[0].count));
}
} }
export default ContextFieldStore; export default ContextFieldStore;

View File

@ -175,6 +175,12 @@ export default class GroupStore implements IGroupStore {
return rowToGroup(row[0]); return rowToGroup(row[0]);
} }
async count(): Promise<number> {
return this.db(T.GROUPS)
.count('*')
.then((res) => Number(res[0].count));
}
async addUsersToGroup( async addUsersToGroup(
groupId: number, groupId: number,
users: IGroupUserModel[], users: IGroupUserModel[],

View File

@ -47,6 +47,13 @@ export default class RoleStore implements IRoleStore {
return rows.map(this.mapRow); return rows.map(this.mapRow);
} }
async count(): Promise<number> {
return this.db
.from(T.ROLES)
.count('*')
.then((res) => Number(res[0].count));
}
async create(role: ICustomRoleInsert): Promise<ICustomRole> { async create(role: ICustomRoleInsert): Promise<ICustomRole> {
const row = await this.db(T.ROLES) const row = await this.db(T.ROLES)
.insert({ .insert({

View File

@ -50,6 +50,13 @@ export default class SegmentStore implements ISegmentStore {
this.logger = getLogger('lib/db/segment-store.ts'); this.logger = getLogger('lib/db/segment-store.ts');
} }
async count(): Promise<number> {
return this.db
.from(T.segments)
.count('*')
.then((res) => Number(res[0].count));
}
async create( async create(
segment: PartialSome<ISegment, 'id'>, segment: PartialSome<ISegment, 'id'>,
user: Partial<Pick<User, 'username' | 'email'>>, user: Partial<Pick<User, 'username' | 'email'>>,

View File

@ -74,6 +74,13 @@ export default class StrategyStore implements IStrategyStore {
await this.db(TABLE).del(); await this.db(TABLE).del();
} }
async count(): Promise<number> {
return this.db
.from(TABLE)
.count('*')
.then((res) => Number(res[0].count));
}
destroy(): void {} destroy(): void {}
async exists(name: string): Promise<boolean> { async exists(name: string): Promise<boolean> {

View File

@ -10,11 +10,14 @@ import {
} from './types/events'; } from './types/events';
import { createMetricsMonitor } from './metrics'; import { createMetricsMonitor } from './metrics';
import createStores from '../test/fixtures/store'; import createStores from '../test/fixtures/store';
import { InstanceStatsService } from './services/instance-stats-service';
import VersionService from './services/version-service';
const monitor = createMetricsMonitor(); const monitor = createMetricsMonitor();
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
const prometheusRegister = register; const prometheusRegister = register;
let eventStore: IEventStore; let eventStore: IEventStore;
let statsService: InstanceStatsService;
let stores; let stores;
beforeAll(() => { beforeAll(() => {
const config = createTestConfig({ const config = createTestConfig({
@ -24,6 +27,8 @@ beforeAll(() => {
}); });
stores = createStores(); stores = createStores();
eventStore = stores.eventStore; eventStore = stores.eventStore;
const versionService = new VersionService(stores, config);
statsService = new InstanceStatsService(stores, config, versionService);
const db = { const db = {
client: { client: {
pool: { pool: {
@ -37,7 +42,15 @@ beforeAll(() => {
}, },
}; };
// @ts-ignore - We don't want a full knex implementation for our tests, it's enough that it actually yields the numbers we want. // @ts-ignore - We don't want a full knex implementation for our tests, it's enough that it actually yields the numbers we want.
monitor.startMonitoring(config, stores, '4.0.0', eventBus, db); monitor.startMonitoring(
config,
stores,
'4.0.0',
eventBus,
statsService,
//@ts-ignore
db,
);
}); });
afterAll(() => { afterAll(() => {
monitor.stopMonitoring(); monitor.stopMonitoring();
@ -102,6 +115,9 @@ test('should collect metrics for db query timings', async () => {
}); });
test('should collect metrics for feature toggle size', async () => { test('should collect metrics for feature toggle size', async () => {
await new Promise((done) => {
setTimeout(done, 10);
});
const metrics = await prometheusRegister.metrics(); const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/); expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/);
}); });

View File

@ -22,6 +22,7 @@ import { IUnleashConfig } from './types/option';
import { IUnleashStores } from './types/stores'; import { IUnleashStores } from './types/stores';
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
import Timer = NodeJS.Timer; import Timer = NodeJS.Timer;
import { InstanceStatsService } from './services/instance-stats-service';
export default class MetricsMonitor { export default class MetricsMonitor {
timer?: Timer; timer?: Timer;
@ -38,19 +39,14 @@ export default class MetricsMonitor {
stores: IUnleashStores, stores: IUnleashStores,
version: string, version: string,
eventBus: EventEmitter, eventBus: EventEmitter,
instanceStatsService: InstanceStatsService,
db: Knex, db: Knex,
): Promise<void> { ): Promise<void> {
if (!config.server.serverMetrics) { if (!config.server.serverMetrics) {
return; return;
} }
const { const { eventStore } = stores;
eventStore,
featureToggleStore,
userStore,
projectStore,
environmentStore,
} = stores;
client.collectDefaultMetrics(); client.collectDefaultMetrics();
@ -97,6 +93,40 @@ export default class MetricsMonitor {
name: 'environments_total', name: 'environments_total',
help: 'Number of environments', help: 'Number of environments',
}); });
const groupsTotal = new client.Gauge({
name: 'groups_total',
help: 'Number of groups',
});
const rolesTotal = new client.Gauge({
name: 'roles_total',
help: 'Number of roles',
});
const segmentsTotal = new client.Gauge({
name: 'segments_total',
help: 'Number of segments',
});
const contextTotal = new client.Gauge({
name: 'context_total',
help: 'Number of context',
});
const strategiesTotal = new client.Gauge({
name: 'strategies_total',
help: 'Number of strategies',
});
const samlEnabled = new client.Gauge({
name: 'saml_enabled',
help: 'Whether SAML is enabled',
});
const oidcEnabled = new client.Gauge({
name: 'oidc_enabled',
help: 'Whether OIDC is enabled',
});
const clientSdkVersionUsage = new client.Counter({ const clientSdkVersionUsage = new client.Counter({
name: 'client_sdk_versions', name: 'client_sdk_versions',
@ -105,41 +135,51 @@ export default class MetricsMonitor {
}); });
async function collectStaticCounters() { async function collectStaticCounters() {
let togglesCount: number = 0;
let usersCount: number;
let projectsCount: number;
let environmentsCount: number;
try { try {
togglesCount = await featureToggleStore.count({ const stats = await instanceStatsService.getStats();
archived: false,
}); featureTogglesTotal.reset();
usersCount = await userStore.count(); featureTogglesTotal.labels(version).set(stats.featureToggles);
projectsCount = await projectStore.count();
environmentsCount = await environmentStore.count();
// eslint-disable-next-line no-empty
} catch (e) {}
featureTogglesTotal.reset();
featureTogglesTotal.labels(version).set(togglesCount);
if (usersCount) {
usersTotal.reset(); usersTotal.reset();
usersTotal.set(usersCount); usersTotal.set(stats.users);
}
if (projectsCount) {
projectsTotal.reset(); projectsTotal.reset();
projectsTotal.set(projectsCount); projectsTotal.set(stats.projects);
}
if (environmentsCount) {
environmentsTotal.reset(); environmentsTotal.reset();
environmentsTotal.set(environmentsCount); environmentsTotal.set(stats.environments);
}
groupsTotal.reset();
groupsTotal.set(stats.groups);
rolesTotal.reset();
rolesTotal.set(stats.roles);
segmentsTotal.reset();
segmentsTotal.set(stats.segments);
contextTotal.reset();
contextTotal.set(stats.contextFields);
strategiesTotal.reset();
strategiesTotal.set(stats.strategies);
samlEnabled.reset();
samlEnabled.set(stats.SAMLenabled ? 1 : 0);
oidcEnabled.reset();
oidcEnabled.set(stats.OIDCenabled ? 1 : 0);
} catch (e) {}
} }
collectStaticCounters(); process.nextTick(() => {
this.timer = setInterval( collectStaticCounters();
() => collectStaticCounters(), this.timer = setInterval(
hoursToMilliseconds(2), () => collectStaticCounters(),
).unref(); hoursToMilliseconds(2),
).unref();
});
eventBus.on( eventBus.on(
events.REQUEST_TIME, events.REQUEST_TIME,

View File

@ -121,6 +121,7 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema'; import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema'; import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema'; import { versionSchema } from './spec/version-schema';
import { instanceAdminStatsSchema } from './spec/instance-admin-stats-schema';
import apiVersion from '../util/version'; import apiVersion from '../util/version';
// All schemas in `openapi/spec` should be listed here. // All schemas in `openapi/spec` should be listed here.
@ -176,6 +177,7 @@ export const schemas = {
healthOverviewSchema, healthOverviewSchema,
healthReportSchema, healthReportSchema,
idSchema, idSchema,
instanceAdminStatsSchema,
legalValueSchema, legalValueSchema,
loginSchema, loginSchema,
meSchema, meSchema,

View File

@ -0,0 +1,13 @@
import { validateSchema } from '../validate';
import { InstanceAdminStatsSchema } from './instance-admin-stats-schema';
test('instanceAdminStatsSchema', () => {
const data: InstanceAdminStatsSchema = {
instanceId: '123',
users: 0,
};
expect(
validateSchema('#/components/schemas/instanceAdminStatsSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,65 @@
import { FromSchema } from 'json-schema-to-ts';
export const instanceAdminStatsSchema = {
$id: '#/components/schemas/instanceAdminStatsSchema',
type: 'object',
additionalProperties: false,
required: ['instanceId'],
properties: {
instanceId: {
type: 'string',
},
timestamp: {
type: 'string',
format: 'date-time',
nullable: true,
},
versionOSS: {
type: 'string',
},
versionEnterprise: {
type: 'string',
},
users: {
type: 'number',
},
featureToggles: {
type: 'number',
},
projects: {
type: 'number',
},
contextFields: {
type: 'number',
},
roles: {
type: 'number',
},
groups: {
type: 'number',
},
environments: {
type: 'number',
},
segments: {
type: 'number',
},
strategies: {
type: 'number',
},
SAMLenabled: {
type: 'number',
},
OIDCenabled: {
type: 'number',
},
sum: {
type: 'string',
},
},
components: {},
} as const;
export type InstanceAdminStatsSchema = FromSchema<
typeof instanceAdminStatsSchema
>;

View File

@ -45,6 +45,11 @@ const OPENAPI_TAGS = [
description: description:
'[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.', '[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.',
}, },
{
name: 'Instance Admin',
description:
'Instance admin endpoints used to manage the Unleash instance itself.',
},
{ {
name: 'Metrics', name: 'Metrics',
description: 'Register, read, or delete metrics recorded by Unleash.', description: 'Register, read, or delete metrics recorded by Unleash.',

View File

@ -26,6 +26,7 @@ import ConstraintsController from './constraints';
import PatController from './user/pat'; import PatController from './user/pat';
import { PublicSignupController } from './public-signup'; import { PublicSignupController } from './public-signup';
import { conditionalMiddleware } from '../../middleware/conditional-middleware'; import { conditionalMiddleware } from '../../middleware/conditional-middleware';
import InstanceAdminController from './instance-admin';
class AdminApi extends Controller { class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -114,6 +115,10 @@ class AdminApi extends Controller {
new PublicSignupController(config, services).router, new PublicSignupController(config, services).router,
), ),
); );
this.app.use(
'/instance-admin',
new InstanceAdminController(config, services).router,
);
} }
} }

View File

@ -0,0 +1,84 @@
import { Parser } from 'json2csv';
import { Response } from 'express';
import { AuthedRequest } from '../../types/core';
import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option';
import Controller from '../controller';
import { NONE } from '../../types/permissions';
import { UiConfigSchema } from '../../openapi/spec/ui-config-schema';
import {
InstanceStats,
InstanceStatsService,
} from '../../services/instance-stats-service';
import { OpenApiService } from '../../services/openapi-service';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
class InstanceAdminController extends Controller {
private instanceStatsService: InstanceStatsService;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
instanceStatsService,
openApiService,
}: Pick<IUnleashServices, 'instanceStatsService' | 'openApiService'>,
) {
super(config);
this.openApiService = openApiService;
this.instanceStatsService = instanceStatsService;
this.route({
method: 'get',
path: '/statistics/csv',
handler: this.getStatisticsCSV,
permission: NONE,
});
this.route({
method: 'get',
path: '/statistics',
handler: this.getStatistics,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Instance Admin'],
operationId: 'getInstanceAdminStats',
responses: {
200: createResponseSchema('instanceAdminStatsSchema'),
},
deprecated: true,
}),
],
});
}
async getStatistics(
req: AuthedRequest,
res: Response<InstanceStats>,
): Promise<void> {
const instanceStats = await this.instanceStatsService.getSignedStats();
res.json(instanceStats);
}
async getStatisticsCSV(
req: AuthedRequest,
res: Response<UiConfigSchema>,
): Promise<void> {
const instanceStats = await this.instanceStatsService.getSignedStats();
const fileName = `unleash-${
instanceStats.instanceId
}-${Date.now()}.csv`;
const json2csvParser = new Parser();
const csv = json2csvParser.parse(instanceStats);
res.contentType('csv');
res.attachment(fileName);
res.send(csv);
}
}
export default InstanceAdminController;

View File

@ -67,6 +67,7 @@ async function createApp(
stores, stores,
serverVersion, serverVersion,
config.eventBus, config.eventBus,
services.instanceStatsService,
db, db,
); );
const unleash: Omit<IUnleash, 'stop'> = { const unleash: Omit<IUnleash, 'stop'> = {

View File

@ -36,6 +36,8 @@ import EdgeService from './edge-service';
import PatService from './pat-service'; import PatService from './pat-service';
import { PublicSignupTokenService } from './public-signup-token-service'; import { PublicSignupTokenService } from './public-signup-token-service';
import { LastSeenService } from './client-metrics/last-seen-service'; import { LastSeenService } from './client-metrics/last-seen-service';
import { InstanceStatsService } from './instance-stats-service';
export const createServices = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
config: IUnleashConfig, config: IUnleashConfig,
@ -116,6 +118,12 @@ export const createServices = (
userService, userService,
); );
const instanceStatsService = new InstanceStatsService(
stores,
config,
versionService,
);
return { return {
accessService, accessService,
addonService, addonService,
@ -154,6 +162,7 @@ export const createServices = (
patService, patService,
publicSignupTokenService, publicSignupTokenService,
lastSeenService, lastSeenService,
instanceStatsService,
}; };
}; };

View File

@ -0,0 +1,185 @@
import { sha256 } from 'js-sha256';
import { Logger } from '../logger';
import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types/stores';
import { IContextFieldStore } from '../types/stores/context-field-store';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IGroupStore } from '../types/stores/group-store';
import { IProjectStore } from '../types/stores/project-store';
import { IStrategyStore } from '../types/stores/strategy-store';
import { IUserStore } from '../types/stores/user-store';
import { ISegmentStore } from '../types/stores/segment-store';
import { IRoleStore } from '../types/stores/role-store';
import VersionService from './version-service';
import { ISettingStore } from '../types/stores/settings-store';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface InstanceStats {
instanceId: string;
timestamp: Date;
versionOSS: string;
versionEnterprise?: string;
users: number;
featureToggles: number;
projects: number;
contextFields: number;
roles: number;
groups: number;
environments: number;
segments: number;
strategies: number;
SAMLenabled: boolean;
OIDCenabled: boolean;
}
interface InstanceStatsSigned extends InstanceStats {
sum: string;
}
export class InstanceStatsService {
private logger: Logger;
private strategyStore: IStrategyStore;
private userStore: IUserStore;
private featureToggleStore: IFeatureToggleStore;
private contextFieldStore: IContextFieldStore;
private projectStore: IProjectStore;
private groupStore: IGroupStore;
private environmentStore: IEnvironmentStore;
private segmentStore: ISegmentStore;
private roleStore: IRoleStore;
private versionService: VersionService;
private settingStore: ISettingStore;
constructor(
{
featureToggleStore,
userStore,
projectStore,
environmentStore,
strategyStore,
contextFieldStore,
groupStore,
segmentStore,
roleStore,
settingStore,
}: Pick<
IUnleashStores,
| 'featureToggleStore'
| 'userStore'
| 'projectStore'
| 'environmentStore'
| 'strategyStore'
| 'contextFieldStore'
| 'groupStore'
| 'segmentStore'
| 'roleStore'
| 'settingStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
versionService: VersionService,
) {
this.strategyStore = strategyStore;
this.userStore = userStore;
this.featureToggleStore = featureToggleStore;
this.environmentStore = environmentStore;
this.projectStore = projectStore;
this.groupStore = groupStore;
this.contextFieldStore = contextFieldStore;
this.segmentStore = segmentStore;
this.roleStore = roleStore;
this.versionService = versionService;
this.settingStore = settingStore;
this.logger = getLogger('services/stats-service.js');
}
async getToggleCount(): Promise<number> {
return this.featureToggleStore.count({
archived: false,
});
}
async hasOIDC(): Promise<boolean> {
const settings = await this.settingStore.get(
'unleash.enterprise.auth.oidc',
);
return settings?.enabled || false;
}
async hasSAML(): Promise<boolean> {
const settings = await this.settingStore.get(
'unleash.enterprise.auth.saml',
);
return settings?.enabled || false;
}
async getStats(): Promise<InstanceStats> {
const versionInfo = this.versionService.getVersionInfo();
const [
featureToggles,
users,
projects,
contextFields,
groups,
roles,
environments,
segments,
strategies,
SAMLenabled,
OIDCenabled,
] = await Promise.all([
this.getToggleCount(),
this.userStore.count(),
this.projectStore.count(),
this.contextFieldStore.count(),
this.groupStore.count(),
this.roleStore.count(),
this.environmentStore.count(),
this.segmentStore.count(),
this.strategyStore.count(),
this.hasSAML(),
this.hasOIDC(),
]);
return {
timestamp: new Date(),
instanceId: versionInfo.instanceId,
versionOSS: versionInfo.current.oss,
versionEnterprise: versionInfo.current.enterprise,
users,
featureToggles,
projects,
contextFields,
roles,
groups,
environments,
segments,
strategies,
SAMLenabled,
OIDCenabled,
};
}
async getSignedStats(): Promise<InstanceStatsSigned> {
const instanceStats = await this.getStats();
const sum = sha256(
`${instanceStats.instanceId}${instanceStats.users}${instanceStats.featureToggles}${instanceStats.projects}${instanceStats.roles}${instanceStats.groups}${instanceStats.environments}${instanceStats.segments}`,
);
return { ...instanceStats, sum };
}
}

View File

@ -139,6 +139,7 @@ export interface IListeningHost {
} }
export interface IUIConfig { export interface IUIConfig {
environment?: string;
slogan?: string; slogan?: string;
name?: string; name?: string;
links?: [ links?: [

View File

@ -34,6 +34,7 @@ import EdgeService from '../services/edge-service';
import PatService from '../services/pat-service'; import PatService from '../services/pat-service';
import { PublicSignupTokenService } from '../services/public-signup-token-service'; import { PublicSignupTokenService } from '../services/public-signup-token-service';
import { LastSeenService } from '../services/client-metrics/last-seen-service'; import { LastSeenService } from '../services/client-metrics/last-seen-service';
import { InstanceStatsService } from '../services/instance-stats-service';
export interface IUnleashServices { export interface IUnleashServices {
accessService: AccessService; accessService: AccessService;
@ -73,4 +74,5 @@ export interface IUnleashServices {
clientSpecService: ClientSpecService; clientSpecService: ClientSpecService;
patService: PatService; patService: PatService;
lastSeenService: LastSeenService; lastSeenService: LastSeenService;
instanceStatsService: InstanceStatsService;
} }

View File

@ -20,4 +20,5 @@ export interface IContextField extends IContextFieldDto {
export interface IContextFieldStore extends Store<IContextField, string> { export interface IContextFieldStore extends Store<IContextField, string> {
create(data: IContextFieldDto): Promise<IContextField>; create(data: IContextFieldDto): Promise<IContextField>;
update(data: IContextFieldDto): Promise<IContextField>; update(data: IContextFieldDto): Promise<IContextField>;
count(): Promise<number>;
} }

View File

@ -58,4 +58,6 @@ export interface IGroupStore extends Store<IGroup, number> {
existsWithName(name: string): Promise<boolean>; existsWithName(name: string): Promise<boolean>;
create(group: IStoreGroup): Promise<IGroup>; create(group: IStoreGroup): Promise<IGroup>;
count(): Promise<number>;
} }

View File

@ -28,4 +28,5 @@ export interface IRoleStore extends Store<ICustomRole, number> {
getRootRoles(): Promise<IRole[]>; getRootRoles(): Promise<IRole[]>;
getRootRoleForAllUsers(): Promise<IUserRole[]>; getRootRoleForAllUsers(): Promise<IUserRole[]>;
nameInUse(name: string, existingId: number): Promise<boolean>; nameInUse(name: string, existingId: number): Promise<boolean>;
count(): Promise<number>;
} }

View File

@ -25,4 +25,6 @@ export interface ISegmentStore extends Store<ISegment, number> {
getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>; getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
existsByName(name: string): Promise<boolean>; existsByName(name: string): Promise<boolean>;
count(): Promise<number>;
} }

View File

@ -38,4 +38,5 @@ export interface IStrategyStore extends Store<IStrategy, string> {
reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void>; reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void>;
importStrategy(data: IMinimalStrategy): Promise<void>; importStrategy(data: IMinimalStrategy): Promise<void>;
dropCustomStrategies(): Promise<void>; dropCustomStrategies(): Promise<void>;
count(): Promise<number>;
} }

View File

@ -0,0 +1,74 @@
import dbInit from '../../helpers/database-init';
import { setupApp } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import { IUnleashStores } from '../../../../lib/types';
let app;
let db;
let stores: IUnleashStores;
beforeAll(async () => {
db = await dbInit('instance_admin_api_serial', getLogger);
stores = db.stores;
await stores.settingStore.insert('instanceInfo', { id: 'test-static' });
app = await setupApp(stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should return instance statistics', async () => {
stores.featureToggleStore.create('default', { name: 'TestStats1' });
return app.request
.get('/api/admin/instance-admin/statistics')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.featureToggles).toBe(1);
});
});
test('should return instance statistics with correct number of projects', async () => {
stores.projectStore.create({
id: 'test',
name: 'Test',
description: 'lorem',
});
return app.request
.get('/api/admin/instance-admin/statistics')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.projects).toBe(2);
});
});
test('should return signed instance statistics', async () => {
return app.request
.get('/api/admin/instance-admin/statistics')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.instanceId).toBe('test-static');
expect(res.body.sum).toBe(
'5ba2cb7c3e29f4e5b3382c560b92b837f3603dc7db73a501ec331c7f0ed17bd0',
);
});
});
test('should return instance statistics as CVS', async () => {
stores.featureToggleStore.create('default', { name: 'TestStats2' });
stores.featureToggleStore.create('default', { name: 'TestStats3' });
const res = await app.request
.get('/api/admin/instance-admin/statistics/csv')
.expect('Content-Type', /text\/csv/)
.expect(200);
expect(res.text).toMatch(/featureToggles/);
expect(res.text).toMatch(/"sum"/);
});

View File

@ -1573,6 +1573,65 @@ exports[`should serve the OpenAPI spec 1`] = `
], ],
"type": "object", "type": "object",
}, },
"instanceAdminStatsSchema": {
"additionalProperties": false,
"properties": {
"OIDCenabled": {
"type": "number",
},
"SAMLenabled": {
"type": "number",
},
"contextFields": {
"type": "number",
},
"environments": {
"type": "number",
},
"featureToggles": {
"type": "number",
},
"groups": {
"type": "number",
},
"instanceId": {
"type": "string",
},
"projects": {
"type": "number",
},
"roles": {
"type": "number",
},
"segments": {
"type": "number",
},
"strategies": {
"type": "number",
},
"sum": {
"type": "string",
},
"timestamp": {
"format": "date-time",
"nullable": true,
"type": "string",
},
"users": {
"type": "number",
},
"versionEnterprise": {
"type": "string",
},
"versionOSS": {
"type": "string",
},
},
"required": [
"instanceId",
],
"type": "object",
},
"legalValueSchema": { "legalValueSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -4583,6 +4642,27 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/instance-admin/statistics": {
"get": {
"deprecated": true,
"operationId": "getInstanceAdminStats",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/instanceAdminStatsSchema",
},
},
},
"description": "instanceAdminStatsSchema",
},
},
"tags": [
"Instance Admin",
],
},
},
"/api/admin/invite-link/tokens": { "/api/admin/invite-link/tokens": {
"get": { "get": {
"operationId": "getAllPublicSignupTokens", "operationId": "getAllPublicSignupTokens",
@ -7589,6 +7669,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.", "description": "[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.",
"name": "Import/Export", "name": "Import/Export",
}, },
{
"description": "Instance admin endpoints used to manage the Unleash instance itself.",
"name": "Instance Admin",
},
{ {
"description": "Register, read, or delete metrics recorded by Unleash.", "description": "Register, read, or delete metrics recorded by Unleash.",
"name": "Metrics", "name": "Metrics",

View File

@ -6,6 +6,10 @@ import {
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
export default class FakeContextFieldStore implements IContextFieldStore { export default class FakeContextFieldStore implements IContextFieldStore {
count(): Promise<number> {
return Promise.resolve(0);
}
defaultContextFields: IContextField[] = [ defaultContextFields: IContextField[] = [
{ {
name: 'environment', name: 'environment',

View File

@ -9,6 +9,10 @@ import Group, {
} from '../../lib/types/group'; } from '../../lib/types/group';
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeGroupStore implements IGroupStore { export default class FakeGroupStore implements IGroupStore {
count(): Promise<number> {
return Promise.resolve(0);
}
data: IGroup[]; data: IGroup[];
async getAll(): Promise<IGroup[]> { async getAll(): Promise<IGroup[]> {

View File

@ -8,6 +8,10 @@ import {
} from 'lib/types/stores/role-store'; } from 'lib/types/stores/role-store';
export default class FakeRoleStore implements IRoleStore { export default class FakeRoleStore implements IRoleStore {
count(): Promise<number> {
return Promise.resolve(0);
}
roles: ICustomRole[] = []; roles: ICustomRole[] = [];
getGroupRolesForProject(projectId: string): Promise<IRole[]> { getGroupRolesForProject(projectId: string): Promise<IRole[]> {

View File

@ -2,6 +2,10 @@ import { ISegmentStore } from '../../lib/types/stores/segment-store';
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model'; import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
export default class FakeSegmentStore implements ISegmentStore { export default class FakeSegmentStore implements ISegmentStore {
count(): Promise<number> {
return Promise.resolve(0);
}
create(): Promise<ISegment> { create(): Promise<ISegment> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

View File

@ -7,6 +7,10 @@ import {
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
export default class FakeStrategiesStore implements IStrategyStore { export default class FakeStrategiesStore implements IStrategyStore {
count(): Promise<number> {
return Promise.resolve(0);
}
defaultStrategy: IStrategy = { defaultStrategy: IStrategy = {
name: 'default', name: 'default',
description: 'default strategy', description: 'default strategy',

View File

@ -2490,6 +2490,11 @@ commander@^2.20.3:
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^6.1.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
commander@^9.1.0: commander@^9.1.0:
version "9.2.0" version "9.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9" resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
@ -4859,6 +4864,11 @@ js-sdsl@^4.1.4:
resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a"
integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q== integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==
js-sha256@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==
js-tokens@^4.0.0: js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
@ -4928,6 +4938,15 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
json2csv@^5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae"
integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==
dependencies:
commander "^6.1.0"
jsonparse "^1.3.1"
lodash.get "^4.4.2"
json5@^1.0.1: json5@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz" resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
@ -4954,6 +4973,11 @@ jsonfile@^6.0.1:
optionalDependencies: optionalDependencies:
graceful-fs "^4.1.6" graceful-fs "^4.1.6"
jsonparse@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
jsprim@^1.2.2: jsprim@^1.2.2:
version "1.4.1" version "1.4.1"
resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz" resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz"