Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/eas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
"metadata": {
"description": "manage store configuration"
},
"observe": {
"description": "monitor app performance metrics"
},
"project": {
"description": "manage project"
},
Expand Down
124 changes: 124 additions & 0 deletions packages/eas-cli/src/commands/observe/__tests__/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Config } from '@oclif/core';

import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { fetchObserveEventsAsync } from '../../../observe/fetchEvents';
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);

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('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();
});
});
130 changes: 130 additions & 0 deletions packages/eas-cli/src/commands/observe/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Flags } from '@oclif/core';

import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppObservePlatform } from '../../graphql/generated';
import Log from '../../log';
import {
DEFAULT_EVENTS_LIMIT,
type EventsOrderPreset,
fetchObserveEventsAsync,
resolveOrderBy,
} from '../../observe/fetchEvents';
import { resolveMetricName } from '../../observe/metricNames';
import { DEFAULT_DAYS_BACK, validateDateFlag } from '../../observe/fetchMetrics';
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';

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'],
}),
limit: Flags.integer({
description: 'Number of events to show',
default: DEFAULT_EVENTS_LIMIT,
min: 1,
max: 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 } = await fetchObserveEventsAsync(graphqlClient, projectId, {
metricName,
orderBy,
limit: flags.limit ?? DEFAULT_EVENTS_LIMIT,
startTime,
endTime,
platform,
appVersion: flags['app-version'],
updateId: flags['update-id'],
});

if (flags.json) {
printJsonOnlyOutput(buildObserveEventsJson(events));
} else {
Log.addNewLineIfNone();
Log.log(buildObserveEventsTable(events));
}
}
}
Loading
Loading