Skip to content

Commit

Permalink
feat(server): add ASN metric to opt-in server usage report (#1610)
Browse files Browse the repository at this point in the history
* feat(server): add ASN metric to opt-in server usage report

* Rename `LocationUsage` to `ReportedUsage`.

* Add test cases for different ASN+country combinations.
  • Loading branch information
sbruens authored Oct 17, 2024
1 parent 91046e3 commit 845c023
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 25 deletions.
101 changes: 93 additions & 8 deletions src/shadowbox/server/shared_metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {AccessKeyConfigJson} from './server_access_key';

import {ServerConfigJson} from './server_config';
import {
CountryUsage,
ReportedUsage,
DailyFeatureMetricsReportJson,
HourlyServerMetricsReportJson,
MetricsCollectorClient,
Expand Down Expand Up @@ -78,7 +78,7 @@ describe('OutlineSharedMetricsPublisher', () => {
);

publisher.startSharing();
usageMetrics.countryUsage = [
usageMetrics.reportedUsage = [
{country: 'AA', inboundBytes: 11},
{country: 'BB', inboundBytes: 11},
{country: 'CC', inboundBytes: 22},
Expand All @@ -102,7 +102,7 @@ describe('OutlineSharedMetricsPublisher', () => {
});

startTime = clock.nowMs;
usageMetrics.countryUsage = [
usageMetrics.reportedUsage = [
{country: 'EE', inboundBytes: 44},
{country: 'FF', inboundBytes: 55},
];
Expand All @@ -121,6 +121,91 @@ describe('OutlineSharedMetricsPublisher', () => {

publisher.stopSharing();
});

it('reports ASN metrics correctly', async () => {
const clock = new ManualClock();
const serverConfig = new InMemoryConfig<ServerConfigJson>({serverId: 'server-id'});
const usageMetrics = new ManualUsageMetrics();
const metricsCollector = new FakeMetricsCollector();
const publisher = new OutlineSharedMetricsPublisher(
clock,
serverConfig,
null,
usageMetrics,
metricsCollector
);
publisher.startSharing();

usageMetrics.reportedUsage = [
{country: 'DD', asn: 999, inboundBytes: 44},
{country: 'EE', inboundBytes: 55},
];
clock.nowMs += 60 * 60 * 1000;
await clock.runCallbacks();

expect(metricsCollector.collectedServerUsageReport.userReports).toEqual([
{bytesTransferred: 44, countries: ['DD'], asn: 999},
{bytesTransferred: 55, countries: ['EE']},
]);
publisher.stopSharing();
});

it('reports different ASNs in the same country correctly', async () => {
const clock = new ManualClock();
const serverConfig = new InMemoryConfig<ServerConfigJson>({serverId: 'server-id'});
const usageMetrics = new ManualUsageMetrics();
const metricsCollector = new FakeMetricsCollector();
const publisher = new OutlineSharedMetricsPublisher(
clock,
serverConfig,
null,
usageMetrics,
metricsCollector
);
publisher.startSharing();

usageMetrics.reportedUsage = [
{country: 'DD', asn: 999, inboundBytes: 44},
{country: 'DD', asn: 888, inboundBytes: 55},
];
clock.nowMs += 60 * 60 * 1000;
await clock.runCallbacks();

expect(metricsCollector.collectedServerUsageReport.userReports).toEqual([
{bytesTransferred: 44, countries: ['DD'], asn: 999},
{bytesTransferred: 55, countries: ['DD'], asn: 888},
]);
publisher.stopSharing();
});

it('reports the same ASNs across different countries correctly', async () => {
const clock = new ManualClock();
const serverConfig = new InMemoryConfig<ServerConfigJson>({serverId: 'server-id'});
const usageMetrics = new ManualUsageMetrics();
const metricsCollector = new FakeMetricsCollector();
const publisher = new OutlineSharedMetricsPublisher(
clock,
serverConfig,
null,
usageMetrics,
metricsCollector
);
publisher.startSharing();

usageMetrics.reportedUsage = [
{country: 'DD', asn: 999, inboundBytes: 44},
{country: 'EE', asn: 999, inboundBytes: 66},
];
clock.nowMs += 60 * 60 * 1000;
await clock.runCallbacks();

expect(metricsCollector.collectedServerUsageReport.userReports).toEqual([
{bytesTransferred: 44, countries: ['DD'], asn: 999},
{bytesTransferred: 66, countries: ['EE'], asn: 999},
]);
publisher.stopSharing();
});

it('ignores sanctioned countries', async () => {
const clock = new ManualClock();
const startTime = clock.nowMs;
Expand All @@ -136,7 +221,7 @@ describe('OutlineSharedMetricsPublisher', () => {
);

publisher.startSharing();
usageMetrics.countryUsage = [
usageMetrics.reportedUsage = [
{country: 'AA', inboundBytes: 11},
{country: 'SY', inboundBytes: 11},
{country: 'CC', inboundBytes: 22},
Expand Down Expand Up @@ -257,13 +342,13 @@ class FakeMetricsCollector implements MetricsCollectorClient {
}

class ManualUsageMetrics implements UsageMetrics {
countryUsage = [] as CountryUsage[];
reportedUsage = [] as ReportedUsage[];

getCountryUsage(): Promise<CountryUsage[]> {
return Promise.resolve(this.countryUsage);
getReportedUsage(): Promise<ReportedUsage[]> {
return Promise.resolve(this.reportedUsage);
}

reset() {
this.countryUsage = [] as CountryUsage[];
this.reportedUsage = [] as ReportedUsage[];
}
}
41 changes: 24 additions & 17 deletions src/shadowbox/server/shared_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ const MS_PER_HOUR = 60 * 60 * 1000;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const SANCTIONED_COUNTRIES = new Set(['CU', 'KP', 'SY']);

export interface CountryUsage {
export interface ReportedUsage {
country: string;
asn?: number;
inboundBytes: number;
}

Expand All @@ -44,6 +45,7 @@ export interface HourlyServerMetricsReportJson {
// Field renames will break backwards-compatibility.
export interface HourlyUserMetricsReportJson {
countries: string[];
asn?: number;
bytesTransferred: number;
}

Expand All @@ -70,7 +72,7 @@ export interface SharedMetricsPublisher {
}

export interface UsageMetrics {
getCountryUsage(): Promise<CountryUsage[]>;
getReportedUsage(): Promise<ReportedUsage[]>;
reset();
}

Expand All @@ -80,17 +82,18 @@ export class PrometheusUsageMetrics implements UsageMetrics {

constructor(private prometheusClient: PrometheusClient) {}

async getCountryUsage(): Promise<CountryUsage[]> {
async getReportedUsage(): Promise<ReportedUsage[]> {
const timeDeltaSecs = Math.round((Date.now() - this.resetTimeMs) / 1000);
// We measure the traffic to and from the target, since that's what we are protecting.
const result = await this.prometheusClient.query(
`sum(increase(shadowsocks_data_bytes_per_location{dir=~"p>t|p<t"}[${timeDeltaSecs}s])) by (location)`
`sum(increase(shadowsocks_data_bytes_per_location{dir=~"p>t|p<t"}[${timeDeltaSecs}s])) by (location, asn)`
);
const usage = [] as CountryUsage[];
const usage = [] as ReportedUsage[];
for (const entry of result.result) {
const country = entry.metric['location'] || '';
const asn = entry.metric['asn'] ? Number(entry.metric['asn']) : undefined;
const inboundBytes = Math.round(parseFloat(entry.value[1]));
usage.push({country, inboundBytes});
usage.push({country, inboundBytes, asn});
}
return usage;
}
Expand All @@ -105,7 +108,7 @@ export interface MetricsCollectorClient {
collectFeatureMetrics(reportJson: DailyFeatureMetricsReportJson): Promise<void>;
}

export class RestMetricsCollectorClient {
export class RestMetricsCollectorClient implements MetricsCollectorClient {
constructor(private serviceUrl: string) {}

collectServerUsageMetrics(reportJson: HourlyServerMetricsReportJson): Promise<void> {
Expand Down Expand Up @@ -163,7 +166,7 @@ export class OutlineSharedMetricsPublisher implements SharedMetricsPublisher {
return;
}
try {
await this.reportServerUsageMetrics(await usageMetrics.getCountryUsage());
await this.reportServerUsageMetrics(await usageMetrics.getReportedUsage());
usageMetrics.reset();
} catch (err) {
logging.error(`Failed to report server usage metrics: ${err}`);
Expand Down Expand Up @@ -197,24 +200,28 @@ export class OutlineSharedMetricsPublisher implements SharedMetricsPublisher {
return this.serverConfig.data().metricsEnabled || false;
}

private async reportServerUsageMetrics(countryUsageMetrics: CountryUsage[]): Promise<void> {
private async reportServerUsageMetrics(locationUsageMetrics: ReportedUsage[]): Promise<void> {
const reportEndTimestampMs = this.clock.now();

const userReports = [] as HourlyUserMetricsReportJson[];
for (const countryUsage of countryUsageMetrics) {
if (countryUsage.inboundBytes === 0) {
const userReports: HourlyUserMetricsReportJson[] = [];
for (const locationUsage of locationUsageMetrics) {
if (locationUsage.inboundBytes === 0) {
continue;
}
if (isSanctionedCountry(countryUsage.country)) {
if (isSanctionedCountry(locationUsage.country)) {
continue;
}
// Make sure to always set a country, which is required by the metrics server validation.
// It's used to differentiate the row from the legacy key usage rows.
const country = countryUsage.country || 'ZZ';
userReports.push({
bytesTransferred: countryUsage.inboundBytes,
const country = locationUsage.country || 'ZZ';
const report: HourlyUserMetricsReportJson = {
bytesTransferred: locationUsage.inboundBytes,
countries: [country],
});
};
if (locationUsage.asn) {
report.asn = locationUsage.asn;
}
userReports.push(report);
}
const report = {
serverId: this.serverConfig.data().serverId,
Expand Down

0 comments on commit 845c023

Please sign in to comment.