mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c3aa3f8f | ||
|
|
e361856877 |
No files matched your search
@@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -203,6 +203,9 @@
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"observe": {
|
||||
"description": "monitor app performance metrics"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
|
||||
@@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = (
|
||||
export const getLimitFlagWithCustomValues = ({
|
||||
defaultTo,
|
||||
limit,
|
||||
description,
|
||||
}: {
|
||||
defaultTo: number;
|
||||
limit: number;
|
||||
description?: string;
|
||||
}): OptionFlag<number | undefined> =>
|
||||
Flags.integer({
|
||||
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
description:
|
||||
description ??
|
||||
`The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppObservePlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveEventsAsync } from '../../../observe/fetchEvents';
|
||||
import { buildObserveEventsJson } from '../../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvents from '../events';
|
||||
|
||||
jest.mock('../../../observe/fetchEvents');
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveEventsJson: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveEvents, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvents {
|
||||
const command = new ObserveEvents(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uses --days-from-now to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--metric', 'tti', '--days-from-now', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--metric', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--metric',
|
||||
'tti',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('defaults endTime to now when only --start is provided', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days-from-now combined with --start', async () => {
|
||||
const command = createCommand([
|
||||
'--metric',
|
||||
'tti',
|
||||
'--days-from-now',
|
||||
'7',
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --limit to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--limit', '42']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('passes --after cursor to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.after).toBe('cursor-xyz');
|
||||
});
|
||||
|
||||
it('does not pass after when --after flag is not provided', async () => {
|
||||
const command = createCommand(['--metric', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options).not.toHaveProperty('after');
|
||||
});
|
||||
|
||||
it('rejects --days-from-now combined with --end', async () => {
|
||||
const command = createCommand([
|
||||
'--metric',
|
||||
'tti',
|
||||
'--days-from-now',
|
||||
'7',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Ios);
|
||||
});
|
||||
|
||||
it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBe(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('passes --app-version to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
});
|
||||
|
||||
it('passes --update-id to fetchObserveEventsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
});
|
||||
|
||||
it('does not pass platform, appVersion, or updateId when flags are not provided', async () => {
|
||||
const command = createCommand(['--metric', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.platform).toBeUndefined();
|
||||
expect(options.appVersion).toBeUndefined();
|
||||
expect(options.updateId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventsJson).toHaveBeenCalledWith(
|
||||
mockEvents,
|
||||
expect.objectContaining({ hasNextPage: false })
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call enableJsonOutput when --json is not provided', async () => {
|
||||
const command = createCommand(['--metric', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics';
|
||||
import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveMetrics from '../metrics';
|
||||
|
||||
jest.mock('../../../observe/fetchMetrics', () => {
|
||||
const actual = jest.requireActual('../../../observe/fetchMetrics');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveMetricsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../observe/formatMetrics', () => ({
|
||||
...jest.requireActual('../../../observe/formatMetrics'),
|
||||
buildObserveMetricsTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveMetricsJson: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync);
|
||||
const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable);
|
||||
const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveMetrics, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveMetricsAsync.mockResolvedValue(new Map());
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveMetrics {
|
||||
const command = new ObserveMetrics(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches metrics with default parameters (both platforms)', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1);
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries only Android when --platform android is passed', async () => {
|
||||
const command = createCommand(['--platform', 'android']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Android]);
|
||||
});
|
||||
|
||||
it('queries only iOS when --platform ios is passed', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3];
|
||||
expect(platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => {
|
||||
const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']);
|
||||
await command.runAsync();
|
||||
|
||||
const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2];
|
||||
expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']);
|
||||
});
|
||||
|
||||
it('uses default time range (60 days back) when no --start/--end flags', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-04-16T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsTable', async () => {
|
||||
const command = createCommand(['--stat', 'p90', '--stat', 'count']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['p90', 'eventCount']
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates --stat flags that resolve to the same key', async () => {
|
||||
const command = createCommand(['--stat', 'med', '--stat', 'median']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median']
|
||||
);
|
||||
});
|
||||
|
||||
it('uses --days-from-now to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days-from-now', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4];
|
||||
const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5];
|
||||
expect(endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('rejects --days-from-now combined with --start', async () => {
|
||||
const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects --days-from-now combined with --end', async () => {
|
||||
const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('uses default stats when --stat is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['median', 'eventCount']
|
||||
);
|
||||
});
|
||||
|
||||
it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => {
|
||||
const command = createCommand([
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
'--stat',
|
||||
'min',
|
||||
'--stat',
|
||||
'avg',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(
|
||||
expect.any(Map),
|
||||
expect.any(Array),
|
||||
['min', 'average']
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateDateFlag, () => {
|
||||
it('throws on invalid --start date', () => {
|
||||
expect(() => validateDateFlag('not-a-date', '--start')).toThrow(
|
||||
'Invalid --start date: "not-a-date"'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid --end date', () => {
|
||||
expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"');
|
||||
});
|
||||
|
||||
it('accepts valid ISO date in --start', () => {
|
||||
expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObservePlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
type EventsOrderPreset,
|
||||
fetchObserveEventsAsync,
|
||||
resolveOrderBy,
|
||||
} from '../../observe/fetchEvents';
|
||||
import { resolveMetricName } from '../../observe/metricNames';
|
||||
import { validateDateFlag } from '../../observe/fetchMetrics';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_EVENTS_LIMIT = 10;
|
||||
const DEFAULT_DAYS_BACK = 60;
|
||||
|
||||
export default class ObserveEvents extends EasCommand {
|
||||
static override description = 'display individual app performance events ordered by metric value';
|
||||
|
||||
static override flags = {
|
||||
metric: Flags.string({
|
||||
description:
|
||||
'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)',
|
||||
required: true,
|
||||
}),
|
||||
sort: Flags.enum<EventsOrderPreset>({
|
||||
description: 'Sort order for events',
|
||||
options: ['slowest', 'fastest', 'newest', 'oldest'],
|
||||
default: 'slowest',
|
||||
}),
|
||||
platform: Flags.enum<'android' | 'ios'>({
|
||||
description: 'Filter by platform',
|
||||
options: ['android', 'ios'],
|
||||
}),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_EVENTS_LIMIT, limit: 100 }),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days-from-now'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days-from-now'],
|
||||
}),
|
||||
'days-from-now': Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveEvents);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
if (flags.start) {
|
||||
validateDateFlag(flags.start, '--start');
|
||||
}
|
||||
if (flags.end) {
|
||||
validateDateFlag(flags.end, '--end');
|
||||
}
|
||||
|
||||
const metricName = resolveMetricName(flags.metric);
|
||||
const orderBy = resolveOrderBy(flags.sort);
|
||||
|
||||
let startTime: string;
|
||||
let endTime: string;
|
||||
|
||||
if (flags['days-from-now']) {
|
||||
endTime = new Date().toISOString();
|
||||
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
endTime = flags.end ?? new Date().toISOString();
|
||||
startTime =
|
||||
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const platform = flags.platform
|
||||
? flags.platform === 'android'
|
||||
? AppObservePlatform.Android
|
||||
: AppObservePlatform.Ios
|
||||
: undefined;
|
||||
|
||||
const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, {
|
||||
metricName,
|
||||
orderBy,
|
||||
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platform,
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveEventsTable(events, pageInfo));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { resolveMetricName } from '../../observe/metricNames';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_METRICS = [
|
||||
'expo.app_startup.cold_launch_time',
|
||||
'expo.app_startup.warm_launch_time',
|
||||
'expo.app_startup.tti',
|
||||
'expo.app_startup.ttr',
|
||||
'expo.app_startup.bundle_load_time',
|
||||
];
|
||||
|
||||
const DEFAULT_DAYS_BACK = 60;
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
export default class ObserveMetrics extends EasCommand {
|
||||
static override description = 'display app performance metrics grouped by app version';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.enum<'android' | 'ios'>({
|
||||
description: 'Filter by platform',
|
||||
options: ['android', 'ios'],
|
||||
}),
|
||||
metric: Flags.string({
|
||||
description:
|
||||
'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load',
|
||||
multiple: true,
|
||||
}),
|
||||
stat: Flags.string({
|
||||
description:
|
||||
'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count',
|
||||
multiple: true,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range for metrics data (ISO date).',
|
||||
exclusive: ['days-from-now'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range for metrics data (ISO date).',
|
||||
exclusive: ['days-from-now'],
|
||||
}),
|
||||
'days-from-now': Flags.integer({
|
||||
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetrics);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
if (flags.start) {
|
||||
validateDateFlag(flags.start, '--start');
|
||||
}
|
||||
if (flags.end) {
|
||||
validateDateFlag(flags.end, '--end');
|
||||
}
|
||||
|
||||
const metricNames = flags.metric?.length
|
||||
? flags.metric.map(resolveMetricName)
|
||||
: DEFAULT_METRICS;
|
||||
|
||||
let startTime: string;
|
||||
let endTime: string;
|
||||
|
||||
if (flags['days-from-now']) {
|
||||
endTime = new Date().toISOString();
|
||||
startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString();
|
||||
} else {
|
||||
endTime = flags.end ?? new Date().toISOString();
|
||||
startTime =
|
||||
flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const platforms: AppPlatform[] = flags.platform
|
||||
? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios]
|
||||
: [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
const metricsMap = await fetchObserveMetricsAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
metricNames,
|
||||
platforms,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
? Array.from(new Set(flags.stat.map(resolveStatKey)))
|
||||
: undefined;
|
||||
|
||||
if (flags.json) {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats));
|
||||
} else {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveTimeSeriesInput,
|
||||
AppObserveVersionMarker,
|
||||
PageInfo,
|
||||
} from '../generated';
|
||||
|
||||
type AppObserveTimeSeriesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
timeSeries: {
|
||||
versionMarkers: AppObserveVersionMarker[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveTimeSeriesQueryVariables = {
|
||||
appId: string;
|
||||
input: Pick<AppObserveTimeSeriesInput, 'metricName' | 'platform' | 'startTime' | 'endTime'>;
|
||||
};
|
||||
|
||||
type AppObserveEventsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
events: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveEvent;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveEventsQueryVariables = {
|
||||
appId: string;
|
||||
filter?: AppObserveEventsFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveEventsOrderBy;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesVersionMarkersAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
metricName,
|
||||
platform,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
appId: string;
|
||||
metricName: string;
|
||||
platform: AppObservePlatform;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
): Promise<AppObserveVersionMarker[]> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTimeSeriesQuery, AppObserveTimeSeriesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTimeSeries($appId: String!, $input: AppObserveTimeSeriesInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
timeSeries(input: $input) {
|
||||
versionMarkers {
|
||||
appVersion
|
||||
eventCount
|
||||
firstSeenAt
|
||||
statistics {
|
||||
min
|
||||
max
|
||||
median
|
||||
average
|
||||
p80
|
||||
p90
|
||||
p99
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
input: { metricName, platform, startTime, endTime },
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.timeSeries.versionMarkers;
|
||||
},
|
||||
|
||||
async eventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveEventsQueryVariables
|
||||
): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveEventsQuery, AppObserveEventsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveEvents(
|
||||
$appId: String!
|
||||
$filter: AppObserveEventsFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveEventsOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
events(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
metricName
|
||||
metricValue
|
||||
timestamp
|
||||
appVersion
|
||||
appBuildNumber
|
||||
deviceModel
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
countryCode
|
||||
sessionId
|
||||
easClientId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.events;
|
||||
return {
|
||||
events: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
it('maps "slowest" to METRIC_VALUE DESC', () => {
|
||||
expect(resolveOrderBy('slowest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "fastest" to METRIC_VALUE ASC', () => {
|
||||
expect(resolveOrderBy('fastest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "newest" to TIMESTAMP DESC', () => {
|
||||
expect(resolveOrderBy('newest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "oldest" to TIMESTAMP ASC', () => {
|
||||
expect(resolveOrderBy('oldest')).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchObserveEventsAsync, () => {
|
||||
const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventsAsync.mockClear();
|
||||
});
|
||||
|
||||
it('calls ObserveQuery.eventsAsync with assembled filter', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, {
|
||||
appId: 'app-123',
|
||||
filter: {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
},
|
||||
first: 10,
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes platform in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 5,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platform: AppObservePlatform.Ios,
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
platform: AppObservePlatform.Ios,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appVersion in filter when provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '1.2.0',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appVersion: '1.2.0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes appUpdateId in filter when updateId is provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
updateId: 'update-abc-123',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
appUpdateId: 'update-abc-123',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledFilter = mockEventsAsync.mock.calls[0][1].filter;
|
||||
expect(calledFilter).not.toHaveProperty('platform');
|
||||
expect(calledFilter).not.toHaveProperty('appVersion');
|
||||
expect(calledFilter).not.toHaveProperty('appUpdateId');
|
||||
});
|
||||
|
||||
it('returns events and pageInfo from the query result', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
},
|
||||
];
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: mockEvents as any,
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor-1' },
|
||||
});
|
||||
|
||||
const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].metricValue).toBe(1.23);
|
||||
expect(result.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after cursor to ObserveQuery.eventsAsync', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
after: 'cursor-abc',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockEventsAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
expect.objectContaining({ first: 10, after: 'cursor-abc' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits after when not provided', async () => {
|
||||
mockEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const calledVars = mockEventsAsync.mock.calls[0][1];
|
||||
expect(calledVars).not.toHaveProperty('after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { AppObservePlatform, AppPlatform } from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { makeMetricsKey } from '../formatMetrics';
|
||||
import { fetchObserveMetricsAsync } from '../fetchMetrics';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe('fetchObserveMetricsAsync', () => {
|
||||
const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimeSeriesMarkers.mockClear();
|
||||
});
|
||||
|
||||
it('fans out queries for each metric+platform combo and assembles metricsMap', async () => {
|
||||
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName, platform }) => {
|
||||
if (metricName === 'expo.app_startup.tti' && platform === AppObservePlatform.Ios) {
|
||||
return [
|
||||
{
|
||||
__typename: 'AppObserveVersionMarker' as const,
|
||||
appVersion: '1.0.0',
|
||||
eventCount: 100,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (
|
||||
metricName === 'expo.app_startup.cold_launch_time' &&
|
||||
platform === AppObservePlatform.Ios
|
||||
) {
|
||||
return [
|
||||
{
|
||||
__typename: 'AppObserveVersionMarker' as const,
|
||||
appVersion: '1.0.0',
|
||||
eventCount: 80,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const metricsMap = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
// Should have called the query twice (2 metrics x 1 platform)
|
||||
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify metricsMap was assembled correctly
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
|
||||
const metricsForVersion = metricsMap.get(key)!;
|
||||
expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.01,
|
||||
max: 0.5,
|
||||
median: 0.1,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.48,
|
||||
eventCount: 100,
|
||||
});
|
||||
expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({
|
||||
min: 0.05,
|
||||
max: 1.2,
|
||||
median: 0.3,
|
||||
average: 0.4,
|
||||
p80: 0.8,
|
||||
p90: 1.0,
|
||||
p99: 1.15,
|
||||
eventCount: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('fans out across multiple platforms', async () => {
|
||||
mockTimeSeriesMarkers.mockResolvedValue([]);
|
||||
|
||||
await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios, AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
// 1 metric x 2 platforms = 2 calls
|
||||
expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2);
|
||||
|
||||
const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform);
|
||||
expect(platforms).toContain(AppObservePlatform.Ios);
|
||||
expect(platforms).toContain(AppObservePlatform.Android);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully — successful queries still populate metricsMap', async () => {
|
||||
mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => {
|
||||
if (metricName === 'bad.metric') {
|
||||
throw new Error('Unknown metric');
|
||||
}
|
||||
return [
|
||||
{
|
||||
__typename: 'AppObserveVersionMarker' as const,
|
||||
appVersion: '2.0.0',
|
||||
eventCount: 50,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const metricsMap = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti', 'bad.metric'],
|
||||
[AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
// Should not throw; the good metric should still be in the map
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Android);
|
||||
expect(metricsMap.has(key)).toBe(true);
|
||||
expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({
|
||||
min: 0.1,
|
||||
max: 0.9,
|
||||
median: 0.5,
|
||||
average: 0.5,
|
||||
p80: 0.7,
|
||||
p90: 0.8,
|
||||
p99: 0.85,
|
||||
eventCount: 50,
|
||||
});
|
||||
// The bad metric should not be present
|
||||
expect(metricsMap.get(key)!.has('bad.metric')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty map when all queries fail', async () => {
|
||||
mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const metricsMap = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Ios],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(metricsMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => {
|
||||
mockTimeSeriesMarkers.mockResolvedValue([
|
||||
{
|
||||
__typename: 'AppObserveVersionMarker' as const,
|
||||
appVersion: '3.0.0',
|
||||
eventCount: 10,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
statistics: {
|
||||
__typename: 'AppObserveVersionMarkerStatistics' as const,
|
||||
min: 0.1,
|
||||
max: 0.2,
|
||||
median: 0.15,
|
||||
average: 0.15,
|
||||
p80: 0.18,
|
||||
p90: 0.19,
|
||||
p99: 0.2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const metricsMap = await fetchObserveMetricsAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
['expo.app_startup.tti'],
|
||||
[AppPlatform.Android],
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'2025-03-01T00:00:00.000Z'
|
||||
);
|
||||
|
||||
// The key should use AppPlatform (ANDROID), not AppObservePlatform
|
||||
expect(metricsMap.has('3.0.0:ANDROID')).toBe(true);
|
||||
expect(metricsMap.has('3.0.0:Android' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AppObserveEvent, PageInfo } from '../../graphql/generated';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents';
|
||||
|
||||
function createMockEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appIdentifier: 'com.example.app',
|
||||
appName: 'ExampleApp',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
eventBatchId: 'batch-1',
|
||||
tags: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
const withNextPage: PageInfo = {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-abc',
|
||||
};
|
||||
|
||||
describe(buildObserveEventsTable, () => {
|
||||
it('formats events into aligned columns', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.85,
|
||||
appVersion: '1.1.0',
|
||||
appBuildNumber: '38',
|
||||
deviceOs: 'Android',
|
||||
deviceOsVersion: '14',
|
||||
deviceModel: 'Pixel 8',
|
||||
countryCode: 'PL',
|
||||
timestamp: '2025-01-14T08:15:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// Escape codes are included, because the header is bolded.
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mMetric Value App Version Platform Device Country Timestamp [22m
|
||||
------ ----- ----------- ---------- --------- ------- ----------------------
|
||||
TTI 1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM
|
||||
TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM"
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns yellow warning for empty array', () => {
|
||||
const output = buildObserveEventsTable([], noNextPage);
|
||||
expect(output).toContain('No events found.');
|
||||
});
|
||||
|
||||
it('uses short names for known metrics', () => {
|
||||
const events = [
|
||||
createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }),
|
||||
createMockEvent({
|
||||
id: 'evt-2',
|
||||
metricName: 'expo.app_startup.warm_launch_time',
|
||||
}),
|
||||
createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }),
|
||||
createMockEvent({
|
||||
id: 'evt-4',
|
||||
metricName: 'expo.app_startup.bundle_load_time',
|
||||
}),
|
||||
];
|
||||
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
expect(output).toContain('Cold Launch');
|
||||
expect(output).toContain('Warm Launch');
|
||||
expect(output).toContain('TTR');
|
||||
expect(output).toContain('Bundle Load');
|
||||
});
|
||||
|
||||
it('shows - for null countryCode', () => {
|
||||
const events = [createMockEvent({ countryCode: null })];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
// The country column should contain a dash
|
||||
const lines = output.split('\n');
|
||||
const dataLine = lines[2]; // header, separator, first data row
|
||||
expect(dataLine).toContain('-');
|
||||
});
|
||||
|
||||
it('appends next page hint when hasNextPage is true', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, withNextPage);
|
||||
|
||||
expect(output).toContain('Next page: --after cursor-abc');
|
||||
});
|
||||
|
||||
it('does not append next page hint when hasNextPage is false', () => {
|
||||
const events = [createMockEvent()];
|
||||
const output = buildObserveEventsTable(events, noNextPage);
|
||||
|
||||
expect(output).not.toContain('Next page');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveEventsJson, () => {
|
||||
it('maps event to JSON shape with all relevant fields and pageInfo', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, withNextPage);
|
||||
|
||||
expect(result).toEqual({
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: 'cursor-abc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null optional fields', () => {
|
||||
const events = [
|
||||
createMockEvent({
|
||||
countryCode: null,
|
||||
sessionId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveEventsJson(events, noNextPage);
|
||||
|
||||
expect(result.events[0].countryCode).toBeNull();
|
||||
expect(result.events[0].sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty events array for empty input', () => {
|
||||
const result = buildObserveEventsJson([], noNextPage);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import {
|
||||
ObserveMetricsMap,
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
buildObserveMetricsTable,
|
||||
makeMetricsKey,
|
||||
resolveStatKey,
|
||||
type MetricValues,
|
||||
} from '../formatMetrics';
|
||||
|
||||
const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount'];
|
||||
const DEFAULT_STATS_JSON: StatisticKey[] = [
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
];
|
||||
|
||||
const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'];
|
||||
|
||||
function makeMetricValueWithDefaults(overrides: Partial<MetricValues>): MetricValues {
|
||||
return {
|
||||
min: 0.1,
|
||||
median: 0.3,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveMetricsTable, () => {
|
||||
it('formats metrics grouped by version with metric columns', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
iosKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android);
|
||||
metricsMap.set(
|
||||
androidKey,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.cold_launch_time',
|
||||
makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }),
|
||||
],
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
// The header is bolded, thus the escape characters in the snapshot
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mApp Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count[22m
|
||||
----------- -------- --------------- ----------------- ------- ---------
|
||||
1.2.0 iOS 0.35s 110 1.32s 90
|
||||
1.1.0 Android 0.25s 120 1.12s 100 "
|
||||
`);
|
||||
});
|
||||
|
||||
it('shows - for versions with no matching metric data', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('2.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"[1mApp Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count[22m
|
||||
----------- -------- --------------- ----------------- ------- ---------
|
||||
2.0.0 iOS - - - - "
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns message when no metrics data found', () => {
|
||||
const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE);
|
||||
expect(output).toMatchInlineSnapshot(`"[33mNo metrics data found.[39m"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveMetricsJson, () => {
|
||||
it('produces JSON with all stats per metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], DEFAULT_STATS_JSON);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
platform: AppPlatform.Ios,
|
||||
metrics: {
|
||||
'expo.app_startup.tti': {
|
||||
min: 0.1,
|
||||
median: 0.12,
|
||||
max: 1.1,
|
||||
average: 0.5,
|
||||
p80: 0.8,
|
||||
p90: 0.9,
|
||||
p99: 1.0,
|
||||
eventCount: 90,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('produces null values when no observe data matches for a metric', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('3.0.0', AppPlatform.Android);
|
||||
metricsMap.set(key, new Map());
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result[0].metrics).toEqual({
|
||||
'expo.app_startup.tti': {
|
||||
min: null,
|
||||
median: null,
|
||||
max: null,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(makeMetricsKey, () => {
|
||||
it('creates a key from version and platform', () => {
|
||||
expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS');
|
||||
expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID');
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveStatKey, () => {
|
||||
it('resolves canonical stat names', () => {
|
||||
expect(resolveStatKey('min')).toBe('min');
|
||||
expect(resolveStatKey('max')).toBe('max');
|
||||
expect(resolveStatKey('median')).toBe('median');
|
||||
expect(resolveStatKey('average')).toBe('average');
|
||||
expect(resolveStatKey('p80')).toBe('p80');
|
||||
expect(resolveStatKey('p90')).toBe('p90');
|
||||
expect(resolveStatKey('p99')).toBe('p99');
|
||||
expect(resolveStatKey('eventCount')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('resolves short aliases', () => {
|
||||
expect(resolveStatKey('med')).toBe('median');
|
||||
expect(resolveStatKey('avg')).toBe('average');
|
||||
expect(resolveStatKey('count')).toBe('eventCount');
|
||||
expect(resolveStatKey('event_count')).toBe('eventCount');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_TABLE', () => {
|
||||
it('defaults to median, eventCount', () => {
|
||||
expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_STATS_JSON', () => {
|
||||
it('includes all stats', () => {
|
||||
expect(DEFAULT_STATS_JSON).toEqual([
|
||||
'min',
|
||||
'median',
|
||||
'max',
|
||||
'average',
|
||||
'p80',
|
||||
'p90',
|
||||
'p99',
|
||||
'eventCount',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom stats parameter', () => {
|
||||
it('table renders only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], [
|
||||
'p99',
|
||||
'eventCount',
|
||||
]);
|
||||
|
||||
expect(output).toContain('TTI P99');
|
||||
expect(output).toContain('TTI Count');
|
||||
expect(output).toContain('0.90s');
|
||||
expect(output).toContain('42');
|
||||
expect(output).not.toContain('TTI Min');
|
||||
expect(output).not.toContain('TTI Med');
|
||||
expect(output).not.toContain('TTI Max');
|
||||
});
|
||||
|
||||
it("table formats eventCount as integer without 's' suffix", () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: 100,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']);
|
||||
|
||||
expect(output).toContain('100');
|
||||
expect(output).not.toContain('100s');
|
||||
expect(output).not.toContain('100.00s');
|
||||
});
|
||||
|
||||
it('JSON includes only selected stats', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.01,
|
||||
median: 0.1,
|
||||
max: 0.5,
|
||||
average: 0.15,
|
||||
p80: 0.3,
|
||||
p90: 0.4,
|
||||
p99: 0.9,
|
||||
eventCount: 42,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], [
|
||||
'p90',
|
||||
'eventCount',
|
||||
]);
|
||||
|
||||
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
p90: 0.4,
|
||||
eventCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON uses default stats when not specified', () => {
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
const key = makeMetricsKey('1.0.0', AppPlatform.Ios);
|
||||
metricsMap.set(
|
||||
key,
|
||||
new Map([
|
||||
[
|
||||
'expo.app_startup.tti',
|
||||
{
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = buildObserveMetricsJson(
|
||||
metricsMap,
|
||||
['expo.app_startup.tti'],
|
||||
DEFAULT_STATS_JSON
|
||||
);
|
||||
|
||||
expect(result[0].metrics['expo.app_startup.tti']).toEqual({
|
||||
min: 0.02,
|
||||
median: 0.1,
|
||||
max: 0.4,
|
||||
average: null,
|
||||
p80: null,
|
||||
p90: null,
|
||||
p99: null,
|
||||
eventCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
expect(resolveMetricName('tti')).toBe('expo.app_startup.tti');
|
||||
});
|
||||
|
||||
it('resolves short alias "ttr" to full metric name', () => {
|
||||
expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr');
|
||||
});
|
||||
|
||||
it('resolves short alias "cold_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "warm_launch" to full metric name', () => {
|
||||
expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time');
|
||||
});
|
||||
|
||||
it('resolves short alias "bundle_load" to full metric name', () => {
|
||||
expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time');
|
||||
});
|
||||
|
||||
it('passes through full metric names unchanged', () => {
|
||||
expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti');
|
||||
expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe(
|
||||
'expo.app_startup.cold_launch_time'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unknown alias', () => {
|
||||
expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"');
|
||||
});
|
||||
|
||||
it('passes through dot-containing custom metric names', () => {
|
||||
expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
AppObservePlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export type EventsOrderPreset = 'slowest' | 'fastest' | 'newest' | 'oldest';
|
||||
|
||||
export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy {
|
||||
switch (preset) {
|
||||
case 'slowest':
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case 'fastest':
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
case 'newest':
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
case 'oldest':
|
||||
return {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchObserveEventsOptions {
|
||||
metricName: string;
|
||||
orderBy: AppObserveEventsOrderBy;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
}
|
||||
|
||||
interface FetchObserveEventsResult {
|
||||
events: AppObserveEvent[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export async function fetchObserveEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveEventsOptions
|
||||
): Promise<FetchObserveEventsResult> {
|
||||
const filter: AppObserveEventsFilter = {
|
||||
metricName: options.metricName,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.eventsAsync(graphqlClient, {
|
||||
appId,
|
||||
filter,
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: options.orderBy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
|
||||
const observePlatformToAppPlatform: Record<AppObservePlatform, AppPlatform> = {
|
||||
[AppObservePlatform.Android]: AppPlatform.Android,
|
||||
[AppObservePlatform.Ios]: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
interface ObserveQueryResult {
|
||||
metricName: string;
|
||||
platform: AppObservePlatform;
|
||||
markers: AppObserveVersionMarker[];
|
||||
}
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new EasCommandError(
|
||||
`Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObserveMetricsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
metricNames: string[],
|
||||
platforms: AppPlatform[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<ObserveMetricsMap> {
|
||||
const observeQueries: Promise<ObserveQueryResult | null>[] = [];
|
||||
|
||||
for (const metricName of metricNames) {
|
||||
for (const appPlatform of platforms) {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
observeQueries.push(
|
||||
ObserveQuery.timeSeriesVersionMarkersAsync(graphqlClient, {
|
||||
appId,
|
||||
metricName,
|
||||
platform: observePlatform,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
.then(markers => ({
|
||||
metricName,
|
||||
platform: observePlatform,
|
||||
markers,
|
||||
}))
|
||||
.catch(error => {
|
||||
Log.warn(
|
||||
`Failed to fetch observe data for metric "${metricName}" on ${observePlatform}: ${error.message}`
|
||||
);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const observeResults = await Promise.all(observeQueries);
|
||||
|
||||
const metricsMap: ObserveMetricsMap = new Map();
|
||||
|
||||
for (const result of observeResults) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
const { metricName, platform, markers } = result;
|
||||
const appPlatform = observePlatformToAppPlatform[platform];
|
||||
for (const marker of markers) {
|
||||
const key = makeMetricsKey(marker.appVersion, appPlatform);
|
||||
if (!metricsMap.has(key)) {
|
||||
metricsMap.set(key, new Map());
|
||||
}
|
||||
const values: MetricValues = {
|
||||
min: marker.statistics.min,
|
||||
max: marker.statistics.max,
|
||||
median: marker.statistics.median,
|
||||
average: marker.statistics.average,
|
||||
p80: marker.statistics.p80,
|
||||
p90: marker.statistics.p90,
|
||||
p99: marker.statistics.p99,
|
||||
eventCount: marker.eventCount,
|
||||
};
|
||||
metricsMap.get(key)!.set(metricName, values);
|
||||
}
|
||||
}
|
||||
|
||||
return metricsMap;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveEvent, PageInfo } from '../graphql/generated';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export interface ObserveEventJson {
|
||||
id: string;
|
||||
metricName: string;
|
||||
metricValue: number;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
sessionId: string | null;
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function buildObserveEventsTable(events: AppObserveEvent[], pageInfo: PageInfo): string {
|
||||
if (events.length === 0) {
|
||||
return chalk.yellow('No events found.');
|
||||
}
|
||||
|
||||
const headers = ['Metric', 'Value', 'App Version', 'Platform', 'Device', 'Country', 'Timestamp'];
|
||||
|
||||
const rows: string[][] = events.map(event => [
|
||||
getMetricDisplayName(event.metricName),
|
||||
`${event.metricValue.toFixed(2)}s`,
|
||||
`${event.appVersion} (${event.appBuildNumber})`,
|
||||
`${event.deviceOs} ${event.deviceOsVersion}`,
|
||||
event.deviceModel,
|
||||
event.countryCode ?? '-',
|
||||
formatTimestamp(event.timestamp),
|
||||
]);
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
|
||||
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveEventsJson(
|
||||
events: AppObserveEvent[],
|
||||
pageInfo: PageInfo
|
||||
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
metricName: event.metricName,
|
||||
metricValue: event.metricValue,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type StatisticKey =
|
||||
| 'min'
|
||||
| 'max'
|
||||
| 'median'
|
||||
| 'average'
|
||||
| 'p80'
|
||||
| 'p90'
|
||||
| 'p99'
|
||||
| 'eventCount';
|
||||
|
||||
export const STAT_ALIASES: Record<string, StatisticKey> = {
|
||||
min: 'min',
|
||||
max: 'max',
|
||||
med: 'median',
|
||||
median: 'median',
|
||||
avg: 'average',
|
||||
average: 'average',
|
||||
p80: 'p80',
|
||||
p90: 'p90',
|
||||
p99: 'p99',
|
||||
count: 'eventCount',
|
||||
event_count: 'eventCount',
|
||||
eventCount: 'eventCount',
|
||||
};
|
||||
|
||||
export const STAT_DISPLAY_NAMES: Record<StatisticKey, string> = {
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
median: 'Med',
|
||||
average: 'Avg',
|
||||
p80: 'P80',
|
||||
p90: 'P90',
|
||||
p99: 'P99',
|
||||
eventCount: 'Count',
|
||||
};
|
||||
|
||||
export function resolveStatKey(input: string): StatisticKey {
|
||||
const resolved = STAT_ALIASES[input];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatValue(stat: StatisticKey, value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (stat === 'eventCount') {
|
||||
return String(value);
|
||||
}
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
export interface MetricValues {
|
||||
min: number | null | undefined;
|
||||
max: number | null | undefined;
|
||||
median: number | null | undefined;
|
||||
average: number | null | undefined;
|
||||
p80: number | null | undefined;
|
||||
p90: number | null | undefined;
|
||||
p99: number | null | undefined;
|
||||
eventCount: number | null | undefined;
|
||||
}
|
||||
|
||||
type ObserveMetricsKey = `${string}:${AppPlatform}`;
|
||||
|
||||
export type ObserveMetricsMap = Map<ObserveMetricsKey, Map<string, MetricValues>>;
|
||||
|
||||
export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey {
|
||||
return `${appVersion}:${platform}`;
|
||||
}
|
||||
|
||||
function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } {
|
||||
const lastColon = key.lastIndexOf(':');
|
||||
return {
|
||||
appVersion: key.slice(0, lastColon),
|
||||
platform: key.slice(lastColon + 1) as AppPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
|
||||
|
||||
export interface ObserveMetricsVersionResult {
|
||||
appVersion: string;
|
||||
platform: AppPlatform;
|
||||
metrics: Record<string, MetricValuesJson>;
|
||||
}
|
||||
|
||||
export function buildObserveMetricsJson(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[]
|
||||
): ObserveMetricsVersionResult[] {
|
||||
const results: ObserveMetricsVersionResult[] = [];
|
||||
|
||||
for (const [key, versionMetrics] of metricsMap) {
|
||||
const { appVersion, platform } = parseMetricsKey(key);
|
||||
|
||||
const metrics: Record<string, MetricValuesJson> = {};
|
||||
for (const metricName of metricNames) {
|
||||
const values = versionMetrics.get(metricName);
|
||||
const statValues: MetricValuesJson = {};
|
||||
for (const stat of stats) {
|
||||
statValues[stat] = values?.[stat] ?? null;
|
||||
}
|
||||
metrics[metricName] = statValues;
|
||||
}
|
||||
|
||||
results.push({ appVersion, platform, metrics });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function buildObserveMetricsTable(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
stats: StatisticKey[]
|
||||
): string {
|
||||
const results = buildObserveMetricsJson(metricsMap, metricNames, stats);
|
||||
|
||||
if (results.length === 0) {
|
||||
return chalk.yellow('No metrics data found.');
|
||||
}
|
||||
|
||||
const fixedHeaders = ['App Version', 'Platform'];
|
||||
const metricHeaders: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const name = getMetricDisplayName(m);
|
||||
for (const stat of stats) {
|
||||
metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`);
|
||||
}
|
||||
}
|
||||
const headers = [...fixedHeaders, ...metricHeaders];
|
||||
|
||||
const rows: string[][] = results.map(result => {
|
||||
const metricCells: string[] = [];
|
||||
for (const m of metricNames) {
|
||||
const values = result.metrics[m];
|
||||
for (const stat of stats) {
|
||||
metricCells.push(formatStatValue(stat, values?.[stat] ?? null));
|
||||
}
|
||||
}
|
||||
|
||||
return [result.appVersion, appPlatformDisplayNames[result.platform], ...metricCells];
|
||||
});
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
|
||||
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
|
||||
export const METRIC_ALIASES: Record<string, string> = {
|
||||
tti: 'expo.app_startup.tti',
|
||||
ttr: 'expo.app_startup.ttr',
|
||||
cold_launch: 'expo.app_startup.cold_launch_time',
|
||||
warm_launch: 'expo.app_startup.warm_launch_time',
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
if (METRIC_ALIASES[input]) {
|
||||
return METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
Reference in new issue
Block a user