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(metrics_server): do not invalidate legacy report types #1532

Merged
merged 15 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 64 additions & 51 deletions src/metrics_server/connection_metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,22 @@ const VALID_USER_REPORT2: HourlyUserConnectionMetricsReport = {
};

/*
* A user report to test legacy fields to ensure backwards compatibility with
* older servers that may still send reports like this.
* Legacy access key user reports to ensure backwards compatibility with servers not
* synced past https://github.com/Jigsaw-Code/outline-server/pull/1529).
*/
const LEGACY_USER_REPORT = {
const LEGACY_PER_KEY_USER_REPORT: HourlyUserConnectionMetricsReport = {
userId: 'foo',
bytesTransferred: 123,
};

/*
* Legacy multiple countries user reports to ensure backwards compatibility with servers
* not synced past https://github.com/Jigsaw-Code/outline-server/pull/1242.
*/
const LEGACY_PER_LOCATION_USER_REPORT: HourlyUserConnectionMetricsReport = {
userId: 'foobar',
countries: ['US', 'UK'],
bytesTransferred: 123,
tunnelTimeSec: 789,
};

const VALID_REPORT: HourlyConnectionMetricsReport = {
Expand All @@ -49,10 +57,17 @@ const VALID_REPORT: HourlyConnectionMetricsReport = {
userReports: [
structuredClone(VALID_USER_REPORT),
structuredClone(VALID_USER_REPORT2),
structuredClone(LEGACY_USER_REPORT),
structuredClone(LEGACY_PER_LOCATION_USER_REPORT),
],
};

const LEGACY_REPORT: HourlyConnectionMetricsReport = {
serverId: 'legacy-id',
sbruens marked this conversation as resolved.
Show resolved Hide resolved
startUtcMs: 3,
endUtcMs: 4,
userReports: [structuredClone(LEGACY_PER_KEY_USER_REPORT)],
};

class FakeConnectionsTable implements InsertableTable<ConnectionRow> {
public rows: ConnectionRow[] | undefined;

Expand All @@ -62,61 +77,57 @@ class FakeConnectionsTable implements InsertableTable<ConnectionRow> {
}

describe('postConnectionMetrics', () => {
it('correctly inserts feature metrics rows', async () => {
it('correctly inserts connection metrics rows', async () => {
const table = new FakeConnectionsTable();
const userReports = [
{
countries: ['UK'],
bytesTransferred: 123,
tunnelTimeSec: 987,
},
{
countries: ['EC'],
bytesTransferred: 456,
tunnelTimeSec: 654,
},
{
countries: ['BR'],
bytesTransferred: 789,
},
];
const report = {serverId: 'id', startUtcMs: 1, endUtcMs: 2, userReports};
await postConnectionMetrics(table, report);

await postConnectionMetrics(table, VALID_REPORT);

const rows: ConnectionRow[] = [
{
serverId: report.serverId,
startTimestamp: new Date(report.startUtcMs).toISOString(),
endTimestamp: new Date(report.endUtcMs).toISOString(),
bytesTransferred: userReports[0].bytesTransferred,
tunnelTimeSec: userReports[0].tunnelTimeSec,
countries: userReports[0].countries,
serverId: VALID_REPORT.serverId,
startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
bytesTransferred: VALID_USER_REPORT.bytesTransferred,
tunnelTimeSec: VALID_USER_REPORT.tunnelTimeSec,
countries: VALID_USER_REPORT.countries,
},
{
serverId: report.serverId,
startTimestamp: new Date(report.startUtcMs).toISOString(),
endTimestamp: new Date(report.endUtcMs).toISOString(),
bytesTransferred: userReports[1].bytesTransferred,
tunnelTimeSec: userReports[1].tunnelTimeSec,
countries: userReports[1].countries,
serverId: VALID_REPORT.serverId,
startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
bytesTransferred: VALID_USER_REPORT2.bytesTransferred,
tunnelTimeSec: VALID_USER_REPORT2.tunnelTimeSec,
countries: VALID_USER_REPORT2.countries,
},
{
serverId: report.serverId,
startTimestamp: new Date(report.startUtcMs).toISOString(),
endTimestamp: new Date(report.endUtcMs).toISOString(),
bytesTransferred: userReports[2].bytesTransferred,
tunnelTimeSec: undefined,
countries: userReports[2].countries,
serverId: VALID_REPORT.serverId,
startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
bytesTransferred: LEGACY_PER_LOCATION_USER_REPORT.bytesTransferred,
tunnelTimeSec: LEGACY_PER_LOCATION_USER_REPORT.tunnelTimeSec,
countries: LEGACY_PER_LOCATION_USER_REPORT.countries,
},
];
expect(table.rows).toEqual(rows);
});
it('correctly drops legacy connection metrics', async () => {
const table = new FakeConnectionsTable();

await postConnectionMetrics(table, LEGACY_REPORT);

expect(table.rows).toEqual([]);
});
});

describe('isValidConnectionMetricsReport', () => {
it('returns true for valid report', () => {
const report = structuredClone(VALID_REPORT);
expect(isValidConnectionMetricsReport(report)).toBeTrue();
});
it('returns true for legacy report', () => {
const report = structuredClone(LEGACY_REPORT);
expect(isValidConnectionMetricsReport(report)).toBeTrue();
});
it('returns false for missing report', () => {
expect(isValidConnectionMetricsReport(undefined)).toBeFalse();
});
Expand Down Expand Up @@ -166,14 +177,6 @@ describe('isValidConnectionMetricsReport', () => {
delete report['endUtcMs'];
expect(isValidConnectionMetricsReport(report)).toBeFalse();
});
it('returns false for missing user report field `countries`', () => {
const report = structuredClone(VALID_REPORT);
const userReport: Partial<HourlyUserConnectionMetricsReport> =
structuredClone(VALID_USER_REPORT);
delete userReport['countries'];
report.userReports[0] = userReport as HourlyUserConnectionMetricsReport;
expect(isValidConnectionMetricsReport(report)).toBeFalse();
});
it('returns false for missing user report field `bytesTransferred`', () => {
const report = structuredClone(VALID_REPORT);
const userReport: Partial<HourlyUserConnectionMetricsReport> =
Expand Down Expand Up @@ -202,7 +205,17 @@ describe('isValidConnectionMetricsReport', () => {
report.endUtcMs = '100' as unknown as number;
expect(isValidConnectionMetricsReport(report)).toBeFalse();
});
it('returns false for `countries` field type that is not a string', () => {
it('returns false for `userId` field type that is not a string', () => {
const report = structuredClone(VALID_REPORT);
report.userReports[0].userId = 1234 as unknown as string;
expect(isValidConnectionMetricsReport(report)).toBeFalse();
});
it('returns false for `countries` field type that is not an array', () => {
const report = structuredClone(VALID_REPORT);
report.userReports[0].countries = 'US' as unknown as string[];
expect(isValidConnectionMetricsReport(report)).toBeFalse();
});
it('returns false for `countries` arry items that are not strings', () => {
const report = structuredClone(VALID_REPORT);
report.userReports[0].countries = [1, 2, 3] as unknown as string[];
expect(isValidConnectionMetricsReport(report)).toBeFalse();
Expand Down
68 changes: 46 additions & 22 deletions src/metrics_server/connection_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@

import {Table} from '@google-cloud/bigquery';
import {InsertableTable} from './infrastructure/table';
import {HourlyConnectionMetricsReport} from './model';
import {
HourlyConnectionMetricsReport,
HourlyUserConnectionMetricsReport,
HourlyUserConnectionMetricsReportByLocation,
} from './model';

const TERABYTE = Math.pow(2, 40);

export interface ConnectionRow {
serverId: string;
Expand Down Expand Up @@ -45,18 +51,29 @@ function getConnectionRowsFromReport(report: HourlyConnectionMetricsReport): Con
const endTimestampStr = new Date(report.endUtcMs).toISOString();
const rows = [];
for (const userReport of report.userReports) {
rows.push({
serverId: report.serverId,
startTimestamp: startTimestampStr,
endTimestamp: endTimestampStr,
bytesTransferred: userReport.bytesTransferred,
tunnelTimeSec: userReport.tunnelTimeSec || undefined,
countries: userReport.countries || [],
});
// User reports come in 2 flavors: "per location" and "per key". We no longer store the
// "per key" reports.
if (isPerLocationUserReport(userReport)) {
rows.push({
serverId: report.serverId,
startTimestamp: startTimestampStr,
endTimestamp: endTimestampStr,
bytesTransferred: userReport.bytesTransferred,
tunnelTimeSec: userReport.tunnelTimeSec || undefined,
countries: userReport.countries,
});
}
}
return rows;
}

function isPerLocationUserReport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userReport: HourlyUserConnectionMetricsReport
): userReport is HourlyUserConnectionMetricsReportByLocation {
return 'countries' in userReport;
}

// Returns true iff testObject contains a valid HourlyConnectionMetricsReport.
export function isValidConnectionMetricsReport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -93,14 +110,21 @@ export function isValidConnectionMetricsReport(
return false;
}

const MIN_BYTES_TRANSFERRED = 0;
const MAX_BYTES_TRANSFERRED = 1 * Math.pow(2, 40); // 1 TB.
for (const userReport of testObject.userReports) {
// Check that `bytesTransferred` is a number between min and max transfer limits
// Check that `userId` is a string.
if (userReport.userId && typeof userReport.userId !== 'string') {
return false;
}

// We used to set a limit of 1TB per access key, then per location. We later
// realized that a server may use a single key, or all the traffic may come
// from a single location.
// However, as we report hourly, it's unlikely we hit 1TB, so we keep the
// check for now to try and prevent malicious reports.
if (
typeof userReport.bytesTransferred !== 'number' ||
userReport.bytesTransferred < MIN_BYTES_TRANSFERRED ||
userReport.bytesTransferred > MAX_BYTES_TRANSFERRED
userReport.bytesTransferred < 0 ||
userReport.bytesTransferred > TERABYTE
) {
return false;
}
Expand All @@ -112,16 +136,16 @@ export function isValidConnectionMetricsReport(
return false;
}

// We require at least 1 country to be set
const countries = userReport.countries ?? [];
if (countries.length === 0) {
return false;
}
// Check that all `countries` are strings.
for (const country of countries) {
if (typeof country !== 'string') {
// Check that `countries` is an array of strings.
if (userReport.countries) {
if (!Array.isArray(userReport.countries)) {
return false;
}
for (const country of userReport.countries) {
if (typeof country !== 'string') {
return false;
}
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/metrics_server/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ export interface HourlyConnectionMetricsReport {
}

export interface HourlyUserConnectionMetricsReport {
countries: string[];
userId?: string;
countries?: string[];
bytesTransferred: number;
tunnelTimeSec?: number;
}

export interface HourlyUserConnectionMetricsReportByLocation
extends Omit<HourlyUserConnectionMetricsReport, 'countries'> {
countries: string[];
}

export interface DailyFeatureMetricsReport {
serverId: string;
serverVersion: string;
Expand Down
Loading