diff --git a/CHANGELOG.md b/CHANGELOG.md index 372dc7e3e1..a80c257b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This is the log of notable changes to EAS CLI and related packages. ### 🛠 Breaking changes +- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman)) + ### 🎉 New features ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts index 8b4c17d951..34a4a180a2 100644 --- a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -1,31 +1,51 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { AppObservePlatform } from '../../../graphql/generated'; +import { ObserveQuery } from '../../../graphql/queries/ObserveQuery'; +import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents'; import { - AppObserveEventsOrderByDirection, - AppObserveEventsOrderByField, - AppObservePlatform, -} from '../../../graphql/generated'; -import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents'; -import { buildObserveEventsJson } from '../../../observe/formatEvents'; + buildObserveCustomEventNamesJson, + buildObserveCustomEventsEmptyWithSuggestionsJson, + buildObserveCustomEventsEmptyWithSuggestionsTable, + buildObserveCustomEventsJson, +} from '../../../observe/formatCustomEvents'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import ObserveEvents from '../events'; -jest.mock('../../../observe/fetchEvents', () => { - const actual = jest.requireActual('../../../observe/fetchEvents'); - return { - ...actual, - fetchObserveEventsAsync: jest.fn(), - }; -}); -jest.mock('../../../observe/formatEvents', () => ({ - buildObserveEventsTable: jest.fn().mockReturnValue('table'), - buildObserveEventsJson: jest.fn().mockReturnValue({}), +jest.mock('../../../observe/fetchCustomEvents'); +jest.mock('../../../observe/formatCustomEvents', () => ({ + buildObserveCustomEventsTable: jest.fn().mockReturnValue('table'), + buildObserveCustomEventsJson: jest.fn().mockReturnValue({}), + buildObserveCustomEventNamesTable: jest.fn().mockReturnValue('names-table'), + buildObserveCustomEventNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }), + buildObserveCustomEventsEmptyWithSuggestionsTable: jest + .fn() + .mockReturnValue('empty-with-suggestions-table'), + buildObserveCustomEventsEmptyWithSuggestionsJson: jest.fn().mockReturnValue({ + filteredEventName: 'my_event', + events: [], + availableEventNames: [], + availableEventNamesIsTruncated: false, + }), +})); +jest.mock('../../../graphql/queries/ObserveQuery', () => ({ + ObserveQuery: { + customEventNamesAsync: jest.fn(), + }, })); jest.mock('../../../log'); jest.mock('../../../utils/json'); -const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); -const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson); +const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync); +const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson); +const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson); +const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked( + buildObserveCustomEventsEmptyWithSuggestionsTable +); +const mockBuildObserveCustomEventsEmptyWithSuggestionsJson = jest.mocked( + buildObserveCustomEventsEmptyWithSuggestionsJson +); +const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); @@ -36,10 +56,11 @@ describe(ObserveEvents, () => { beforeEach(() => { jest.clearAllMocks(); - mockFetchObserveEventsAsync.mockResolvedValue({ + mockFetchObserveCustomEventsAsync.mockResolvedValue({ events: [], pageInfo: { hasNextPage: false, hasPreviousPage: false }, }); + mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false }); }); function createCommand(argv: string[]): ObserveEvents { @@ -52,38 +73,80 @@ describe(ObserveEvents, () => { return command; } - it('uses --days to compute start/end time range', async () => { + it('passes eventName arg to fetchObserveCustomEventsAsync', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [{ id: 'evt-1' } as any], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + const command = createCommand(['my_event']); + await command.runAsync(); + + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.eventName).toBe('my_event'); + expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); + }); + + it('routes to customEventNamesAsync when no positional arg is provided', async () => { + const command = createCommand([]); + await command.runAsync(); + + expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1); + expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled(); + }); + + it('routes to fetchObserveCustomEventsAsync when --all-events is set with no positional arg', async () => { + const command = createCommand(['--all-events']); + await command.runAsync(); + + expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1); + expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.eventName).toBeUndefined(); + }); + + it('throws when both an event name argument and --all-events are provided', async () => { + const command = createCommand(['my_event', '--all-events']); + await expect(command.runAsync()).rejects.toThrow( + '--all-events cannot be combined with an event name argument' + ); + expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled(); + expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); + }); + + it('passes the resolved time range and platform to customEventNamesAsync', async () => { const now = new Date('2025-06-15T12:00:00.000Z'); jest.useFakeTimers({ now }); - const command = createCommand(['tti', '--days', '7']); + const command = createCommand(['--days', '7', '--platform', 'ios']); 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'); + expect(mockCustomEventNamesAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + startTime: '2025-06-08T12:00:00.000Z', + endTime: '2025-06-15T12:00:00.000Z', + platform: AppObservePlatform.Ios, + }); jest.useRealTimers(); }); - it('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => { + it('uses --days to compute start/end time range when an event name is provided', async () => { const now = new Date('2025-06-15T12:00:00.000Z'); jest.useFakeTimers({ now }); - const command = createCommand(['tti']); + const command = createCommand(['my_event', '--days', '7']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options.startTime).toBe('2025-04-16T12:00:00.000Z'); + const options = mockFetchObserveCustomEventsAsync.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 explicit --start and --end when provided', async () => { const command = createCommand([ - 'tti', + 'my_event', '--start', '2025-01-01T00:00:00.000Z', '--end', @@ -91,109 +154,86 @@ describe(ObserveEvents, () => { ]); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + const options = mockFetchObserveCustomEventsAsync.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(['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 combined with --start', async () => { - const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']); - + const command = createCommand([ + 'my_event', + '--days', + '7', + '--start', + '2025-01-01T00:00:00.000Z', + ]); await expect(command.runAsync()).rejects.toThrow(); }); - it('passes --limit to fetchObserveEventsAsync', async () => { - const command = createCommand(['tti', '--limit', '42']); + it('passes --limit to fetchObserveCustomEventsAsync', async () => { + const command = createCommand(['my_event', '--limit', '42']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; expect(options.limit).toBe(42); }); - it('passes --after cursor to fetchObserveEventsAsync', async () => { - const command = createCommand(['tti', '--after', 'cursor-xyz']); + it('passes --after cursor', async () => { + const command = createCommand(['my_event', '--after', 'cursor-xyz']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + const options = mockFetchObserveCustomEventsAsync.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(['tti']); + it('passes --platform ios as AppObservePlatform.Ios', async () => { + const command = createCommand(['my_event', '--platform', 'ios']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options).not.toHaveProperty('after'); - }); - - it('rejects --days combined with --end', async () => { - const command = createCommand(['tti', '--days', '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(['tti', '--platform', 'ios']); - await command.runAsync(); - - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; expect(options.platform).toBe(AppObservePlatform.Ios); }); - it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => { - const command = createCommand(['tti', '--platform', 'android']); + it('passes --app-version', async () => { + const command = createCommand(['my_event', '--app-version', '2.1.0']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options.platform).toBe(AppObservePlatform.Android); + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.appVersion).toBe('2.1.0'); }); - it('passes --app-version to fetchObserveEventsAsync', async () => { - const command = createCommand(['tti', '--app-version', '2.1.0']); + it('passes --update-id', async () => { + const command = createCommand(['my_event', '--update-id', 'update-xyz']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options.appVersion).toBe('2.1.0'); + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.updateId).toBe('update-xyz'); }); - it('passes --update-id to fetchObserveEventsAsync', async () => { - const command = createCommand(['tti', '--update-id', 'update-xyz']); + it('passes --session-id', async () => { + const command = createCommand(['my_event', '--session-id', 'session-xyz']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options.updateId).toBe('update-xyz'); + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.sessionId).toBe('session-xyz'); }); - it('does not pass platform, appVersion, or updateId when flags are not provided', async () => { - const command = createCommand(['tti']); + it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => { + const command = createCommand(['my_event']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; expect(options.platform).toBeUndefined(); expect(options.appVersion).toBeUndefined(); expect(options.updateId).toBeUndefined(); + expect(options.sessionId).toBeUndefined(); }); - it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => { + it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => { const mockEvents = [ { id: 'evt-1', - metricName: 'expo.app_startup.tti', - metricValue: 1.23, + eventName: 'my_event', timestamp: '2025-01-15T10:30:00.000Z', appVersion: '1.0.0', appBuildNumber: '42', @@ -203,78 +243,139 @@ describe(ObserveEvents, () => { countryCode: 'US', sessionId: 'session-1', easClientId: 'client-1', + properties: [], }, ]; - mockFetchObserveEventsAsync.mockResolvedValue({ + mockFetchObserveCustomEventsAsync.mockResolvedValue({ events: mockEvents as any, pageInfo: { hasNextPage: false, hasPreviousPage: false }, }); - const command = createCommand(['tti', '--json', '--non-interactive']); + const command = createCommand(['my_event', '--json', '--non-interactive']); await command.runAsync(); expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveEventsJson).toHaveBeenCalledWith( + expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith( mockEvents, expect.objectContaining({ hasNextPage: false }) ); expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); }); - it('does not call enableJsonOutput when --json is not provided', async () => { - const command = createCommand(['tti']); + it('falls back to fetching event names and renders the empty-with-suggestions table when filtered fetch returns 0 events', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + const mockNames = [ + { eventName: 'foo', count: 10 }, + { eventName: 'bar', count: 5 }, + ]; + mockCustomEventNamesAsync.mockResolvedValue({ + names: mockNames as any, + isTruncated: false, + }); + + const command = createCommand(['my_event']); await command.runAsync(); - expect(mockEnableJsonOutput).not.toHaveBeenCalled(); - expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled(); + expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1); + expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1); + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith( + 'my_event', + mockNames, + expect.objectContaining({ isTruncated: false }) + ); }); - it('passes --sort flag through to fetchObserveEventsAsync', async () => { - const command = createCommand(['tti', '--sort', 'slowest']); + it('does not call customEventNamesAsync when filtered fetch returns at least one event', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [ + { + id: 'evt-1', + eventName: 'my_event', + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + easClientId: 'client-1', + properties: [], + } as any, + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const command = createCommand(['my_event']); await command.runAsync(); - const options = mockFetchObserveEventsAsync.mock.calls[0][2]; - expect(options.orderBy).toEqual({ - field: AppObserveEventsOrderByField.MetricValue, - direction: AppObserveEventsOrderByDirection.Desc, - }); + expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); }); - it('throws in non-interactive mode when no metric is provided', async () => { - const command = createCommand(['--non-interactive']); + it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + const mockNames = [{ eventName: 'foo', count: 10 }]; + mockCustomEventNamesAsync.mockResolvedValue({ + names: mockNames as any, + isTruncated: false, + }); + + const command = createCommand(['my_event', '--json', '--non-interactive']); + await command.runAsync(); - await expect(command.runAsync()).rejects.toThrow( - 'metric argument is required in non-interactive mode' + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).toHaveBeenCalledWith( + 'my_event', + mockNames, + false ); + expect(mockBuildObserveCustomEventsJson).not.toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); }); -}); -describe(resolveOrderBy, () => { - it('resolves lowercase "slowest" to MetricValue DESC', () => { - expect(resolveOrderBy('slowest')).toEqual({ - field: AppObserveEventsOrderByField.MetricValue, - direction: AppObserveEventsOrderByDirection.Desc, - }); - }); + it('does not run the empty-with-suggestions fallback when no event name is provided (event names mode)', async () => { + mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false }); - it('resolves lowercase "fastest" to MetricValue ASC', () => { - expect(resolveOrderBy('fastest')).toEqual({ - field: AppObserveEventsOrderByField.MetricValue, - direction: AppObserveEventsOrderByDirection.Asc, - }); + const command = createCommand([]); + await command.runAsync(); + + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled(); }); - it('resolves lowercase "newest" to Timestamp DESC', () => { - expect(resolveOrderBy('newest')).toEqual({ - field: AppObserveEventsOrderByField.Timestamp, - direction: AppObserveEventsOrderByDirection.Desc, + it('does not run the empty-with-suggestions fallback for --all-events with 0 results', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, }); + + const command = createCommand(['--all-events']); + await command.runAsync(); + + expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); + expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); }); - it('resolves lowercase "oldest" to Timestamp ASC', () => { - expect(resolveOrderBy('oldest')).toEqual({ - field: AppObserveEventsOrderByField.Timestamp, - direction: AppObserveEventsOrderByDirection.Asc, + it('emits JSON of event names + counts when --json is provided without an event name', async () => { + const mockNames = [ + { eventName: 'foo', count: 10 }, + { eventName: 'bar', count: 5 }, + ]; + mockCustomEventNamesAsync.mockResolvedValue({ + names: mockNames as any, + isTruncated: false, }); + + const command = createCommand(['--json', '--non-interactive']); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveCustomEventNamesJson).toHaveBeenCalledWith(mockNames, false); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); }); }); diff --git a/packages/eas-cli/src/commands/observe/__tests__/logs.test.ts b/packages/eas-cli/src/commands/observe/__tests__/logs.test.ts deleted file mode 100644 index 8b98568d8f..0000000000 --- a/packages/eas-cli/src/commands/observe/__tests__/logs.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; -import { getMockOclifConfig } from '../../../__tests__/commands/utils'; -import { AppObservePlatform } from '../../../graphql/generated'; -import { ObserveQuery } from '../../../graphql/queries/ObserveQuery'; -import { fetchObserveCustomEventsAsync } from '../../../observe/fetchCustomEvents'; -import { - buildObserveCustomEventNamesJson, - buildObserveCustomEventsEmptyWithSuggestionsJson, - buildObserveCustomEventsEmptyWithSuggestionsTable, - buildObserveCustomEventsJson, -} from '../../../observe/formatCustomEvents'; -import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; -import ObserveLogs from '../logs'; - -jest.mock('../../../observe/fetchCustomEvents'); -jest.mock('../../../observe/formatCustomEvents', () => ({ - buildObserveCustomEventsTable: jest.fn().mockReturnValue('table'), - buildObserveCustomEventsJson: jest.fn().mockReturnValue({}), - buildObserveCustomEventNamesTable: jest.fn().mockReturnValue('names-table'), - buildObserveCustomEventNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }), - buildObserveCustomEventsEmptyWithSuggestionsTable: jest - .fn() - .mockReturnValue('empty-with-suggestions-table'), - buildObserveCustomEventsEmptyWithSuggestionsJson: jest.fn().mockReturnValue({ - filteredEventName: 'my_event', - events: [], - availableEventNames: [], - availableEventNamesIsTruncated: false, - }), -})); -jest.mock('../../../graphql/queries/ObserveQuery', () => ({ - ObserveQuery: { - customEventNamesAsync: jest.fn(), - }, -})); -jest.mock('../../../log'); -jest.mock('../../../utils/json'); - -const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync); -const mockBuildObserveCustomEventsJson = jest.mocked(buildObserveCustomEventsJson); -const mockBuildObserveCustomEventNamesJson = jest.mocked(buildObserveCustomEventNamesJson); -const mockBuildObserveCustomEventsEmptyWithSuggestionsTable = jest.mocked( - buildObserveCustomEventsEmptyWithSuggestionsTable -); -const mockBuildObserveCustomEventsEmptyWithSuggestionsJson = jest.mocked( - buildObserveCustomEventsEmptyWithSuggestionsJson -); -const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync); -const mockEnableJsonOutput = jest.mocked(enableJsonOutput); -const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); - -describe(ObserveLogs, () => { - const graphqlClient = {} as any as ExpoGraphqlClient; - const mockConfig = getMockOclifConfig(); - const projectId = 'test-project-id'; - - beforeEach(() => { - jest.clearAllMocks(); - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false }); - }); - - function createCommand(argv: string[]): ObserveLogs { - const command = new ObserveLogs(argv, mockConfig); - // @ts-expect-error - jest.spyOn(command, 'getContextAsync').mockReturnValue({ - projectId, - loggedIn: { graphqlClient }, - }); - return command; - } - - it('passes eventName arg to fetchObserveCustomEventsAsync', async () => { - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [{ id: 'evt-1' } as any], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - const command = createCommand(['my_event']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.eventName).toBe('my_event'); - expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); - }); - - it('routes to customEventNamesAsync when no positional arg is provided', async () => { - const command = createCommand([]); - await command.runAsync(); - - expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1); - expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled(); - }); - - it('routes to fetchObserveCustomEventsAsync when --all-events is set with no positional arg', async () => { - const command = createCommand(['--all-events']); - await command.runAsync(); - - expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1); - expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.eventName).toBeUndefined(); - }); - - it('throws when both an event name argument and --all-events are provided', async () => { - const command = createCommand(['my_event', '--all-events']); - await expect(command.runAsync()).rejects.toThrow( - '--all-events cannot be combined with an event name argument' - ); - expect(mockFetchObserveCustomEventsAsync).not.toHaveBeenCalled(); - expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); - }); - - it('passes the resolved time range and platform to customEventNamesAsync', async () => { - const now = new Date('2025-06-15T12:00:00.000Z'); - jest.useFakeTimers({ now }); - - const command = createCommand(['--days', '7', '--platform', 'ios']); - await command.runAsync(); - - expect(mockCustomEventNamesAsync).toHaveBeenCalledWith(graphqlClient, { - appId: projectId, - startTime: '2025-06-08T12:00:00.000Z', - endTime: '2025-06-15T12:00:00.000Z', - platform: AppObservePlatform.Ios, - }); - - jest.useRealTimers(); - }); - - it('uses --days to compute start/end time range when an event name is provided', async () => { - const now = new Date('2025-06-15T12:00:00.000Z'); - jest.useFakeTimers({ now }); - - const command = createCommand(['my_event', '--days', '7']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.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 explicit --start and --end when provided', async () => { - const command = createCommand([ - 'my_event', - '--start', - '2025-01-01T00:00:00.000Z', - '--end', - '2025-02-01T00:00:00.000Z', - ]); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.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('rejects --days combined with --start', async () => { - const command = createCommand([ - 'my_event', - '--days', - '7', - '--start', - '2025-01-01T00:00:00.000Z', - ]); - await expect(command.runAsync()).rejects.toThrow(); - }); - - it('passes --limit to fetchObserveCustomEventsAsync', async () => { - const command = createCommand(['my_event', '--limit', '42']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.limit).toBe(42); - }); - - it('passes --after cursor', async () => { - const command = createCommand(['my_event', '--after', 'cursor-xyz']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.after).toBe('cursor-xyz'); - }); - - it('passes --platform ios as AppObservePlatform.Ios', async () => { - const command = createCommand(['my_event', '--platform', 'ios']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.platform).toBe(AppObservePlatform.Ios); - }); - - it('passes --app-version', async () => { - const command = createCommand(['my_event', '--app-version', '2.1.0']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.appVersion).toBe('2.1.0'); - }); - - it('passes --update-id', async () => { - const command = createCommand(['my_event', '--update-id', 'update-xyz']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.updateId).toBe('update-xyz'); - }); - - it('passes --session-id', async () => { - const command = createCommand(['my_event', '--session-id', 'session-xyz']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.sessionId).toBe('session-xyz'); - }); - - it('does not pass platform, appVersion, updateId, or sessionId when flags are not provided', async () => { - const command = createCommand(['my_event']); - await command.runAsync(); - - const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; - expect(options.platform).toBeUndefined(); - expect(options.appVersion).toBeUndefined(); - expect(options.updateId).toBeUndefined(); - expect(options.sessionId).toBeUndefined(); - }); - - it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided with an event name', async () => { - const mockEvents = [ - { - id: 'evt-1', - eventName: 'my_event', - 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', - properties: [], - }, - ]; - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: mockEvents as any, - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - - const command = createCommand(['my_event', '--json', '--non-interactive']); - await command.runAsync(); - - expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveCustomEventsJson).toHaveBeenCalledWith( - mockEvents, - expect.objectContaining({ hasNextPage: false }) - ); - expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); - }); - - it('falls back to fetching event names and renders the empty-with-suggestions table when filtered fetch returns 0 events', async () => { - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - const mockNames = [ - { eventName: 'foo', count: 10 }, - { eventName: 'bar', count: 5 }, - ]; - mockCustomEventNamesAsync.mockResolvedValue({ - names: mockNames as any, - isTruncated: false, - }); - - const command = createCommand(['my_event']); - await command.runAsync(); - - expect(mockFetchObserveCustomEventsAsync).toHaveBeenCalledTimes(1); - expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1); - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).toHaveBeenCalledWith( - 'my_event', - mockNames, - expect.objectContaining({ isTruncated: false }) - ); - }); - - it('does not call customEventNamesAsync when filtered fetch returns at least one event', async () => { - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [ - { - id: 'evt-1', - eventName: 'my_event', - timestamp: '2025-01-15T10:30:00.000Z', - appVersion: '1.0.0', - appBuildNumber: '42', - deviceModel: 'iPhone 15', - deviceOs: 'iOS', - deviceOsVersion: '17.0', - easClientId: 'client-1', - properties: [], - } as any, - ], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - - const command = createCommand(['my_event']); - await command.runAsync(); - - expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); - }); - - it('emits empty-with-suggestions JSON when filtered fetch returns 0 events and --json is set', async () => { - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - const mockNames = [{ eventName: 'foo', count: 10 }]; - mockCustomEventNamesAsync.mockResolvedValue({ - names: mockNames as any, - isTruncated: false, - }); - - const command = createCommand(['my_event', '--json', '--non-interactive']); - await command.runAsync(); - - expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).toHaveBeenCalledWith( - 'my_event', - mockNames, - false - ); - expect(mockBuildObserveCustomEventsJson).not.toHaveBeenCalled(); - expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); - }); - - it('does not run the empty-with-suggestions fallback when no event name is provided (event names mode)', async () => { - mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false }); - - const command = createCommand([]); - await command.runAsync(); - - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsJson).not.toHaveBeenCalled(); - }); - - it('does not run the empty-with-suggestions fallback for --all-events with 0 results', async () => { - mockFetchObserveCustomEventsAsync.mockResolvedValue({ - events: [], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - }); - - const command = createCommand(['--all-events']); - await command.runAsync(); - - expect(mockCustomEventNamesAsync).not.toHaveBeenCalled(); - expect(mockBuildObserveCustomEventsEmptyWithSuggestionsTable).not.toHaveBeenCalled(); - }); - - it('emits JSON of event names + counts when --json is provided without an event name', async () => { - const mockNames = [ - { eventName: 'foo', count: 10 }, - { eventName: 'bar', count: 5 }, - ]; - mockCustomEventNamesAsync.mockResolvedValue({ - names: mockNames as any, - isTruncated: false, - }); - - const command = createCommand(['--json', '--non-interactive']); - await command.runAsync(); - - expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveCustomEventNamesJson).toHaveBeenCalledWith(mockNames, false); - expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); - }); -}); diff --git a/packages/eas-cli/src/commands/observe/__tests__/metrics-summary.test.ts b/packages/eas-cli/src/commands/observe/__tests__/metrics-summary.test.ts new file mode 100644 index 0000000000..c8234afbbd --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/metrics-summary.test.ts @@ -0,0 +1,228 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { AppPlatform } from '../../../graphql/generated'; +import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics'; +import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveMetricsSummary from '../metrics-summary'; + +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 mockFetchObserveMetricsSummaryAsync = jest.mocked(fetchObserveMetricsAsync); +const mockBuildObserveMetricsSummaryTable = jest.mocked(buildObserveMetricsTable); +const mockBuildObserveMetricsSummaryJson = jest.mocked(buildObserveMetricsJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +describe(ObserveMetricsSummary, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveMetricsSummaryAsync.mockResolvedValue({ + metricsMap: new Map(), + buildNumbersMap: new Map(), + updateIdsMap: new Map(), + totalEventCounts: new Map(), + }); + }); + + function createCommand(argv: string[]): ObserveMetricsSummary { + const command = new ObserveMetricsSummary(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(mockFetchObserveMetricsSummaryAsync).toHaveBeenCalledTimes(1); + const platforms = mockFetchObserveMetricsSummaryAsync.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 = mockFetchObserveMetricsSummaryAsync.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 = mockFetchObserveMetricsSummaryAsync.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 = mockFetchObserveMetricsSummaryAsync.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 = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsSummaryAsync.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 = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsSummaryAsync.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', 'eventCount']); + await command.runAsync(); + + expect(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['p90', 'eventCount'], + expect.objectContaining({ daysBack: 60 }) + ); + }); + + it('deduplicates --stat flags that resolve to the same key', async () => { + const command = createCommand(['--stat', 'median', '--stat', 'median']); + await command.runAsync(); + + expect(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['median'], + expect.objectContaining({ daysBack: 60 }) + ); + }); + + it('uses --days to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--days', '7']); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsSummaryAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsSummaryAsync.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 combined with --start', async () => { + const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('rejects --days combined with --end', async () => { + const command = createCommand(['--days', '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(mockBuildObserveMetricsSummaryTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['median', 'eventCount'], + expect.objectContaining({ daysBack: 60 }) + ); + }); + + it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => { + const command = createCommand([ + '--json', + '--non-interactive', + '--stat', + 'min', + '--stat', + 'average', + ]); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveMetricsSummaryJson).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['min', 'average'], + expect.any(Map), + expect.any(Map), + expect.any(Map) + ); + 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(); + }); +}); diff --git a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts index 738539dff8..e5a25a70c2 100644 --- a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts +++ b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts @@ -1,29 +1,31 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { getMockOclifConfig } from '../../../__tests__/commands/utils'; -import { AppPlatform } from '../../../graphql/generated'; -import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics'; -import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics'; +import { + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, +} from '../../../graphql/generated'; +import { fetchObserveEventsAsync, resolveOrderBy } from '../../../observe/fetchEvents'; +import { buildObserveEventsJson } from '../../../observe/formatEvents'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import ObserveMetrics from '../metrics'; -jest.mock('../../../observe/fetchMetrics', () => { - const actual = jest.requireActual('../../../observe/fetchMetrics'); +jest.mock('../../../observe/fetchEvents', () => { + const actual = jest.requireActual('../../../observe/fetchEvents'); return { ...actual, - fetchObserveMetricsAsync: jest.fn(), + fetchObserveEventsAsync: jest.fn(), }; }); -jest.mock('../../../observe/formatMetrics', () => ({ - ...jest.requireActual('../../../observe/formatMetrics'), - buildObserveMetricsTable: jest.fn().mockReturnValue('table'), - buildObserveMetricsJson: jest.fn().mockReturnValue([]), +jest.mock('../../../observe/formatEvents', () => ({ + buildObserveEventsTable: jest.fn().mockReturnValue('table'), + buildObserveEventsJson: 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 mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); +const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); @@ -34,17 +36,15 @@ describe(ObserveMetrics, () => { beforeEach(() => { jest.clearAllMocks(); - mockFetchObserveMetricsAsync.mockResolvedValue({ - metricsMap: new Map(), - buildNumbersMap: new Map(), - updateIdsMap: new Map(), - totalEventCounts: new Map(), + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, }); }); function createCommand(argv: string[]): ObserveMetrics { const command = new ObserveMetrics(argv, mockConfig); - // @ts-expect-error getContextAsync is a protected method + // @ts-expect-error jest.spyOn(command, 'getContextAsync').mockReturnValue({ projectId, loggedIn: { graphqlClient }, @@ -52,173 +52,229 @@ describe(ObserveMetrics, () => { return command; } - it('fetches metrics with default parameters (both platforms)', async () => { + it('uses --days to compute start/end time range', async () => { const now = new Date('2025-06-15T12:00:00.000Z'); jest.useFakeTimers({ now }); - const command = createCommand([]); + const command = createCommand(['tti', '--days', '7']); await command.runAsync(); - expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1); - const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; - expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]); + 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('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('uses DEFAULT_DAYS_BACK (60 days) when neither --days nor --start/--end are provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); - it('queries only iOS when --platform ios is passed', async () => { - const command = createCommand(['--platform', 'ios']); + const command = createCommand(['tti']); await command.runAsync(); - const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; - expect(platforms).toEqual([AppPlatform.Ios]); + 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('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => { - const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']); + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + 'tti', + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); await command.runAsync(); - const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2]; - expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']); + 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('uses default time range (60 days back) when no --start/--end flags', async () => { + 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([]); + const command = createCommand(['tti', '--start', '2025-01-01T00:00:00.000Z']); 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'); + 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('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', - ]); + it('rejects --days combined with --start', async () => { + const command = createCommand(['tti', '--days', '7', '--start', '2025-01-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('passes --limit to fetchObserveEventsAsync', async () => { + const command = createCommand(['tti', '--limit', '42']); 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'); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.limit).toBe(42); }); - it('passes resolved --stat flags to buildObserveMetricsTable', async () => { - const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']); + it('passes --after cursor to fetchObserveEventsAsync', async () => { + const command = createCommand(['tti', '--after', 'cursor-xyz']); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['p90', 'eventCount'], - expect.objectContaining({ daysBack: 60 }) - ); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.after).toBe('cursor-xyz'); }); - it('deduplicates --stat flags that resolve to the same key', async () => { - const command = createCommand(['--stat', 'median', '--stat', 'median']); + it('does not pass after when --after flag is not provided', async () => { + const command = createCommand(['tti']); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['median'], - expect.objectContaining({ daysBack: 60 }) - ); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options).not.toHaveProperty('after'); }); - it('uses --days to compute start/end time range', async () => { - const now = new Date('2025-06-15T12:00:00.000Z'); - jest.useFakeTimers({ now }); + it('rejects --days combined with --end', async () => { + const command = createCommand(['tti', '--days', '7', '--end', '2025-02-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); - const command = createCommand(['--days', '7']); + it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => { + const command = createCommand(['tti', '--platform', 'ios']); 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'); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Ios); + }); - jest.useRealTimers(); + it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => { + const command = createCommand(['tti', '--platform', 'android']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Android); }); - it('rejects --days combined with --start', async () => { - const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']); + it('passes --app-version to fetchObserveEventsAsync', async () => { + const command = createCommand(['tti', '--app-version', '2.1.0']); + await command.runAsync(); - await expect(command.runAsync()).rejects.toThrow(); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.appVersion).toBe('2.1.0'); }); - it('rejects --days combined with --end', async () => { - const command = createCommand(['--days', '7', '--end', '2025-02-01T00:00:00.000Z']); + it('passes --update-id to fetchObserveEventsAsync', async () => { + const command = createCommand(['tti', '--update-id', 'update-xyz']); + await command.runAsync(); - await expect(command.runAsync()).rejects.toThrow(); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.updateId).toBe('update-xyz'); }); - it('uses default stats when --stat is not provided', async () => { - const command = createCommand([]); + it('does not pass platform, appVersion, or updateId when flags are not provided', async () => { + const command = createCommand(['tti']); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['median', 'eventCount'], - expect.objectContaining({ daysBack: 60 }) - ); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBeUndefined(); + expect(options.appVersion).toBeUndefined(); + expect(options.updateId).toBeUndefined(); }); - it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => { - const command = createCommand([ - '--json', - '--non-interactive', - '--stat', - 'min', - '--stat', - 'average', - ]); + 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(['tti', '--json', '--non-interactive']); await command.runAsync(); expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['min', 'average'], - expect.any(Map), - expect.any(Map), - expect.any(Map) + expect(mockBuildObserveEventsJson).toHaveBeenCalledWith( + mockEvents, + expect.objectContaining({ hasNextPage: false }) ); 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('does not call enableJsonOutput when --json is not provided', async () => { + const command = createCommand(['tti']); + await command.runAsync(); + + expect(mockEnableJsonOutput).not.toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled(); + }); + + it('passes --sort flag through to fetchObserveEventsAsync', async () => { + const command = createCommand(['tti', '--sort', 'slowest']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.orderBy).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('throws in non-interactive mode when no metric is provided', async () => { + const command = createCommand(['--non-interactive']); + + await expect(command.runAsync()).rejects.toThrow( + 'metric argument is required in non-interactive mode' ); }); +}); - it('throws on invalid --end date', () => { - expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"'); +describe(resolveOrderBy, () => { + it('resolves lowercase "slowest" to MetricValue DESC', () => { + expect(resolveOrderBy('slowest')).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('resolves lowercase "fastest" to MetricValue ASC', () => { + expect(resolveOrderBy('fastest')).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Asc, + }); }); - it('accepts valid ISO date in --start', () => { - expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow(); + it('resolves lowercase "newest" to Timestamp DESC', () => { + expect(resolveOrderBy('newest')).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('resolves lowercase "oldest" to Timestamp ASC', () => { + expect(resolveOrderBy('oldest')).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Asc, + }); }); }); diff --git a/packages/eas-cli/src/commands/observe/events.ts b/packages/eas-cli/src/commands/observe/events.ts index 2e9b59b3fa..55c945d07d 100644 --- a/packages/eas-cli/src/commands/observe/events.ts +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -1,16 +1,11 @@ import { Args, Flags } from '@oclif/core'; import EasCommand from '../../commandUtils/EasCommand'; -import { EasCommandError } from '../../commandUtils/errors'; import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; import Log from '../../log'; -import { - EventsOrderPreset, - fetchObserveEventsAsync, - fetchTotalEventCountAsync, - resolveOrderBy, -} from '../../observe/fetchEvents'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents'; import { ObserveAfterFlag, ObserveAppVersionFlag, @@ -19,35 +14,34 @@ import { ObserveTimeRangeFlags, ObserveUpdateIdFlag, } from '../../observe/flags'; -import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames'; -import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents'; -import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms'; +import { + buildObserveCustomEventNamesJson, + buildObserveCustomEventNamesTable, + buildObserveCustomEventsEmptyWithSuggestionsJson, + buildObserveCustomEventsEmptyWithSuggestionsTable, + buildObserveCustomEventsJson, + buildObserveCustomEventsTable, +} from '../../observe/formatCustomEvents'; +import { appObservePlatformFromFlag } from '../../observe/platforms'; import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; import { resolveTimeRange } from '../../observe/startAndEndTime'; -import { selectAsync } from '../../prompts'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; const DEFAULT_EVENTS_LIMIT = 10; export default class ObserveEvents extends EasCommand { static override hidden = true; - static override description = 'display individual app performance events ordered by metric value'; + static override description = + 'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.'; static override args = { - metric: Args.string({ - description: 'Metric to query (e.g. tti, cold_launch)', + eventName: Args.string({ + description: 'Event name to filter by', required: false, - options: Object.keys(METRIC_ALIASES), }), }; static override flags = { - sort: Flags.option({ - description: 'Sort order for events', - options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()), - required: false, - default: EventsOrderPreset.Oldest.valueOf().toLowerCase(), - })(), ...ObservePlatformFlag, ...ObserveAfterFlag, limit: getLimitFlagWithCustomValues({ @@ -57,6 +51,14 @@ export default class ObserveEvents extends EasCommand { ...ObserveTimeRangeFlags, ...ObserveAppVersionFlag, ...ObserveUpdateIdFlag, + 'session-id': Flags.string({ + description: 'Filter by session ID', + }), + 'all-events': Flags.boolean({ + description: + 'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.', + default: false, + }), ...ObserveProjectIdFlag, ...EasNonInteractiveAndJsonFlags, }; @@ -73,6 +75,12 @@ export default class ObserveEvents extends EasCommand { async runAsync(): Promise { const { flags, args } = await this.parse(ObserveEvents); + if (args.eventName && flags['all-events']) { + throw new Error( + '--all-events cannot be combined with an event name argument. Pass an event name to filter by it, or pass --all-events to list all events across all event names.' + ); + } + const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({ command: this, commandClass: ObserveEvents, @@ -87,61 +95,82 @@ export default class ObserveEvents extends EasCommand { Log.warn('EAS Observe is in preview and subject to breaking changes.'); } - let metricName: string; - if (args.metric) { - metricName = resolveMetricName(args.metric); - } else if (flags['non-interactive']) { - throw new EasCommandError( - 'A metric argument is required in non-interactive mode. Available metrics: ' + - Object.keys(METRIC_ALIASES).join(', ') - ); - } else { - const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({ - title: `${displayName} (${fullName})`, - value: fullName, - })); - metricName = await selectAsync('Select a metric', choices); - } - const orderBy = resolveOrderBy(flags.sort); - const { daysBack, startTime, endTime } = resolveTimeRange(flags); const platform = appObservePlatformFromFlag(flags.platform); - const platforms = appPlatformsFromFlag(flags.platform); - - const [{ events, pageInfo }, totalEventCount] = await Promise.all([ - fetchObserveEventsAsync(graphqlClient, projectId, { - metricName, - orderBy, - limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, - ...(flags.after && { after: flags.after }), + + if (!args.eventName && !flags['all-events']) { + const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, { + appId: projectId, startTime, endTime, platform, - appVersion: flags['app-version'], - updateId: flags['update-id'], - }), - fetchTotalEventCountAsync( - graphqlClient, - projectId, - metricName, - platforms, + }); + + if (flags.json) { + printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated)); + } else { + Log.addNewLineIfNone(); + Log.log( + buildObserveCustomEventNamesTable(names, { + daysBack, + startTime, + endTime, + isTruncated, + }) + ); + } + return; + } + + const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, { + eventName: args.eventName, + limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, + ...(flags.after && { after: flags.after }), + startTime, + endTime, + platform, + appVersion: flags['app-version'], + updateId: flags['update-id'], + sessionId: flags['session-id'], + }); + + if (args.eventName && events.length === 0) { + const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, { + appId: projectId, startTime, - endTime - ), - ]); + endTime, + platform, + }); + + if (flags.json) { + printJsonOnlyOutput( + buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated) + ); + } else { + Log.addNewLineIfNone(); + Log.log( + buildObserveCustomEventsEmptyWithSuggestionsTable(args.eventName, names, { + daysBack, + startTime, + endTime, + isTruncated, + }) + ); + } + return; + } if (flags.json) { - printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo)); + printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo)); } else { Log.addNewLineIfNone(); Log.log( - buildObserveEventsTable(events, pageInfo, { - metricName, + buildObserveCustomEventsTable(events, pageInfo, { + eventName: args.eventName, daysBack, startTime, endTime, - totalEventCount, }) ); } diff --git a/packages/eas-cli/src/commands/observe/logs.ts b/packages/eas-cli/src/commands/observe/logs.ts deleted file mode 100644 index 89cec95e3f..0000000000 --- a/packages/eas-cli/src/commands/observe/logs.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { Args, Flags } from '@oclif/core'; - -import EasCommand from '../../commandUtils/EasCommand'; -import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; -import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; -import Log from '../../log'; -import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; -import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents'; -import { - ObserveAfterFlag, - ObserveAppVersionFlag, - ObservePlatformFlag, - ObserveProjectIdFlag, - ObserveTimeRangeFlags, - ObserveUpdateIdFlag, -} from '../../observe/flags'; -import { - buildObserveCustomEventNamesJson, - buildObserveCustomEventNamesTable, - buildObserveCustomEventsEmptyWithSuggestionsJson, - buildObserveCustomEventsEmptyWithSuggestionsTable, - buildObserveCustomEventsJson, - buildObserveCustomEventsTable, -} from '../../observe/formatCustomEvents'; -import { appObservePlatformFromFlag } from '../../observe/platforms'; -import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; -import { resolveTimeRange } from '../../observe/startAndEndTime'; -import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; - -const DEFAULT_EVENTS_LIMIT = 10; - -export default class ObserveLogs extends EasCommand { - static override hidden = true; - static override description = - 'display individual custom events (logs) emitted by the app, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.'; - - static override args = { - eventName: Args.string({ - description: 'Custom event name to filter by', - required: false, - }), - }; - - static override flags = { - ...ObservePlatformFlag, - ...ObserveAfterFlag, - limit: getLimitFlagWithCustomValues({ - defaultTo: DEFAULT_EVENTS_LIMIT, - limit: 100, - }), - ...ObserveTimeRangeFlags, - ...ObserveAppVersionFlag, - ...ObserveUpdateIdFlag, - 'session-id': Flags.string({ - description: 'Filter by session ID', - }), - 'all-events': Flags.boolean({ - description: - 'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.', - default: false, - }), - ...ObserveProjectIdFlag, - ...EasNonInteractiveAndJsonFlags, - }; - - static override contextDefinition = { - ...this.ContextOptions.ProjectId, - ...this.ContextOptions.LoggedIn, - }; - - private static loggedInOnlyContextDefinition = { - ...this.ContextOptions.LoggedIn, - }; - - async runAsync(): Promise { - const { flags, args } = await this.parse(ObserveLogs); - - if (args.eventName && flags['all-events']) { - throw new Error( - '--all-events cannot be combined with an event name argument. Pass an event name to filter by it, or pass --all-events to list all events across all event names.' - ); - } - - const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({ - command: this, - commandClass: ObserveLogs, - loggedInOnlyContextDefinition: ObserveLogs.loggedInOnlyContextDefinition, - projectIdOverride: flags['project-id'], - nonInteractive: flags['non-interactive'], - }); - - if (flags.json) { - enableJsonOutput(); - } else { - Log.warn('EAS Observe is in preview and subject to breaking changes.'); - } - - const { daysBack, startTime, endTime } = resolveTimeRange(flags); - - const platform = appObservePlatformFromFlag(flags.platform); - - if (!args.eventName && !flags['all-events']) { - const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, { - appId: projectId, - startTime, - endTime, - platform, - }); - - if (flags.json) { - printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated)); - } else { - Log.addNewLineIfNone(); - Log.log( - buildObserveCustomEventNamesTable(names, { - daysBack, - startTime, - endTime, - isTruncated, - }) - ); - } - return; - } - - const { events, pageInfo } = await fetchObserveCustomEventsAsync(graphqlClient, projectId, { - eventName: args.eventName, - limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, - ...(flags.after && { after: flags.after }), - startTime, - endTime, - platform, - appVersion: flags['app-version'], - updateId: flags['update-id'], - sessionId: flags['session-id'], - }); - - if (args.eventName && events.length === 0) { - const { names, isTruncated } = await ObserveQuery.customEventNamesAsync(graphqlClient, { - appId: projectId, - startTime, - endTime, - platform, - }); - - if (flags.json) { - printJsonOnlyOutput( - buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated) - ); - } else { - Log.addNewLineIfNone(); - Log.log( - buildObserveCustomEventsEmptyWithSuggestionsTable(args.eventName, names, { - daysBack, - startTime, - endTime, - isTruncated, - }) - ); - } - return; - } - - if (flags.json) { - printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo)); - } else { - Log.addNewLineIfNone(); - Log.log( - buildObserveCustomEventsTable(events, pageInfo, { - eventName: args.eventName, - daysBack, - startTime, - endTime, - }) - ); - } - } -} diff --git a/packages/eas-cli/src/commands/observe/metrics-summary.ts b/packages/eas-cli/src/commands/observe/metrics-summary.ts new file mode 100644 index 0000000000..c658f43bf9 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/metrics-summary.ts @@ -0,0 +1,138 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import Log from '../../log'; +import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics'; +import { + ObservePlatformFlag, + ObserveProjectIdFlag, + ObserveTimeRangeFlags, +} from '../../observe/flags'; +import { + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + resolveStatKey, +} from '../../observe/formatMetrics'; +import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames'; +import { appPlatformsFromFlag } from '../../observe/platforms'; +import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; +import { resolveTimeRange } from '../../observe/startAndEndTime'; +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_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +export default class ObserveMetricsSummary extends EasCommand { + static override hidden = true; + static override description = + 'display aggregated performance metric statistics grouped by app version'; + + static override flags = { + ...ObservePlatformFlag, + metric: Flags.option({ + description: 'Metric name to display (can be specified multiple times).', + multiple: true, + options: Object.keys(METRIC_ALIASES), + })(), + stat: Flags.option({ + description: 'Statistic to display per metric (can be specified multiple times)', + multiple: true, + options: DEFAULT_STATS_JSON, + })(), + ...ObserveTimeRangeFlags, + ...ObserveProjectIdFlag, + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + private static loggedInOnlyContextDefinition = { + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveMetricsSummary); + + const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({ + command: this, + commandClass: ObserveMetricsSummary, + loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition, + projectIdOverride: flags['project-id'], + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + const metricNames = flags.metric?.length + ? flags.metric.map(resolveMetricName) + : DEFAULT_METRICS; + + const { daysBack, startTime, endTime } = resolveTimeRange(flags); + + const platforms = appPlatformsFromFlag(flags.platform); + + const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } = + 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, + totalEventCounts, + buildNumbersMap, + updateIdsMap + ) + ); + } else { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; + Log.addNewLineIfNone(); + Log.log( + buildObserveMetricsTable(metricsMap, metricNames, stats, { + daysBack, + buildNumbersMap, + totalEventCounts, + }) + ); + } + } +} diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts index 915f88f71a..3e06be60b1 100644 --- a/packages/eas-cli/src/commands/observe/metrics.ts +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -1,63 +1,62 @@ -import { Flags } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import EasCommand from '../../commandUtils/EasCommand'; +import { EasCommandError } from '../../commandUtils/errors'; import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; import Log from '../../log'; -import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics'; import { + EventsOrderPreset, + fetchObserveEventsAsync, + fetchTotalEventCountAsync, + resolveOrderBy, +} from '../../observe/fetchEvents'; +import { + ObserveAfterFlag, + ObserveAppVersionFlag, ObservePlatformFlag, ObserveProjectIdFlag, ObserveTimeRangeFlags, + ObserveUpdateIdFlag, } from '../../observe/flags'; -import { - StatisticKey, - buildObserveMetricsJson, - buildObserveMetricsTable, - resolveStatKey, -} from '../../observe/formatMetrics'; -import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames'; -import { appPlatformsFromFlag } from '../../observe/platforms'; +import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents'; +import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms'; import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; import { resolveTimeRange } from '../../observe/startAndEndTime'; +import { selectAsync } from '../../prompts'; 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_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; -const DEFAULT_STATS_JSON: StatisticKey[] = [ - 'min', - 'median', - 'max', - 'average', - 'p80', - 'p90', - 'p99', - 'eventCount', -]; +const DEFAULT_EVENTS_LIMIT = 10; export default class ObserveMetrics extends EasCommand { static override hidden = true; - static override description = 'display app performance metrics grouped by app version'; + static override description = 'display individual performance metric samples ordered by value'; - static override flags = { - ...ObservePlatformFlag, - metric: Flags.option({ - description: 'Metric name to display (can be specified multiple times).', - multiple: true, + static override args = { + metric: Args.string({ + description: 'Metric to query (e.g. tti, cold_launch)', + required: false, options: Object.keys(METRIC_ALIASES), + }), + }; + + static override flags = { + sort: Flags.option({ + description: 'Sort order for events', + options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()), + required: false, + default: EventsOrderPreset.Oldest.valueOf().toLowerCase(), })(), - stat: Flags.option({ - description: 'Statistic to display per metric (can be specified multiple times)', - multiple: true, - options: DEFAULT_STATS_JSON, - })(), + ...ObservePlatformFlag, + ...ObserveAfterFlag, + limit: getLimitFlagWithCustomValues({ + defaultTo: DEFAULT_EVENTS_LIMIT, + limit: 100, + }), ...ObserveTimeRangeFlags, + ...ObserveAppVersionFlag, + ...ObserveUpdateIdFlag, ...ObserveProjectIdFlag, ...EasNonInteractiveAndJsonFlags, }; @@ -72,7 +71,7 @@ export default class ObserveMetrics extends EasCommand { }; async runAsync(): Promise { - const { flags } = await this.parse(ObserveMetrics); + const { flags, args } = await this.parse(ObserveMetrics); const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({ command: this, @@ -88,48 +87,61 @@ export default class ObserveMetrics extends EasCommand { Log.warn('EAS Observe is in preview and subject to breaking changes.'); } - const metricNames = flags.metric?.length - ? flags.metric.map(resolveMetricName) - : DEFAULT_METRICS; + let metricName: string; + if (args.metric) { + metricName = resolveMetricName(args.metric); + } else if (flags['non-interactive']) { + throw new EasCommandError( + 'A metric argument is required in non-interactive mode. Available metrics: ' + + Object.keys(METRIC_ALIASES).join(', ') + ); + } else { + const choices = Object.entries(METRIC_SHORT_NAMES).map(([fullName, displayName]) => ({ + title: `${displayName} (${fullName})`, + value: fullName, + })); + metricName = await selectAsync('Select a metric', choices); + } + const orderBy = resolveOrderBy(flags.sort); const { daysBack, startTime, endTime } = resolveTimeRange(flags); + const platform = appObservePlatformFromFlag(flags.platform); const platforms = appPlatformsFromFlag(flags.platform); - const { metricsMap, buildNumbersMap, updateIdsMap, totalEventCounts } = - await fetchObserveMetricsAsync( + const [{ events, pageInfo }, totalEventCount] = await Promise.all([ + 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'], + }), + fetchTotalEventCountAsync( graphqlClient, projectId, - metricNames, + metricName, 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, - totalEventCounts, - buildNumbersMap, - updateIdsMap - ) - ); + printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo)); } else { - const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; Log.addNewLineIfNone(); Log.log( - buildObserveMetricsTable(metricsMap, metricNames, stats, { + buildObserveEventsTable(events, pageInfo, { + metricName, daysBack, - buildNumbersMap, - totalEventCounts, + startTime, + endTime, + totalEventCount, }) ); } diff --git a/packages/eas-cli/src/observe/__tests__/formatCustomEvents.test.ts b/packages/eas-cli/src/observe/__tests__/formatCustomEvents.test.ts index 3f3e87428d..62c72db24e 100644 --- a/packages/eas-cli/src/observe/__tests__/formatCustomEvents.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatCustomEvents.test.ts @@ -69,7 +69,7 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL it('returns yellow warning for empty events', () => { const output = buildObserveCustomEventsTable([], noNextPage); - expect(output).toContain('No custom events found.'); + expect(output).toContain('No events found.'); }); it('shows event name in summary header when an event name option is provided', () => { @@ -82,13 +82,13 @@ Jan 14, 2025, 08:15:00.000 AM checkout 1.1.0 (38) Android 14 Pixel 8 PL expect(output).toContain('login events for the last 30 days'); }); - it('shows the generic "Custom events" subject when no event name option is provided', () => { + it('shows the generic "Events" subject when no event name option is provided', () => { const events = [makeCustomEvent()]; const output = buildObserveCustomEventsTable(events, noNextPage, { daysBack: 30, }); - expect(output).toContain('Custom events for the last 30 days'); + expect(output).toContain('Events for the last 30 days'); }); it('shows date range in summary header when start/end provided', () => { @@ -294,7 +294,7 @@ describe(buildObserveCustomEventsJson, () => { describe(buildObserveCustomEventNamesTable, () => { it('returns yellow warning when names list is empty', () => { const output = buildObserveCustomEventNamesTable([]); - expect(output).toContain('No custom event names found.'); + expect(output).toContain('No event names found.'); }); it('shows event names with counts', () => { @@ -336,7 +336,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => { const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', []); expect(output).toContain('No events found matching "login"'); - expect(output).toContain('No custom event names found in this time range.'); + expect(output).toContain('No event names found in this time range.'); }); it('appends a truncation notice when isTruncated is set', () => { diff --git a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts index f0702a638d..1b3905b253 100644 --- a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts +++ b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts @@ -25,6 +25,10 @@ describe(resolveMetricName, () => { expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time'); }); + it('resolves short alias "update_download" to full metric name', () => { + expect(resolveMetricName('update_download')).toBe('expo.updates.download_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( @@ -73,6 +77,7 @@ describe(getMetricDisplayName, () => { expect(getMetricDisplayName('expo.app_startup.tti')).toBe('Startup TTI'); expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('Startup TTR'); expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load'); + expect(getMetricDisplayName('expo.updates.download_time')).toBe('Update Download'); }); it('returns short display name for known navigation metrics', () => { diff --git a/packages/eas-cli/src/observe/formatCustomEvents.ts b/packages/eas-cli/src/observe/formatCustomEvents.ts index 646bf5f587..e98558b960 100644 --- a/packages/eas-cli/src/observe/formatCustomEvents.ts +++ b/packages/eas-cli/src/observe/formatCustomEvents.ts @@ -54,7 +54,7 @@ export function buildObserveCustomEventsTable( options?: BuildCustomEventsTableOptions ): string { if (events.length === 0) { - return chalk.yellow('No custom events found.'); + return chalk.yellow('No events found.'); } const showEventName = !options?.eventName; @@ -88,7 +88,7 @@ export function buildObserveCustomEventsTable( options.totalEventCount != null ? ` — ${options.totalEventCount.toLocaleString()} total events` : ''; - const subject = options.eventName ? `${options.eventName} events` : 'Custom events'; + const subject = options.eventName ? `${options.eventName} events` : 'Events'; lines.push(chalk.bold(`${subject} ${timeDesc}${totalDesc}`.trim()), ''); } @@ -156,7 +156,7 @@ export function buildObserveCustomEventsEmptyWithSuggestionsTable( lines.push(chalk.yellow(`No events found matching "${eventName}" ${timeDesc}.`.trim())); if (names.length === 0) { - lines.push('', chalk.yellow('No custom event names found in this time range.')); + lines.push('', chalk.yellow('No event names found in this time range.')); return lines.join('\n'); } @@ -203,7 +203,7 @@ export function buildObserveCustomEventNamesTable( options?: BuildCustomEventNamesTableOptions ): string { if (names.length === 0) { - return chalk.yellow('No custom event names found.'); + return chalk.yellow('No event names found.'); } const headers = ['Event Name', 'Count']; @@ -213,7 +213,7 @@ export function buildObserveCustomEventNamesTable( if (options) { const timeDesc = buildTimeRangeDescription(options); - const subject = 'Custom event names'; + const subject = 'Event names'; lines.push(chalk.bold(`${subject} ${timeDesc}`.trim()), ''); } diff --git a/packages/eas-cli/src/observe/metricNames.ts b/packages/eas-cli/src/observe/metricNames.ts index de11e8e494..7dc8b08dbd 100644 --- a/packages/eas-cli/src/observe/metricNames.ts +++ b/packages/eas-cli/src/observe/metricNames.ts @@ -6,6 +6,7 @@ export const METRIC_ALIASES: Record = { cold_launch: 'expo.app_startup.cold_launch_time', warm_launch: 'expo.app_startup.warm_launch_time', bundle_load: 'expo.app_startup.bundle_load_time', + update_download: 'expo.updates.download_time', }; export const NAVIGATION_METRIC_ALIASES: Record = { @@ -23,6 +24,7 @@ export const METRIC_SHORT_NAMES: Record = { 'expo.app_startup.tti': 'Startup TTI', 'expo.app_startup.ttr': 'Startup TTR', 'expo.app_startup.bundle_load_time': 'Bundle Load', + 'expo.updates.download_time': 'Update Download', 'expo.navigation.cold_ttr': 'Nav Cold TTR', 'expo.navigation.warm_ttr': 'Nav Warm TTR', 'expo.navigation.tti': 'Nav TTI',