Skip to content

Commit

Permalink
chore: add function to generate some of the metrics report (#261)
Browse files Browse the repository at this point in the history
This introduces a (dormant) function for generating metrics reports.
It returns a result like this:

    {
      "type": "metrics-v1",
      "appVersion": "0.0.1",
      "os": "android",
      "osVersion": 10,
      "screen": { "width": 1024, "height": 768 },
      "countries": ["CAN", "USA", "MEX"]
    }

This isn't all the metrics we want to collect but I think it's a useful
starting point.
  • Loading branch information
EvanHahn authored Apr 23, 2024
1 parent cdf151a commit e1ba816
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 2 deletions.
74 changes: 72 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@formatjs/intl-relativetimeformat": "^11.2.4",
"@gorhom/bottom-sheet": "^4.5.1",
"@mapeo/ipc": "0.3.0",
"@osm_borders/maritime_10000m": "^1.1.0",
"@react-native-community/hooks": "^2.8.0",
"@react-native-community/netinfo": "11.1.0",
"@react-native-picker/picker": "2.6.1",
Expand All @@ -50,6 +51,7 @@
"expo-location": "~16.5.4",
"expo-secure-store": "~12.8.1",
"expo-sensors": "~12.9.1",
"geojson-geometries-lookup": "^0.5.0",
"lodash.isequal": "^4.5.0",
"nanoid": "^5.0.1",
"nodejs-mobile-react-native": "^18.17.7",
Expand Down Expand Up @@ -98,6 +100,7 @@
"@react-native/typescript-config": "^0.74.0",
"@testing-library/react-native": "^12.4.3",
"@types/debug": "^4.1.7",
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12",
"@types/lodash.isequal": "^4.5.6",
"@types/node": "^20.8.4",
Expand Down
105 changes: 105 additions & 0 deletions src/frontend/metrics/generateMetricsReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import generateMetricsReport from './generateMetricsReport';

describe('generateMetricsReport', () => {
const packageJson = readPackageJson();

const defaultOptions: Parameters<typeof generateMetricsReport>[0] = {
packageJson,
os: 'android',
osVersion: 123,
screen: {width: 12, height: 34},
observations: [
// Middle of the Atlantic
{lat: 10, lon: -33},
// Mexico City
{lat: 19.419914, lon: -99.088059},
// Machias Seal Island, disputed territory
{lat: 44.5, lon: -67.101111},
// To be ignored
{},
{lat: 12},
{lon: 34},
],
};

it('can be serialized and deserialized as JSON', () => {
const report = generateMetricsReport(defaultOptions);
const actual = JSON.parse(JSON.stringify(report));
const expected = removeUndefinedEntries(report);
expect(actual).toEqual(expected);
});

it('includes a report type', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.type).toBe('metrics-v1');
});

it('includes the app version', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.appVersion).toBe(packageJson.version);
});

it('includes the OS (Android style)', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.os).toBe('android');
expect(report.osVersion).toBe(123);
});

it('includes the OS (iOS style)', () => {
const options = {...defaultOptions, os: 'ios' as const, osVersion: '1.2.3'};
const report = generateMetricsReport(options);
expect(report.os).toBe('ios');
expect(report.osVersion).toBe('1.2.3');
});

it('includes the OS (desktop style)', () => {
const options = {
...defaultOptions,
os: 'win32' as const,
osVersion: '1.2.3',
};
const report = generateMetricsReport(options);
expect(report.os).toBe('win32');
expect(report.osVersion).toBe('1.2.3');
});

it('includes screen dimensions', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.screen).toEqual({width: 12, height: 34});
});

it("doesn't include countries if no observations are provided", () => {
const options = {...defaultOptions, observations: []};
const report = generateMetricsReport(options);
expect(report.countries).toBe(undefined);
});

it('includes countries where observations are found', () => {
const report = generateMetricsReport(defaultOptions);
expect(report.countries).toHaveLength(new Set(report.countries).size);
expect(new Set(report.countries)).toEqual(new Set(['MEX', 'CAN', 'USA']));
});
});

