From 57c3aa3f8fc2ff1574174476e361e9e19fb8edf4 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 11:18:20 +0100 Subject: [PATCH] [eas-cli] Add observe:events command --- CHANGELOG.md | 2 +- .../commands/observe/__tests__/events.test.ts | 237 +++++++++++++++ .../eas-cli/src/commands/observe/events.ts | 133 +++++++++ .../src/graphql/queries/ObserveQuery.ts | 95 +++++- .../src/observe/__tests__/fetchEvents.test.ts | 272 ++++++++++++++++++ .../observe/__tests__/formatEvents.test.ts | 189 ++++++++++++ packages/eas-cli/src/observe/fetchEvents.ts | 78 +++++ packages/eas-cli/src/observe/formatEvents.ts | 88 ++++++ 8 files changed, 1092 insertions(+), 2 deletions(-) create mode 100644 packages/eas-cli/src/commands/observe/__tests__/events.test.ts create mode 100644 packages/eas-cli/src/commands/observe/events.ts create mode 100644 packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts create mode 100644 packages/eas-cli/src/observe/__tests__/formatEvents.test.ts create mode 100644 packages/eas-cli/src/observe/fetchEvents.ts create mode 100644 packages/eas-cli/src/observe/formatEvents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f0f4e4211..ad07236cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features -- Add `eas observe:metrics` command for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)) +- Add `eas observe:metrics` and `eas observe:events` commands for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts new file mode 100644 index 0000000000..e138ab2206 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -0,0 +1,237 @@ +import { Config } from '@oclif/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppObservePlatform } from '../../../graphql/generated'; +import { fetchObserveEventsAsync } from '../../../observe/fetchEvents'; +import { buildObserveEventsJson } from '../../../observe/formatEvents'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveEvents from '../events'; + +jest.mock('../../../observe/fetchEvents'); +jest.mock('../../../observe/formatEvents', () => ({ + buildObserveEventsTable: jest.fn().mockReturnValue('table'), + buildObserveEventsJson: jest.fn().mockReturnValue({}), +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); +const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +describe(ObserveEvents, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = {} as unknown as Config; + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + }); + + function createCommand(argv: string[]): ObserveEvents { + const command = new ObserveEvents(argv, mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + it('uses --days-from-now to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--days-from-now', '7']); + await command.runAsync(); + + expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(options.startTime).toBe('2025-06-08T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-04-16T12:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('defaults endTime to now when only --start is provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('rejects --days-from-now combined with --start', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--start', + '2025-01-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('passes --limit to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--limit', '42']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.limit).toBe(42); + }); + + it('passes --after cursor to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.after).toBe('cursor-xyz'); + }); + + it('does not pass after when --after flag is not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options).not.toHaveProperty('after'); + }); + + it('rejects --days-from-now combined with --end', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => { + const command = createCommand(['--metric', 'tti', '--platform', 'ios']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Ios); + }); + + it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => { + const command = createCommand(['--metric', 'tti', '--platform', 'android']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Android); + }); + + it('passes --app-version to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.appVersion).toBe('2.1.0'); + }); + + it('passes --update-id to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.updateId).toBe('update-xyz'); + }); + + it('does not pass platform, appVersion, or updateId when flags are not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBeUndefined(); + expect(options.appVersion).toBeUndefined(); + expect(options.updateId).toBeUndefined(); + }); + + it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => { + const mockEvents = [ + { + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + }, + ]; + mockFetchObserveEventsAsync.mockResolvedValue({ + events: mockEvents as any, + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveEventsJson).toHaveBeenCalledWith( + mockEvents, + expect.objectContaining({ hasNextPage: false }) + ); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); + }); + + it('does not call enableJsonOutput when --json is not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + expect(mockEnableJsonOutput).not.toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/observe/events.ts b/packages/eas-cli/src/commands/observe/events.ts new file mode 100644 index 0000000000..226a81be2b --- /dev/null +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -0,0 +1,133 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; +import { AppObservePlatform } from '../../graphql/generated'; +import Log from '../../log'; +import { + type EventsOrderPreset, + fetchObserveEventsAsync, + resolveOrderBy, +} from '../../observe/fetchEvents'; +import { resolveMetricName } from '../../observe/metricNames'; +import { validateDateFlag } from '../../observe/fetchMetrics'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +const DEFAULT_EVENTS_LIMIT = 10; +const DEFAULT_DAYS_BACK = 60; + +export default class ObserveEvents extends EasCommand { + static override description = 'display individual app performance events ordered by metric value'; + + static override flags = { + metric: Flags.string({ + description: + 'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)', + required: true, + }), + sort: Flags.enum({ + description: 'Sort order for events', + options: ['slowest', 'fastest', 'newest', 'oldest'], + default: 'slowest', + }), + platform: Flags.enum<'android' | 'ios'>({ + description: 'Filter by platform', + options: ['android', 'ios'], + }), + after: Flags.string({ + description: + 'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.', + }), + limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_EVENTS_LIMIT, limit: 100 }), + start: Flags.string({ + description: 'Start of time range (ISO date)', + exclusive: ['days-from-now'], + }), + end: Flags.string({ + description: 'End of time range (ISO date)', + exclusive: ['days-from-now'], + }), + 'days-from-now': Flags.integer({ + description: 'Show events from the last N days (mutually exclusive with --start/--end)', + min: 1, + exclusive: ['start', 'end'], + }), + 'app-version': Flags.string({ + description: 'Filter by app version', + }), + 'update-id': Flags.string({ + description: 'Filter by EAS update ID', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveEvents); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveEvents, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricName = resolveMetricName(flags.metric); + const orderBy = resolveOrderBy(flags.sort); + + let startTime: string; + let endTime: string; + + if (flags['days-from-now']) { + endTime = new Date().toISOString(); + startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString(); + } else { + endTime = flags.end ?? new Date().toISOString(); + startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + } + + const platform = flags.platform + ? flags.platform === 'android' + ? AppObservePlatform.Android + : AppObservePlatform.Ios + : undefined; + + const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, { + metricName, + orderBy, + limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, + ...(flags.after && { after: flags.after }), + startTime, + endTime, + platform, + appVersion: flags['app-version'], + updateId: flags['update-id'], + }); + + if (flags.json) { + printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo)); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveEventsTable(events, pageInfo)); + } + } +} diff --git a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts index 54ff577c0b..aeb247944d 100644 --- a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -2,7 +2,15 @@ import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { withErrorHandlingAsync } from '../client'; -import { AppObservePlatform, AppObserveTimeSeriesInput, AppObserveVersionMarker } from '../generated'; +import { + AppObserveEvent, + AppObserveEventsFilter, + AppObserveEventsOrderBy, + AppObservePlatform, + AppObserveTimeSeriesInput, + AppObserveVersionMarker, + PageInfo, +} from '../generated'; type AppObserveTimeSeriesQuery = { app: { @@ -22,6 +30,31 @@ type AppObserveTimeSeriesQueryVariables = { input: Pick; }; +type AppObserveEventsQuery = { + app: { + byId: { + id: string; + observe: { + events: { + pageInfo: PageInfo; + edges: Array<{ + cursor: string; + node: AppObserveEvent; + }>; + }; + }; + }; + }; +}; + +type AppObserveEventsQueryVariables = { + appId: string; + filter?: AppObserveEventsFilter; + first?: number; + after?: string; + orderBy?: AppObserveEventsOrderBy; +}; + export const ObserveQuery = { async timeSeriesVersionMarkersAsync( graphqlClient: ExpoGraphqlClient, @@ -79,4 +112,64 @@ export const ObserveQuery = { return data.app.byId.observe.timeSeries.versionMarkers; }, + + async eventsAsync( + graphqlClient: ExpoGraphqlClient, + variables: AppObserveEventsQueryVariables + ): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveEvents( + $appId: String! + $filter: AppObserveEventsFilter + $first: Int + $after: String + $orderBy: AppObserveEventsOrderBy + ) { + app { + byId(appId: $appId) { + id + observe { + events(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { + pageInfo { + hasNextPage + hasPreviousPage + endCursor + } + edges { + cursor + node { + id + metricName + metricValue + timestamp + appVersion + appBuildNumber + deviceModel + deviceOs + deviceOsVersion + countryCode + sessionId + easClientId + } + } + } + } + } + } + } + `, + variables + ) + .toPromise() + ); + + const { edges, pageInfo } = data.app.byId.observe.events; + return { + events: edges.map(edge => edge.node), + pageInfo, + }; + }, }; diff --git a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts new file mode 100644 index 0000000000..f3d6a827a8 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -0,0 +1,272 @@ +import { + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, +} from '../../graphql/generated'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents'; + +jest.mock('../../graphql/queries/ObserveQuery'); + +describe(resolveOrderBy, () => { + it('maps "slowest" to METRIC_VALUE DESC', () => { + expect(resolveOrderBy('slowest')).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('maps "fastest" to METRIC_VALUE ASC', () => { + expect(resolveOrderBy('fastest')).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Asc, + }); + }); + + it('maps "newest" to TIMESTAMP DESC', () => { + expect(resolveOrderBy('newest')).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('maps "oldest" to TIMESTAMP ASC', () => { + expect(resolveOrderBy('oldest')).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Asc, + }); + }); +}); + +describe(fetchObserveEventsAsync, () => { + const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync); + const mockGraphqlClient = {} as any; + + beforeEach(() => { + mockEventsAsync.mockClear(); + }); + + it('calls ObserveQuery.eventsAsync with assembled filter', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(mockEventsAsync).toHaveBeenCalledTimes(1); + expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, { + appId: 'app-123', + filter: { + metricName: 'expo.app_startup.tti', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }, + first: 10, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + }); + }); + + it('includes platform in filter when provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 5, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + platform: AppObservePlatform.Ios, + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + platform: AppObservePlatform.Ios, + }), + }) + ); + }); + + it('includes appVersion in filter when provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + appVersion: '1.2.0', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + appVersion: '1.2.0', + }), + }) + ); + }); + + it('includes appUpdateId in filter when updateId is provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + updateId: 'update-abc-123', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + appUpdateId: 'update-abc-123', + }), + }) + ); + }); + + it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + const calledFilter = mockEventsAsync.mock.calls[0][1].filter; + expect(calledFilter).not.toHaveProperty('platform'); + expect(calledFilter).not.toHaveProperty('appVersion'); + expect(calledFilter).not.toHaveProperty('appUpdateId'); + }); + + it('returns events and pageInfo from the query result', async () => { + const mockEvents = [ + { + __typename: 'AppObserveEvent' as const, + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + }, + ]; + mockEventsAsync.mockResolvedValue({ + events: mockEvents as any, + pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cursor-1' }, + }); + + const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(result.events).toHaveLength(1); + expect(result.events[0].metricValue).toBe(1.23); + expect(result.pageInfo.hasNextPage).toBe(true); + }); + + it('passes after cursor to ObserveQuery.eventsAsync', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + after: 'cursor-abc', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ first: 10, after: 'cursor-abc' }) + ); + }); + + it('omits after when not provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + const calledVars = mockEventsAsync.mock.calls[0][1]; + expect(calledVars).not.toHaveProperty('after'); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts new file mode 100644 index 0000000000..05f33b6483 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts @@ -0,0 +1,189 @@ +import { AppObserveEvent, PageInfo } from '../../graphql/generated'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents'; + +function createMockEvent(overrides: Partial = {}): AppObserveEvent { + return { + __typename: 'AppObserveEvent', + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + appIdentifier: 'com.example.app', + appName: 'ExampleApp', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + eventBatchId: 'batch-1', + tags: {}, + ...overrides, + }; +} + +const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false }; +const withNextPage: PageInfo = { + hasNextPage: true, + hasPreviousPage: false, + endCursor: 'cursor-abc', +}; + +describe(buildObserveEventsTable, () => { + it('formats events into aligned columns', () => { + const events = [ + createMockEvent({ + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.2.0', + appBuildNumber: '42', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + deviceModel: 'iPhone 15', + countryCode: 'US', + timestamp: '2025-01-15T10:30:00.000Z', + }), + createMockEvent({ + id: 'evt-2', + metricName: 'expo.app_startup.tti', + metricValue: 0.85, + appVersion: '1.1.0', + appBuildNumber: '38', + deviceOs: 'Android', + deviceOsVersion: '14', + deviceModel: 'Pixel 8', + countryCode: 'PL', + timestamp: '2025-01-14T08:15:00.000Z', + }), + ]; + + const output = buildObserveEventsTable(events, noNextPage); + + // Escape codes are included, because the header is bolded. + expect(output).toMatchInlineSnapshot(` +"Metric Value App Version Platform Device Country Timestamp  +------ ----- ----------- ---------- --------- ------- ---------------------- +TTI 1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM +TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM" +`); + }); + + it('returns yellow warning for empty array', () => { + const output = buildObserveEventsTable([], noNextPage); + expect(output).toContain('No events found.'); + }); + + it('uses short names for known metrics', () => { + const events = [ + createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }), + createMockEvent({ + id: 'evt-2', + metricName: 'expo.app_startup.warm_launch_time', + }), + createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }), + createMockEvent({ + id: 'evt-4', + metricName: 'expo.app_startup.bundle_load_time', + }), + ]; + + const output = buildObserveEventsTable(events, noNextPage); + + expect(output).toContain('Cold Launch'); + expect(output).toContain('Warm Launch'); + expect(output).toContain('TTR'); + expect(output).toContain('Bundle Load'); + }); + + it('shows - for null countryCode', () => { + const events = [createMockEvent({ countryCode: null })]; + const output = buildObserveEventsTable(events, noNextPage); + + // The country column should contain a dash + const lines = output.split('\n'); + const dataLine = lines[2]; // header, separator, first data row + expect(dataLine).toContain('-'); + }); + + it('appends next page hint when hasNextPage is true', () => { + const events = [createMockEvent()]; + const output = buildObserveEventsTable(events, withNextPage); + + expect(output).toContain('Next page: --after cursor-abc'); + }); + + it('does not append next page hint when hasNextPage is false', () => { + const events = [createMockEvent()]; + const output = buildObserveEventsTable(events, noNextPage); + + expect(output).not.toContain('Next page'); + }); +}); + +describe(buildObserveEventsJson, () => { + it('maps event to JSON shape with all relevant fields and pageInfo', () => { + const events = [ + createMockEvent({ + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + timestamp: '2025-01-15T10:30:00.000Z', + }), + ]; + + const result = buildObserveEventsJson(events, withNextPage); + + expect(result).toEqual({ + events: [ + { + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + timestamp: '2025-01-15T10:30:00.000Z', + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-abc', + }, + }); + }); + + it('handles null optional fields', () => { + const events = [ + createMockEvent({ + countryCode: null, + sessionId: null, + }), + ]; + + const result = buildObserveEventsJson(events, noNextPage); + + expect(result.events[0].countryCode).toBeNull(); + expect(result.events[0].sessionId).toBeNull(); + }); + + it('returns empty events array for empty input', () => { + const result = buildObserveEventsJson([], noNextPage); + expect(result.events).toEqual([]); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchEvents.ts b/packages/eas-cli/src/observe/fetchEvents.ts new file mode 100644 index 0000000000..51dc571c7b --- /dev/null +++ b/packages/eas-cli/src/observe/fetchEvents.ts @@ -0,0 +1,78 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { + AppObserveEvent, + AppObserveEventsFilter, + AppObserveEventsOrderBy, + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, + PageInfo, +} from '../graphql/generated'; +import { ObserveQuery } from '../graphql/queries/ObserveQuery'; + +export type EventsOrderPreset = 'slowest' | 'fastest' | 'newest' | 'oldest'; + +export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy { + switch (preset) { + case 'slowest': + return { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }; + case 'fastest': + return { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Asc, + }; + case 'newest': + return { + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Desc, + }; + case 'oldest': + return { + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Asc, + }; + } +} + +interface FetchObserveEventsOptions { + metricName: string; + orderBy: AppObserveEventsOrderBy; + limit: number; + after?: string; + startTime: string; + endTime: string; + platform?: AppObservePlatform; + appVersion?: string; + updateId?: string; +} + +interface FetchObserveEventsResult { + events: AppObserveEvent[]; + pageInfo: PageInfo; +} + +export async function fetchObserveEventsAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + options: FetchObserveEventsOptions +): Promise { + const filter: AppObserveEventsFilter = { + metricName: options.metricName, + startTime: options.startTime, + endTime: options.endTime, + ...(options.platform && { platform: options.platform }), + ...(options.appVersion && { appVersion: options.appVersion }), + ...(options.updateId && { appUpdateId: options.updateId }), + }; + + return await ObserveQuery.eventsAsync(graphqlClient, { + appId, + filter, + first: options.limit, + ...(options.after && { after: options.after }), + orderBy: options.orderBy, + }); +} diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts new file mode 100644 index 0000000000..4ab5448e3e --- /dev/null +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -0,0 +1,88 @@ +import chalk from 'chalk'; + +import { AppObserveEvent, PageInfo } from '../graphql/generated'; +import { getMetricDisplayName } from './metricNames'; + +function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export interface ObserveEventJson { + id: string; + metricName: string; + metricValue: number; + appVersion: string; + appBuildNumber: string; + deviceModel: string; + deviceOs: string; + deviceOsVersion: string; + countryCode: string | null; + sessionId: string | null; + easClientId: string; + timestamp: string; +} + +export function buildObserveEventsTable(events: AppObserveEvent[], pageInfo: PageInfo): string { + if (events.length === 0) { + return chalk.yellow('No events found.'); + } + + const headers = ['Metric', 'Value', 'App Version', 'Platform', 'Device', 'Country', 'Timestamp']; + + const rows: string[][] = events.map(event => [ + getMetricDisplayName(event.metricName), + `${event.metricValue.toFixed(2)}s`, + `${event.appVersion} (${event.appBuildNumber})`, + `${event.deviceOs} ${event.deviceOsVersion}`, + event.deviceModel, + event.countryCode ?? '-', + formatTimestamp(event.timestamp), + ]); + + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); + + const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); + const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); + const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')); + + const lines = [chalk.bold(headerLine), separatorLine, ...dataLines]; + + if (pageInfo.hasNextPage && pageInfo.endCursor) { + lines.push('', `Next page: --after ${pageInfo.endCursor}`); + } + + return lines.join('\n'); +} + +export function buildObserveEventsJson( + events: AppObserveEvent[], + pageInfo: PageInfo +): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } { + return { + events: events.map(event => ({ + id: event.id, + metricName: event.metricName, + metricValue: event.metricValue, + appVersion: event.appVersion, + appBuildNumber: event.appBuildNumber, + deviceModel: event.deviceModel, + deviceOs: event.deviceOs, + deviceOsVersion: event.deviceOsVersion, + countryCode: event.countryCode ?? null, + sessionId: event.sessionId ?? null, + easClientId: event.easClientId, + timestamp: event.timestamp, + })), + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + endCursor: pageInfo.endCursor ?? null, + }, + }; +}