diff --git a/src/shadowbox/infrastructure/prometheus_scraper.ts b/src/shadowbox/infrastructure/prometheus_scraper.ts index aee0239a6..6a7bdaf6f 100644 --- a/src/shadowbox/infrastructure/prometheus_scraper.ts +++ b/src/shadowbox/infrastructure/prometheus_scraper.ts @@ -37,7 +37,11 @@ interface QueryResult { error: string; } -export class PrometheusClient { +export interface PrometheusClient { + query(query: string): Promise; +} + +export class ApiPrometheusClient implements PrometheusClient { constructor(private address: string) {} query(query: string): Promise { @@ -101,7 +105,7 @@ async function spawnPrometheusSubprocess( prometheusEndpoint: string ): Promise { logging.info('======== Starting Prometheus ========'); - logging.info(`${binaryFilename} ${processArgs.map(a => `"${a}"`).join(' ')}`); + logging.info(`${binaryFilename} ${processArgs.map((a) => `"${a}"`).join(' ')}`); const runProcess = child_process.spawn(binaryFilename, processArgs); runProcess.on('error', (error) => { logging.error(`Error spawning Prometheus: ${error}`); diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 53ef29b31..22450739a 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -127,7 +127,60 @@ paths: responses: '204': description: Access key limit deleted successfully. - + /experimental/server/metrics: + get: + tags: Server + parameters: + - in: query + name: since + description: the range of time to return data for + schema: + type: string + responses: + '200': + description: Display server metric information + content: + application/json: + schema: + type: object + properties: + server: + type: array + items: + type: object + properties: + location: + type: string + asn: + type: number + asOrg: + type: string + tunnelTime: + type: object + properties: + seconds: number + dataTransferred: + type: object + properties: + bytes: number + accessKeys: + type: array + items: + type: object + properties: + accessKeyId: + type: string + tunnelTime: + type: object + properties: + seconds: number + dataTransferred: + type: object + properties: + bytes: number + examples: + '0': + value: '{"server":[{"location":"US","asn":null,"asOrg":null,"tunnelTime":{"seconds":100},"dataTransferred":{"bytes":100}}],"accessKeys":[{"accessKeyId":"0","tunnelTime":{"seconds":100},"dataTransferred":{"bytes":100}}]}' /name: put: description: Renames the server diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index ee5c661a0..8f52c18d0 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -24,7 +24,7 @@ import {RealClock} from '../infrastructure/clock'; import {PortProvider} from '../infrastructure/get_port'; import * as json_config from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; -import {PrometheusClient, startPrometheus} from '../infrastructure/prometheus_scraper'; +import {ApiPrometheusClient, startPrometheus} from '../infrastructure/prometheus_scraper'; import {RolloutTracker} from '../infrastructure/rollout'; import * as version from './version'; @@ -197,7 +197,7 @@ async function main() { prometheusEndpoint ); - const prometheusClient = new PrometheusClient(prometheusEndpoint); + const prometheusClient = new ApiPrometheusClient(prometheusEndpoint); if (!serverConfig.data().portForNewAccessKeys) { serverConfig.data().portForNewAccessKeys = await portProvider.reserveNewPort(); serverConfig.write(); diff --git a/src/shadowbox/server/manager_metrics.spec.ts b/src/shadowbox/server/manager_metrics.spec.ts index d13a6c1ee..13bcc6d0e 100644 --- a/src/shadowbox/server/manager_metrics.spec.ts +++ b/src/shadowbox/server/manager_metrics.spec.ts @@ -13,9 +13,200 @@ // limitations under the License. import {PrometheusManagerMetrics} from './manager_metrics'; +import {PrometheusClient, QueryResultData} from '../infrastructure/prometheus_scraper'; import {FakePrometheusClient} from './mocks/mocks'; +export class QueryMapPrometheusClient implements PrometheusClient { + constructor(private queryMap: {[query: string]: QueryResultData}) {} + + async query(_query: string): Promise { + return this.queryMap[_query]; + } +} + describe('PrometheusManagerMetrics', () => { + it('getServerMetrics', async (done) => { + const managerMetrics = new PrometheusManagerMetrics( + new QueryMapPrometheusClient({ + 'sum(increase(shadowsocks_data_bytes_per_location{dir=~"ct"}[0s])) by (location, asn, asorg)': + { + resultType: 'vector', + result: [ + { + metric: { + location: 'US', + asn: '49490', + asorg: null, + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_tunnel_time_seconds_per_location[0s])) by (location, asn, asorg)': + { + resultType: 'vector', + result: [ + { + metric: { + location: 'US', + asn: '49490', + asorg: null, + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_data_bytes{dir=~"ct"}[0s])) by (access_key)': { + resultType: 'vector', + result: [ + { + metric: { + access_key: '0', + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_tunnel_time_seconds[0s])) by (access_key)': { + resultType: 'vector', + result: [ + { + metric: { + access_key: '0', + }, + value: [null, '1000'], + }, + ], + }, + }) + ); + + const serverMetrics = await managerMetrics.getServerMetrics({seconds: 0}); + + expect(JSON.stringify(serverMetrics, null, 2)).toEqual(`{ + "server": [ + { + "location": "US", + "asn": 49490, + "asOrg": "null", + "tunnelTime": { + "seconds": 1000 + }, + "dataTransferred": { + "bytes": 1000 + } + } + ], + "accessKeys": [ + { + "accessKeyId": 0, + "tunnelTime": { + "seconds": 1000 + }, + "dataTransferred": { + "bytes": 1000 + } + } + ] +}`); + done(); + }); + + it('getServerMetrics - does a full outer join on metric data', async (done) => { + const managerMetrics = new PrometheusManagerMetrics( + new QueryMapPrometheusClient({ + 'sum(increase(shadowsocks_data_bytes_per_location{dir=~"ct"}[0s])) by (location, asn, asorg)': + { + resultType: 'vector', + result: [ + { + metric: { + location: 'US', + asn: '49490', + asorg: null, + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_tunnel_time_seconds_per_location[0s])) by (location, asn, asorg)': + { + resultType: 'vector', + result: [ + { + metric: { + location: 'CA', + asn: '53520', + asorg: null, + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_data_bytes{dir=~"ct"}[0s])) by (access_key)': { + resultType: 'vector', + result: [ + { + metric: { + access_key: '0', + }, + value: [null, '1000'], + }, + ], + }, + 'sum(increase(shadowsocks_tunnel_time_seconds[0s])) by (access_key)': { + resultType: 'vector', + result: [ + { + metric: { + access_key: '1', + }, + value: [null, '1000'], + }, + ], + }, + }) + ); + + const serverMetrics = await managerMetrics.getServerMetrics({seconds: 0}); + + expect(JSON.stringify(serverMetrics, null, 2)).toEqual(`{ + "server": [ + { + "location": "CA", + "asn": 53520, + "asOrg": "null", + "tunnelTime": { + "seconds": 1000 + } + }, + { + "location": "US", + "asn": 49490, + "asOrg": "null", + "dataTransferred": { + "bytes": 1000 + } + } + ], + "accessKeys": [ + { + "accessKeyId": 1, + "tunnelTime": { + "seconds": 1000 + } + }, + { + "accessKeyId": 0, + "dataTransferred": { + "bytes": 1000 + } + } + ] +}`); + done(); + }); + it('getOutboundByteTransfer', async (done) => { const managerMetrics = new PrometheusManagerMetrics( new FakePrometheusClient({'access-key-1': 1000, 'access-key-2': 10000}) diff --git a/src/shadowbox/server/manager_metrics.ts b/src/shadowbox/server/manager_metrics.ts index cb8275f5b..c1bc09e2a 100644 --- a/src/shadowbox/server/manager_metrics.ts +++ b/src/shadowbox/server/manager_metrics.ts @@ -15,8 +15,36 @@ import {PrometheusClient} from '../infrastructure/prometheus_scraper'; import {DataUsageByUser, DataUsageTimeframe} from '../model/metrics'; +interface Duration { + seconds: number; +} + +interface Data { + bytes: number; +} + +interface ServerMetricsServerEntry { + location: string; + asn: number; + asOrg: string; + tunnelTime: Duration; + dataTransferred: Data; +} + +interface ServerMetricsAccessKeyEntry { + accessKeyId: number; + tunnelTime: Duration; + dataTransferred: Data; +} + +interface ServerMetrics { + server: ServerMetricsServerEntry[]; + accessKeys: ServerMetricsAccessKeyEntry[]; +} + export interface ManagerMetrics { getOutboundByteTransfer(timeframe: DataUsageTimeframe): Promise; + getServerMetrics(timeframe: Duration): Promise; } // Reads manager metrics from a Prometheus instance. @@ -40,4 +68,83 @@ export class PrometheusManagerMetrics implements ManagerMetrics { } return {bytesTransferredByUserId: usage}; } + + async getServerMetrics(timeframe: Duration): Promise { + const dataTransferredByLocation = await this.prometheusClient.query( + `sum(increase(shadowsocks_data_bytes_per_location{dir=~"ct"}[${timeframe.seconds}s])) by (location, asn, asorg)` + ); + const tunnelTimeByLocation = await this.prometheusClient.query( + `sum(increase(shadowsocks_tunnel_time_seconds_per_location[${timeframe.seconds}s])) by (location, asn, asorg)` + ); + const dataTransferredByAccessKey = await this.prometheusClient.query( + `sum(increase(shadowsocks_data_bytes{dir=~"ct"}[${timeframe.seconds}s])) by (access_key)` + ); + const tunnelTimeByAccessKey = await this.prometheusClient.query( + `sum(increase(shadowsocks_tunnel_time_seconds[${timeframe.seconds}s])) by (access_key)` + ); + + const serverMap = new Map(); + const serverMapKey = (entry) => + `${entry.metric['location']},${entry.metric['asn']},${entry.metric['asorg']}`; + for (const entry of tunnelTimeByLocation.result) { + serverMap.set(serverMapKey(entry), { + tunnelTime: { + seconds: parseFloat(entry.value[1]), + }, + }); + } + + for (const entry of dataTransferredByLocation.result) { + if (!serverMap.has(serverMapKey(entry))) { + serverMap.set(serverMapKey(entry), {}); + } + + serverMap.get(serverMapKey(entry)).dataTransferred = { + bytes: parseFloat(entry.value[1]), + }; + } + + const server = []; + for (const [key, metrics] of serverMap.entries()) { + const [location, asn, asOrg] = key.split(','); + server.push({ + location, + asn: parseInt(asn), + asOrg, + ...metrics, + }); + } + + const accessKeyMap = new Map(); + for (const entry of tunnelTimeByAccessKey.result) { + accessKeyMap.set(entry.metric['access_key'], { + tunnelTime: { + seconds: parseFloat(entry.value[1]), + }, + }); + } + + for (const entry of dataTransferredByAccessKey.result) { + if (!accessKeyMap.has(entry.metric['access_key'])) { + accessKeyMap.set(entry.metric['access_key'], {}); + } + + accessKeyMap.get(entry.metric['access_key']).dataTransferred = { + bytes: parseFloat(entry.value[1]), + }; + } + + const accessKeys = []; + for (const [key, metrics] of accessKeyMap.entries()) { + accessKeys.push({ + accessKeyId: parseInt(key), + ...metrics, + }); + } + + return { + server, + accessKeys, + }; + } } diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index a0ad4cd28..22e04c817 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -19,7 +19,7 @@ import * as restify from 'restify'; import {InMemoryConfig, JsonConfig} from '../infrastructure/json_config'; import {AccessKey, AccessKeyRepository, DataLimit} from '../model/access_key'; import {ManagerMetrics} from './manager_metrics'; -import {bindService, ShadowsocksManagerService} from './manager_service'; +import {bindService, ShadowsocksManagerService, convertTimeRangeToSeconds} from './manager_service'; import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks'; import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key'; import {ServerConfigJson} from './server_config'; @@ -1200,6 +1200,20 @@ describe('bindService', () => { }); }); +describe('convertTimeRangeToHours', () => { + it('properly parses time ranges', () => { + expect(convertTimeRangeToSeconds('30d')).toEqual(30 * 24 * 60 * 60); + expect(convertTimeRangeToSeconds('20h')).toEqual(20 * 60 * 60); + expect(convertTimeRangeToSeconds('3w')).toEqual(7 * 3 * 24 * 60 * 60); + }); + + it('throws when an invalid time range is provided', () => { + expect(() => convertTimeRangeToSeconds('30dd')).toThrow(); + expect(() => convertTimeRangeToSeconds('hi mom')).toThrow(); + expect(() => convertTimeRangeToSeconds('1j')).toThrow(); + }); +}); + class ShadowsocksManagerServiceBuilder { private defaultServerName_ = 'default name'; private serverConfig_: JsonConfig = null; diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index ecff3a61a..91bd25f4b 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -75,10 +75,19 @@ interface RequestParams { // method: string [param: string]: unknown; } + +// Type to reflect that we recive an untyped query string +interface RequestQuery { + // Supported parameters: + // since: string + [param: string]: unknown; +} + // Simplified request and response type interfaces containing only the // properties we actually use, to make testing easier. interface RequestType { params: RequestParams; + query?: RequestQuery; } interface ResponseType { send(code: number, data?: {}): void; @@ -122,6 +131,7 @@ export function bindService( apiServer.put(`${apiPrefix}/name`, service.renameServer.bind(service)); apiServer.get(`${apiPrefix}/server`, service.getServer.bind(service)); + apiServer.get(`${apiPrefix}/experimental/server/metrics`, service.getServerMetrics.bind(service)); apiServer.put( `${apiPrefix}/server/access-key-data-limit`, service.setDefaultDataLimit.bind(service) @@ -180,6 +190,24 @@ function redirect(url: string): restify.RequestHandlerType { }; } +export function convertTimeRangeToSeconds(timeRange: string): number { + const TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER = { + s: 1, + h: 60 * 60, + d: 24 * 60 * 60, + w: 7 * 24 * 60 * 60, + }; + + const timeRangeValue = Number(timeRange.slice(0, -1)); + const timeRangeUnit = timeRange.slice(-1); + + if (isNaN(timeRangeValue) || !TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER[timeRangeUnit]) { + throw new TypeError(`Invalid time range: ${timeRange}`); + } + + return timeRangeValue * TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER[timeRangeUnit]; +} + function validateAccessKeyId(accessKeyId: unknown): string { if (!accessKeyId) { throw new restifyErrors.MissingParameterError({statusCode: 400}, 'Parameter `id` is missing'); @@ -599,6 +627,34 @@ export class ShadowsocksManagerService { } } + async getServerMetrics(req: RequestType, res: ResponseType, next: restify.Next) { + logging.debug(`getServerMetrics request ${JSON.stringify(req.params)}`); + + let seconds; + try { + if (!req.query?.since) { + return next( + new restifyErrors.MissingParameterError({statusCode: 400}, 'Parameter `since` is missing') + ); + } + + seconds = convertTimeRangeToSeconds(req.query.since as string); + } catch (error) { + logging.error(error); + return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message)); + } + + try { + const response = await this.managerMetrics.getServerMetrics({seconds}); + res.send(HttpSuccess.OK, response); + logging.debug(`getServerMetrics response ${JSON.stringify(response)}`); + return next(); + } catch (error) { + logging.error(error); + return next(new restifyErrors.InternalServerError()); + } + } + getShareMetrics(req: RequestType, res: ResponseType, next: restify.Next): void { logging.debug(`getShareMetrics request ${JSON.stringify(req.params)}`); const response = {metricsEnabled: this.metricsPublisher.isSharingEnabled()}; diff --git a/src/shadowbox/server/mocks/mocks.ts b/src/shadowbox/server/mocks/mocks.ts index 58f6a6458..a99cd5a72 100644 --- a/src/shadowbox/server/mocks/mocks.ts +++ b/src/shadowbox/server/mocks/mocks.ts @@ -48,10 +48,8 @@ export class FakeShadowsocksServer implements ShadowsocksServer { } } -export class FakePrometheusClient extends PrometheusClient { - constructor(public bytesTransferredById: {[accessKeyId: string]: number}) { - super(''); - } +export class FakePrometheusClient implements PrometheusClient { + constructor(public bytesTransferredById: {[accessKeyId: string]: number}) {} async query(_query: string): Promise { const queryResultData = {result: []} as QueryResultData;