Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): unified metrics endpoint #1616

Merged
merged 13 commits into from
Nov 22, 2024
125 changes: 123 additions & 2 deletions src/shadowbox/server/manager_metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,133 @@
// limitations under the License.

import {PrometheusManagerMetrics} from './manager_metrics';
import {FakePrometheusClient} from './mocks/mocks';
import {FakeAccessKeyPrometheusClient} from './mocks/mocks';
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved

describe('PrometheusManagerMetrics', () => {
it('getServerMetrics', async (done) => {
const managerMetrics = new PrometheusManagerMetrics(
new FakeAccessKeyPrometheusClient([
{
accessKeyId: 0,
location: 'US',
asn: 49490,
asOrg: null,
dataTransferred: {
bytes: 50000,
},
tunnelTime: {
seconds: 10000,
},
},
{
accessKeyId: 1,
location: 'US',
asn: 49490,
asOrg: null,
dataTransferred: {
bytes: 50000,
},
tunnelTime: {
seconds: 5000,
},
},
{
accessKeyId: 2,
location: 'CA',
asn: null,
asOrg: null,
dataTransferred: {
bytes: 40000,
},
tunnelTime: {
seconds: 7500,
},
},
])
);

const serverMetrics = await managerMetrics.getServerMetrics({hours: 0});

expect(JSON.stringify(serverMetrics, null, 2)).toEqual(`{
"server": [
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
{
"location": "US",
"asn": 49490,
"asOrg": "null",
"dataTransferred": {
"bytes": 100000
},
"tunnelTime": {
"seconds": 15000
}
},
{
"location": "CA",
"asn": null,
"asOrg": "null",
"dataTransferred": {
"bytes": 40000
},
"tunnelTime": {
"seconds": 7500
}
}
],
"accessKeys": [
{
"accessKeyId": 0,
"dataTransferred": {
"bytes": 50000
},
"tunnelTime": {
"seconds": 10000
}
},
{
"accessKeyId": 1,
"dataTransferred": {
"bytes": 50000
},
"tunnelTime": {
"seconds": 5000
}
},
{
"accessKeyId": 2,
"dataTransferred": {
"bytes": 40000
},
"tunnelTime": {
"seconds": 7500
}
}
]
}`);
done();
});

it('getOutboundByteTransfer', async (done) => {
const managerMetrics = new PrometheusManagerMetrics(
new FakePrometheusClient({'access-key-1': 1000, 'access-key-2': 10000})
new FakeAccessKeyPrometheusClient([
{
accessKeyId: 'access-key-1',
asn: null,
asOrg: null,
location: null,
dataTransferred: {
bytes: 1000,
},
},
{
accessKeyId: 'access-key-2',
asn: null,
asOrg: null,
location: null,
dataTransferred: {
bytes: 10000,
},
},
])
);
const dataUsage = await managerMetrics.getOutboundByteTransfer({hours: 0});
const bytesTransferredByUserId = dataUsage.bytesTransferredByUserId;
Expand Down
102 changes: 102 additions & 0 deletions src/shadowbox/server/manager_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,40 @@
import {PrometheusClient} from '../infrastructure/prometheus_scraper';
import {DataUsageByUser, DataUsageTimeframe} from '../model/metrics';

interface ServerMetricsTimeframe {
hours: number;
}

interface ServerMetricsServerEntry {
location: string;
asn: number;
asOrg: string;
tunnelTime?: {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
seconds: number;
};
dataTransferred: {
bytes: number;
};
}

interface ServerMetricsAccessKeyEntry {
accessKeyId: number;
tunnelTime?: {
seconds: number;
};
dataTransferred: {
bytes: number;
};
}

interface ServerMetrics {
server: ServerMetricsServerEntry[];
accessKeys: ServerMetricsAccessKeyEntry[];
}

export interface ManagerMetrics {
getOutboundByteTransfer(timeframe: DataUsageTimeframe): Promise<DataUsageByUser>;
getServerMetrics(timeframe: ServerMetricsTimeframe): Promise<ServerMetrics>;
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
}

// Reads manager metrics from a Prometheus instance.
Expand All @@ -40,4 +72,74 @@ export class PrometheusManagerMetrics implements ManagerMetrics {
}
return {bytesTransferredByUserId: usage};
}

async getServerMetrics({hours}: ServerMetricsTimeframe): Promise<ServerMetrics> {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
const dataTransferredByLocation = await this.prometheusClient.query(
`sum(increase(shadowsocks_data_bytes_per_location{dir=~"c<p|p>t"}[${hours}h])) by (location, asn, asorg)`
);
const tunnelTimeByLocation = await this.prometheusClient.query(
`sum(increase(shadowsocks_tunnel_time_seconds_per_location[${hours}h])) by (location, asn, asorg)`
);
const dataTransferredByAccessKey = await this.prometheusClient.query(
`sum(increase(shadowsocks_data_bytes{dir=~"c<p|p>t"}[${hours}h])) by (access_key)`
);
const tunnelTimeByAccessKey = await this.prometheusClient.query(
`sum(increase(shadowsocks_tunnel_time_seconds[${hours}h])) by (access_key)`
);

const server = [];
for (const entry of dataTransferredByLocation.result) {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
const result: ServerMetricsServerEntry = {
location: entry.metric['location'],
asn: parseInt(entry.metric['asn']),
asOrg: entry.metric['asorg'],
dataTransferred: {
bytes: parseFloat(entry.value[1]),
},
};

const matchingTunnelTimeResult = tunnelTimeByLocation.result.find((target) => {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
return (
entry.metric['location'] === target.metric['location'] &&
entry.metric['asn'] === target.metric['asn'] &&
entry.metric['asorg'] === target.metric['asorg']
);
});

if (matchingTunnelTimeResult) {
result.tunnelTime = {
seconds: parseFloat(matchingTunnelTimeResult.value[1]),
};
}

server.push(result);
}

const accessKeys = [];
for (const entry of dataTransferredByAccessKey.result) {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
const result: ServerMetricsAccessKeyEntry = {
accessKeyId: parseInt(entry.metric['access_key']),
dataTransferred: {
bytes: parseFloat(entry.value[1]),
},
};

const matchingTunnelTimeResult = tunnelTimeByAccessKey.result.find((target) => {
return entry.metric['access_key'] === target.metric['access_key'];
});

if (matchingTunnelTimeResult) {
result.tunnelTime = {
seconds: parseFloat(matchingTunnelTimeResult.value[1]),
};
}

accessKeys.push(result);
}

return {
server,
accessKeys,
};
}
}
44 changes: 44 additions & 0 deletions src/shadowbox/server/manager_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,18 @@ interface RequestParams {
// method: string
[param: string]: unknown;
}

interface RequestQuery {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
// 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;
Expand Down Expand Up @@ -122,6 +130,7 @@ export function bindService(

apiServer.put(`${apiPrefix}/name`, service.renameServer.bind(service));
apiServer.get(`${apiPrefix}/server`, service.getServer.bind(service));
apiServer.get(`${apiPrefix}/server/metrics`, service.getServerMetrics.bind(service));
apiServer.put(
`${apiPrefix}/server/access-key-data-limit`,
service.setDefaultDataLimit.bind(service)
Expand Down Expand Up @@ -599,6 +608,41 @@ export class ShadowsocksManagerService {
}
}

async getServerMetrics(req: RequestType, res: ResponseType, next: restify.Next) {
try {
logging.debug(`getServerMetrics request ${JSON.stringify(req.params)}`);

if (!req.query?.since) {
return next(
new restifyErrors.MissingParameterError({statusCode: 400}, 'Parameter `since` is missing')
);
}

const timestamp = new Date(req.query.since as string);
const isIsoTimestamp =
!isNaN(timestamp.getTime()) && timestamp.toISOString() === req.query.since;
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved

if (!isIsoTimestamp) {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
`'since' parameter must be an ISO timestamp. Got ${req.query.since}`
)
);
}

const hours = Math.floor((new Date().getTime() - timestamp.getTime()) / (1000 * 60 * 60));
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved

const response = await this.managerMetrics.getServerMetrics({hours});
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()};
Expand Down
75 changes: 75 additions & 0 deletions src/shadowbox/server/mocks/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,78 @@ export class FakePrometheusClient extends PrometheusClient {
return queryResultData;
}
}

interface FakeAccessKeyPrometheusClientMetric {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
accessKeyId: number | string;
location?: string;
asn?: number;
asOrg?: string;
tunnelTime?: {
seconds: number;
};
dataTransferred: {
bytes: number;
};
}

export class FakeAccessKeyPrometheusClient extends PrometheusClient {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
constructor(public rawAccessKeyMetrics: FakeAccessKeyPrometheusClientMetric[]) {
super('');
}

async query(_query: string): Promise<QueryResultData> {
const queryResultData = {result: []} as QueryResultData;

if (_query.startsWith('sum(increase(shadowsocks_data_bytes_per_location')) {
const locations = {};

for (const {location, asn, asOrg, dataTransferred} of this.rawAccessKeyMetrics) {
const locationKey = `${location},${asn},${asOrg}`;

locations[locationKey] ??= 0;
locations[locationKey] += dataTransferred.bytes;
}

for (const [locationKey, bytes] of Object.entries(locations)) {
const [location, asn, asorg] = locationKey.split(',');
queryResultData.result.push({
metric: {location, asn, asorg},
value: [Date.now() / 1000, `${bytes}`],
});
}
} else if (_query.startsWith('sum(increase(shadowsocks_tunnel_time_seconds_per_location')) {
const locations = {};

for (const {location, asn, asOrg, tunnelTime} of this.rawAccessKeyMetrics) {
const locationKey = `${location},${asn},${asOrg}`;

locations[locationKey] ??= 0;
locations[locationKey] += tunnelTime.seconds;
}

for (const [locationKey, seconds] of Object.entries(locations)) {
const [location, asn, asorg] = locationKey.split(',');
queryResultData.result.push({
metric: {location, asn, asorg},
value: [Date.now() / 1000, `${seconds}`],
});
}
} else if (_query.startsWith('sum(increase(shadowsocks_data_bytes')) {
for (const {accessKeyId, dataTransferred} of this.rawAccessKeyMetrics) {
queryResultData.result.push({
metric: {access_key: `${accessKeyId}`},
value: [Date.now() / 1000, `${dataTransferred.bytes}`],
});
}
} else if (_query.startsWith('sum(increase(shadowsocks_tunnel_time_seconds')) {
for (const {accessKeyId, tunnelTime} of this.rawAccessKeyMetrics) {
queryResultData.result.push({
metric: {access_key: `${accessKeyId}`},
value: [Date.now() / 1000, `${tunnelTime.seconds}`],
});
}
}

return queryResultData;
}
}
Loading