function readPackageJson() {
const packageJsonPath = path.resolve(
__dirname,
'..',
'..',
'..',
'package.json',
);
const packageJsonData = fs.readFileSync(packageJsonPath, 'utf8');
return JSON.parse(packageJsonData);
}

function removeUndefinedEntries(
obj: Record<string, unknown>,
): Record<string, unknown> {
const definedEntries = Object.entries(obj).filter(
entry => entry[1] !== undefined,
);
return Object.fromEntries(definedEntries);
}
38 changes: 38 additions & 0 deletions src/frontend/metrics/generateMetricsReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {ReadonlyDeep} from 'type-fest';
import type {Observation} from '@mapeo/schema';
import positionToCountries from './positionToCountries';

export default function generateMetricsReport({
packageJson,
os,
osVersion,
screen,
observations,
}: ReadonlyDeep<{
packageJson: {version: string};
os: 'android' | 'ios' | NodeJS.Platform;
osVersion: number | string;
screen: {width: number; height: number};
observations: ReadonlyArray<Pick<Observation, 'lat' | 'lon'>>;
}>) {
const countries = new Set<string>();

for (const {lat, lon} of observations) {
if (typeof lat === 'number' && typeof lon === 'number') {
addToSet(countries, positionToCountries(lat, lon));
}
}

return {
type: 'metrics-v1',
appVersion: packageJson.version,
os,
osVersion,
screen,
...(countries.size ? {countries: Array.from(countries)} : {}),
};
}

function addToSet<T>(set: Set<T>, toAdd: Iterable<T>): void {
for (const item of toAdd) set.add(item);
}
27 changes: 27 additions & 0 deletions src/frontend/metrics/positionToCountries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import positionToCountries from './positionToCountries';

describe('positionToCountries', () => {
it('returns nothing for invalid values', () => {
expect(positionToCountries(-91, 181)).toEqual(new Set());
expect(positionToCountries(Infinity, -Infinity)).toEqual(new Set());
expect(positionToCountries(NaN, NaN)).toEqual(new Set());
});

it('returns nothing for the middle of the Atlantic ocean', () => {
expect(positionToCountries(10, -33)).toEqual(new Set());
});

it('returns Mexico for a point in Mexico City', () => {
expect(positionToCountries(19.419914, -99.088059)).toEqual(
new Set(['MEX']),
);
});

it('returns multiple countries for disputed territories', () => {
// [Machias Seal Island][0] is a disputed territory.
// [0]: https://en.wikipedia.org/wiki/Machias_Seal_Island
expect(positionToCountries(44.5, -67.101111)).toEqual(
new Set(['CAN', 'USA']),
);
});
});
29 changes: 29 additions & 0 deletions src/frontend/metrics/positionToCountries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import borders from '@osm_borders/maritime_10000m';
import GeojsonGeometriesLookup from 'geojson-geometries-lookup';

let lookup: undefined | GeojsonGeometriesLookup;

export default function positionToCountries(
latitude: number,
longitude: number,
): Set<string> {
lookup ??= new GeojsonGeometriesLookup(borders);

const result = new Set<string>();

const {features} = lookup.getContainers({
type: 'Point',
coordinates: [longitude, latitude],
});
for (const {properties} of features) {
if (
properties &&
'isoA3' in properties &&
typeof properties.isoA3 === 'string'
) {
result.add(properties.isoA3);
}
}

return result;
}
15 changes: 15 additions & 0 deletions src/frontend/types/geojson-geometries-lookup.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
declare module 'geojson-geometries-lookup' {
import type {
GeoJSON,
Point,
LineString,
Polygon,
FeatureCollection,
} from '@types/geojson';

export default class GeojsonGeometriesLookup {
constructor(geoJson: GeoJSON);

getContainers(geometry: Point | LineString | Polygon): FeatureCollection;
}
}
6 changes: 6 additions & 0 deletions src/frontend/types/osm_borders__maritime_10000m.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module '@osm_borders/maritime_10000m' {
import {GeoJSON} from 'geojson';

const data: GeoJSON;
export default data;
}

0 comments on commit e1ba816

Please sign in to comment.