From 845c02373b46d9ed38ed153be712906c439c690b Mon Sep 17 00:00:00 2001 From: Sander Bruens Date: Thu, 17 Oct 2024 13:05:30 -0400 Subject: [PATCH] feat(server): add ASN metric to opt-in server usage report (#1610) * feat(server): add ASN metric to opt-in server usage report * Rename `LocationUsage` to `ReportedUsage`. * Add test cases for different ASN+country combinations. --- src/shadowbox/server/shared_metrics.spec.ts | 101 ++++++++++++++++++-- src/shadowbox/server/shared_metrics.ts | 41 ++++---- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/shadowbox/server/shared_metrics.spec.ts b/src/shadowbox/server/shared_metrics.spec.ts index 868a7330c..5e21b1ace 100644 --- a/src/shadowbox/server/shared_metrics.spec.ts +++ b/src/shadowbox/server/shared_metrics.spec.ts @@ -20,7 +20,7 @@ import {AccessKeyConfigJson} from './server_access_key'; import {ServerConfigJson} from './server_config'; import { - CountryUsage, + ReportedUsage, DailyFeatureMetricsReportJson, HourlyServerMetricsReportJson, MetricsCollectorClient, @@ -78,7 +78,7 @@ describe('OutlineSharedMetricsPublisher', () => { ); publisher.startSharing(); - usageMetrics.countryUsage = [ + usageMetrics.reportedUsage = [ {country: 'AA', inboundBytes: 11}, {country: 'BB', inboundBytes: 11}, {country: 'CC', inboundBytes: 22}, @@ -102,7 +102,7 @@ describe('OutlineSharedMetricsPublisher', () => { }); startTime = clock.nowMs; - usageMetrics.countryUsage = [ + usageMetrics.reportedUsage = [ {country: 'EE', inboundBytes: 44}, {country: 'FF', inboundBytes: 55}, ]; @@ -121,6 +121,91 @@ describe('OutlineSharedMetricsPublisher', () => { publisher.stopSharing(); }); + + it('reports ASN metrics correctly', async () => { + const clock = new ManualClock(); + const serverConfig = new InMemoryConfig({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({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({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; @@ -136,7 +221,7 @@ describe('OutlineSharedMetricsPublisher', () => { ); publisher.startSharing(); - usageMetrics.countryUsage = [ + usageMetrics.reportedUsage = [ {country: 'AA', inboundBytes: 11}, {country: 'SY', inboundBytes: 11}, {country: 'CC', inboundBytes: 22}, @@ -257,13 +342,13 @@ class FakeMetricsCollector implements MetricsCollectorClient { } class ManualUsageMetrics implements UsageMetrics { - countryUsage = [] as CountryUsage[]; + reportedUsage = [] as ReportedUsage[]; - getCountryUsage(): Promise { - return Promise.resolve(this.countryUsage); + getReportedUsage(): Promise { + return Promise.resolve(this.reportedUsage); } reset() { - this.countryUsage = [] as CountryUsage[]; + this.reportedUsage = [] as ReportedUsage[]; } } diff --git a/src/shadowbox/server/shared_metrics.ts b/src/shadowbox/server/shared_metrics.ts index f1ab67a3a..c0076e35d 100644 --- a/src/shadowbox/server/shared_metrics.ts +++ b/src/shadowbox/server/shared_metrics.ts @@ -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; } @@ -44,6 +45,7 @@ export interface HourlyServerMetricsReportJson { // Field renames will break backwards-compatibility. export interface HourlyUserMetricsReportJson { countries: string[]; + asn?: number; bytesTransferred: number; } @@ -70,7 +72,7 @@ export interface SharedMetricsPublisher { } export interface UsageMetrics { - getCountryUsage(): Promise; + getReportedUsage(): Promise; reset(); } @@ -80,17 +82,18 @@ export class PrometheusUsageMetrics implements UsageMetrics { constructor(private prometheusClient: PrometheusClient) {} - async getCountryUsage(): Promise { + async getReportedUsage(): Promise { 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|pt|p; } -export class RestMetricsCollectorClient { +export class RestMetricsCollectorClient implements MetricsCollectorClient { constructor(private serviceUrl: string) {} collectServerUsageMetrics(reportJson: HourlyServerMetricsReportJson): Promise { @@ -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}`); @@ -197,24 +200,28 @@ export class OutlineSharedMetricsPublisher implements SharedMetricsPublisher { return this.serverConfig.data().metricsEnabled || false; } - private async reportServerUsageMetrics(countryUsageMetrics: CountryUsage[]): Promise { + private async reportServerUsageMetrics(locationUsageMetrics: ReportedUsage[]): Promise { 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,