diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 495cd29815..6c60e4a012 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -202,6 +202,9 @@ "metadata": { "description": "manage store configuration" }, + "observe": { + "description": "monitor app performance metrics" + }, "project": { "description": "manage project" }, 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..c5d3b07199 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -0,0 +1,124 @@ +import { Config } from '@oclif/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { fetchObserveEventsAsync } from '../../../observe/fetchEvents'; +import ObserveEvents from '../events'; + +jest.mock('../../../observe/fetchEvents'); +jest.mock('../../../observe/formatEvents', () => ({ + buildObserveEventsTable: jest.fn().mockReturnValue('table'), + buildObserveEventsJson: jest.fn().mockReturnValue({}), +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); + +describe(ObserveEvents, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = {} as unknown as Config; + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + }); + + function createCommand(argv: string[]): ObserveEvents { + const command = new ObserveEvents(argv, mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + it('uses --days-from-now to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--days-from-now', '7']); + await command.runAsync(); + + expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(options.startTime).toBe('2025-06-08T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-04-16T12:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('defaults endTime to now when only --start is provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('rejects --days-from-now combined with --start', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--start', + '2025-01-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('rejects --days-from-now combined with --end', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); +}); 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..5b26d98ed1 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -0,0 +1,130 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { AppObservePlatform } from '../../graphql/generated'; +import Log from '../../log'; +import { + DEFAULT_EVENTS_LIMIT, + type EventsOrderPreset, + fetchObserveEventsAsync, + resolveOrderBy, +} from '../../observe/fetchEvents'; +import { resolveMetricName } from '../../observe/metricNames'; +import { DEFAULT_DAYS_BACK, validateDateFlag } from '../../observe/fetchMetrics'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class ObserveEvents extends EasCommand { + static override description = 'display individual app performance events ordered by metric value'; + + static override flags = { + metric: Flags.string({ + description: + 'Metric to query (full name or alias: tti, ttr, cold_launch, warm_launch, bundle_load)', + required: true, + }), + sort: Flags.enum({ + description: 'Sort order for events', + options: ['slowest', 'fastest', 'newest', 'oldest'], + default: 'slowest', + }), + platform: Flags.enum<'android' | 'ios'>({ + description: 'Filter by platform', + options: ['android', 'ios'], + }), + limit: Flags.integer({ + description: 'Number of events to show', + default: DEFAULT_EVENTS_LIMIT, + min: 1, + max: 100, + }), + start: Flags.string({ + description: 'Start of time range (ISO date)', + exclusive: ['days-from-now'], + }), + end: Flags.string({ + description: 'End of time range (ISO date)', + exclusive: ['days-from-now'], + }), + 'days-from-now': Flags.integer({ + description: 'Show events from the last N days (mutually exclusive with --start/--end)', + min: 1, + exclusive: ['start', 'end'], + }), + 'app-version': Flags.string({ + description: 'Filter by app version', + }), + 'update-id': Flags.string({ + description: 'Filter by EAS update ID', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveEvents); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveEvents, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricName = resolveMetricName(flags.metric); + const orderBy = resolveOrderBy(flags.sort); + + let startTime: string; + let endTime: string; + + if (flags['days-from-now']) { + endTime = new Date().toISOString(); + startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString(); + } else { + endTime = flags.end ?? new Date().toISOString(); + startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + } + + const platform = flags.platform + ? flags.platform === 'android' + ? AppObservePlatform.Android + : AppObservePlatform.Ios + : undefined; + + const { events } = await fetchObserveEventsAsync(graphqlClient, projectId, { + metricName, + orderBy, + limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, + startTime, + endTime, + platform, + appVersion: flags['app-version'], + updateId: flags['update-id'], + }); + + if (flags.json) { + printJsonOnlyOutput(buildObserveEventsJson(events)); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveEventsTable(events)); + } + } +} diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts new file mode 100644 index 0000000000..907c49893a --- /dev/null +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -0,0 +1,142 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { AppPlatform, BuildStatus } from '../../graphql/generated'; +import { BuildQuery } from '../../graphql/queries/BuildQuery'; +import Log from '../../log'; +import { + DEFAULT_DAYS_BACK, + DEFAULT_LIMIT, + DEFAULT_METRICS, + fetchObserveMetricsAsync, + validateDateFlag, +} from '../../observe/fetchMetrics'; +import { + DEFAULT_STATS_JSON, + DEFAULT_STATS_TABLE, + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + resolveStatKey, +} from '../../observe/formatMetrics'; +import { resolveMetricName } from '../../observe/metricNames'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class ObserveMetrics extends EasCommand { + static override description = 'display app performance metrics grouped by recent builds'; + + static override flags = { + platform: Flags.enum<'android' | 'ios'>({ + description: 'Filter by platform', + options: ['android', 'ios'], + }), + metric: Flags.string({ + description: + 'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load', + multiple: true, + }), + stat: Flags.string({ + description: + 'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count', + multiple: true, + }), + start: Flags.string({ + description: 'Start of time range (ISO date)', + }), + end: Flags.string({ + description: 'End of time range (ISO date)', + }), + limit: Flags.integer({ + description: 'Number of builds to show', + default: DEFAULT_LIMIT, + min: 1, + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveMetrics); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveMetrics, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricNames = flags.metric?.length + ? flags.metric.map(resolveMetricName) + : DEFAULT_METRICS; + + const endTime = flags.end ?? new Date().toISOString(); + const startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + + const platformFilter = flags.platform + ? flags.platform === 'android' + ? AppPlatform.Android + : AppPlatform.Ios + : undefined; + + const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, { + appId: projectId, + limit: flags.limit ?? DEFAULT_LIMIT, + offset: 0, + filter: { + status: BuildStatus.Finished, + ...(platformFilter ? { platform: platformFilter } : {}), + }, + }); + + if (builds.length === 0) { + if (flags.json) { + printJsonOnlyOutput([]); + } else { + Log.warn('No finished builds found.'); + } + return; + } + + const platformsInBuilds = new Set(builds.map(b => b.platform)); + + const metricsMap = await fetchObserveMetricsAsync( + graphqlClient, + projectId, + metricNames, + platformsInBuilds, + 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(builds, metricsMap, metricNames, stats)); + } else { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; + Log.addNewLineIfNone(); + Log.log(buildObserveMetricsTable(builds, metricsMap, metricNames, stats)); + } + } +} diff --git a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts new file mode 100644 index 0000000000..aeb247944d --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -0,0 +1,175 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + AppObserveEvent, + AppObserveEventsFilter, + AppObserveEventsOrderBy, + AppObservePlatform, + AppObserveTimeSeriesInput, + AppObserveVersionMarker, + PageInfo, +} from '../generated'; + +type AppObserveTimeSeriesQuery = { + app: { + byId: { + id: string; + observe: { + timeSeries: { + versionMarkers: AppObserveVersionMarker[]; + }; + }; + }; + }; +}; + +type AppObserveTimeSeriesQueryVariables = { + appId: string; + input: Pick; +}; + +type AppObserveEventsQuery = { + app: { + byId: { + id: string; + observe: { + events: { + pageInfo: PageInfo; + edges: Array<{ + cursor: string; + node: AppObserveEvent; + }>; + }; + }; + }; + }; +}; + +type AppObserveEventsQueryVariables = { + appId: string; + filter?: AppObserveEventsFilter; + first?: number; + after?: string; + orderBy?: AppObserveEventsOrderBy; +}; + +export const ObserveQuery = { + async timeSeriesVersionMarkersAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + metricName, + platform, + startTime, + endTime, + }: { + appId: string; + metricName: string; + platform: AppObservePlatform; + startTime: string; + endTime: string; + } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveTimeSeries($appId: String!, $input: AppObserveTimeSeriesInput!) { + app { + byId(appId: $appId) { + id + observe { + timeSeries(input: $input) { + versionMarkers { + appVersion + eventCount + firstSeenAt + statistics { + min + max + median + average + p80 + p90 + p99 + } + } + } + } + } + } + } + `, + { + appId, + input: { metricName, platform, startTime, endTime }, + } + ) + .toPromise() + ); + + return data.app.byId.observe.timeSeries.versionMarkers; + }, + + async eventsAsync( + graphqlClient: ExpoGraphqlClient, + variables: AppObserveEventsQueryVariables + ): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + 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..4ca17c7a29 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -0,0 +1,227 @@ +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); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts new file mode 100644 index 0000000000..d2ed0758c6 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -0,0 +1,222 @@ +import { AppObservePlatform, AppPlatform } from '../../graphql/generated'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { makeMetricsKey } from '../formatMetrics'; +import { fetchObserveMetricsAsync } from '../fetchMetrics'; + +jest.mock('../../graphql/queries/ObserveQuery'); + +describe('fetchObserveMetricsAsync', () => { + const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync); + const mockGraphqlClient = {} as any; + + beforeEach(() => { + mockTimeSeriesMarkers.mockClear(); + }); + + it('fans out queries for each metric+platform combo and assembles metricsMap', async () => { + mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName, platform }) => { + if (metricName === 'expo.app_startup.tti' && platform === AppObservePlatform.Ios) { + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '1.0.0', + eventCount: 100, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.01, + max: 0.5, + median: 0.1, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.48, + }, + }, + ]; + } + if ( + metricName === 'expo.app_startup.cold_launch_time' && + platform === AppObservePlatform.Ios + ) { + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '1.0.0', + eventCount: 80, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.05, + max: 1.2, + median: 0.3, + average: 0.4, + p80: 0.8, + p90: 1.0, + p99: 1.15, + }, + }, + ]; + } + return []; + }); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'], + new Set([AppPlatform.Ios]), + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should have called the query twice (2 metrics x 1 platform) + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + // Verify metricsMap was assembled correctly + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + expect(metricsMap.has(key)).toBe(true); + + const metricsForVersion = metricsMap.get(key)!; + expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({ + min: 0.01, + max: 0.5, + median: 0.1, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.48, + eventCount: 100, + }); + expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({ + min: 0.05, + max: 1.2, + median: 0.3, + average: 0.4, + p80: 0.8, + p90: 1.0, + p99: 1.15, + eventCount: 80, + }); + }); + + it('fans out across multiple platforms', async () => { + mockTimeSeriesMarkers.mockResolvedValue([]); + + await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + new Set([AppPlatform.Ios, AppPlatform.Android]), + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // 1 metric x 2 platforms = 2 calls + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform); + expect(platforms).toContain(AppObservePlatform.Ios); + expect(platforms).toContain(AppObservePlatform.Android); + }); + + it('handles partial failures gracefully — successful queries still populate metricsMap', async () => { + mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => { + if (metricName === 'bad.metric') { + throw new Error('Unknown metric'); + } + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '2.0.0', + eventCount: 50, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + }, + }, + ]; + }); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti', 'bad.metric'], + new Set([AppPlatform.Android]), + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should not throw; the good metric should still be in the map + const key = makeMetricsKey('2.0.0', AppPlatform.Android); + expect(metricsMap.has(key)).toBe(true); + expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({ + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + eventCount: 50, + }); + // The bad metric should not be present + expect(metricsMap.get(key)!.has('bad.metric')).toBe(false); + }); + + it('returns empty map when all queries fail', async () => { + mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error')); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + new Set([AppPlatform.Ios]), + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + expect(metricsMap.size).toBe(0); + }); + + it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => { + mockTimeSeriesMarkers.mockResolvedValue([ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '3.0.0', + eventCount: 10, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.2, + median: 0.15, + average: 0.15, + p80: 0.18, + p90: 0.19, + p99: 0.2, + }, + }, + ]); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + new Set([AppPlatform.Android]), + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // The key should use AppPlatform (ANDROID), not AppObservePlatform + expect(metricsMap.has('3.0.0:ANDROID')).toBe(true); + expect(metricsMap.has('3.0.0:Android' as any)).toBe(false); + }); +}); 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..2c9877bc3b --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts @@ -0,0 +1,160 @@ +import { AppObserveEvent } 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, + }; +} + +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); + + // 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([]); + 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); + + 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); + + // The country column should contain a dash + const lines = output.split('\n'); + const dataLine = lines[2]; // header, separator, first data row + expect(dataLine).toContain('-'); + }); +}); + +describe(buildObserveEventsJson, () => { + it('maps event to JSON shape with all relevant fields', () => { + 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); + + expect(result).toEqual([ + { + 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', + }, + ]); + }); + + it('handles null optional fields', () => { + const events = [ + createMockEvent({ + countryCode: null, + sessionId: null, + }), + ]; + + const result = buildObserveEventsJson(events); + + expect(result[0].countryCode).toBeNull(); + expect(result[0].sessionId).toBeNull(); + }); + + it('returns empty array for empty input', () => { + expect(buildObserveEventsJson([])).toEqual([]); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts new file mode 100644 index 0000000000..0cde36ae9f --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -0,0 +1,589 @@ +import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated'; +import { + DEFAULT_STATS_JSON, + DEFAULT_STATS_TABLE, + ObserveMetricsMap, + buildObserveMetricsJson, + buildObserveMetricsTable, + makeMetricsKey, + resolveStatKey, + type MetricValues, +} from '../formatMetrics'; + +function createMockBuild(overrides: { + id: string; + platform: AppPlatform; + appVersion: string | null; + buildProfile?: string | null; + completedAt?: string | null; + gitCommitHash?: string | null; +}): ReturnType { + return createBuildFragment(overrides); +} + +function createBuildFragment(overrides: { + id: string; + platform: AppPlatform; + appVersion: string | null; + buildProfile?: string | null; + completedAt?: string | null; + gitCommitHash?: string | null; +}) { + return { + __typename: 'Build' as const, + id: overrides.id, + status: BuildStatus.Finished, + platform: overrides.platform, + appVersion: overrides.appVersion, + appBuildVersion: '1', + buildProfile: overrides.buildProfile ?? 'production', + completedAt: overrides.completedAt ?? '2025-01-15T10:00:00.000Z', + createdAt: '2025-01-15T09:00:00.000Z', + updatedAt: '2025-01-15T10:00:00.000Z', + channel: 'production', + distribution: null, + iosEnterpriseProvisioning: null, + sdkVersion: '52.0.0', + runtimeVersion: '1.0.0', + gitCommitHash: overrides.gitCommitHash ?? 'abc1234567890', + gitCommitMessage: 'test commit', + initialQueuePosition: null, + queuePosition: null, + estimatedWaitTimeLeftSeconds: null, + priority: BuildPriority.Normal, + message: null, + expirationDate: null, + isForIosSimulator: false, + error: null, + artifacts: null, + fingerprint: null, + initiatingActor: null, + logFiles: [], + project: { + __typename: 'App' as const, + id: 'project-id', + name: 'test-app', + slug: 'test-app', + ownerAccount: { id: 'account-id', name: 'test-owner' }, + }, + metrics: null, + }; +} + +const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti']; + +function makeMetricValueWithDefaults(overrides: Partial): MetricValues { + return { + min: 0.1, + median: 0.3, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 100, + ...overrides, + }; +} + +describe(buildObserveMetricsTable, () => { + it('formats builds grouped by version with min, median, max columns', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '1.2.0', + gitCommitHash: 'aaa1111222233334444', + }), + createMockBuild({ + id: 'build-2', + platform: AppPlatform.Ios, + appVersion: '1.2.0', + gitCommitHash: 'bbb2222333344445555', + }), + createMockBuild({ + id: 'build-3', + platform: AppPlatform.Android, + appVersion: '1.1.0', + gitCommitHash: 'ccc3333444455556666', + }), + ]; + + const metricsMap: ObserveMetricsMap = new Map(); + const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios); + metricsMap.set( + iosKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })], + ]) + ); + + const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android); + metricsMap.set( + androidKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })], + ]) + ); + + const output = buildObserveMetricsTable( + builds, + metricsMap, + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); + + // The header is bolded, thus the escape characters in the snapshot + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- ------------ ---------------- --------------- ----------------- ------- --------- +1.2.0 iOS Jan 15, 2025 aaa1111, bbb2222 0.35s 110 1.32s 90 +1.1.0 Android Jan 15, 2025 ccc3333 0.25s 120 1.12s 100 " +`); + }); + + it('shows - for builds with no matching observe data', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '2.0.0', + }), + ]; + + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- ------------ ------- --------------- ----------------- ------- --------- +2.0.0 iOS Jan 15, 2025 abc1234 - - - - " +`); + }); + + it('shows - for builds with null appVersion', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: null, + }), + ]; + + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- ------------ ------- --------------- ----------------- ------- --------- +- iOS Jan 15, 2025 abc1234 - - - - " +`); + }); + + it('returns message when no builds found', () => { + const output = buildObserveMetricsTable([], new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE); + expect(output).toMatchInlineSnapshot(`"No finished builds found."`); + }); + + it('shows the latest build date when multiple builds share a version', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + completedAt: '2025-01-10T10:00:00.000Z', + gitCommitHash: 'aaa1111222233334444', + }), + createMockBuild({ + id: 'build-2', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + completedAt: '2025-01-20T10:00:00.000Z', + gitCommitHash: 'bbb2222333344445555', + }), + ]; + + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); + + expect(output).toContain('1.0.0'); + expect(output).toContain('iOS'); + expect(output).toContain('Jan 20, 2025'); + expect(output).not.toContain('Jan 10, 2025'); + }); + + it('deduplicates commit hashes for same version+platform', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + gitCommitHash: 'same123456789', + }), + createMockBuild({ + id: 'build-2', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + gitCommitHash: 'same123456789', + }), + ]; + + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- ------------ ------- --------------- ----------------- ------- --------- +1.0.0 iOS Jan 15, 2025 same123 - - - - " +`); + }); +}); + +describe(buildObserveMetricsJson, () => { + it('produces grouped JSON with min, median, max per metric', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + gitCommitHash: 'aaa1111222233334444', + }), + createMockBuild({ + id: 'build-2', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + gitCommitHash: 'bbb2222333344445555', + }), + ]; + + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })], + ]) + ); + + const result = buildObserveMetricsJson( + builds, + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + appVersion: '1.0.0', + platform: AppPlatform.Ios, + lastBuildDate: '2025-01-15T10:00:00.000Z', + commits: ['aaa1111', 'bbb2222'], + metrics: { + 'expo.app_startup.tti': { + min: 0.1, + median: 0.12, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 90, + }, + }, + }); + }); + + it('produces null min/median/max when no observe data matches', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Android, + appVersion: '3.0.0', + }), + ]; + + const result = buildObserveMetricsJson( + builds, + new Map(), + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics).toEqual({ + 'expo.app_startup.tti': { + min: null, + median: null, + max: null, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + }); + }); + + it('produces null appVersion when build has no appVersion', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: null, + }), + ]; + + const result = buildObserveMetricsJson( + builds, + new Map(), + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].appVersion).toBeNull(); + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + min: null, + median: null, + max: null, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }); + }); +}); + +describe(makeMetricsKey, () => { + it('creates a key from version and platform', () => { + expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS'); + expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID'); + }); +}); + +describe(resolveStatKey, () => { + it('resolves canonical stat names', () => { + expect(resolveStatKey('min')).toBe('min'); + expect(resolveStatKey('max')).toBe('max'); + expect(resolveStatKey('median')).toBe('median'); + expect(resolveStatKey('average')).toBe('average'); + expect(resolveStatKey('p80')).toBe('p80'); + expect(resolveStatKey('p90')).toBe('p90'); + expect(resolveStatKey('p99')).toBe('p99'); + expect(resolveStatKey('eventCount')).toBe('eventCount'); + }); + + it('resolves short aliases', () => { + expect(resolveStatKey('med')).toBe('median'); + expect(resolveStatKey('avg')).toBe('average'); + expect(resolveStatKey('count')).toBe('eventCount'); + expect(resolveStatKey('event_count')).toBe('eventCount'); + }); + + it('throws on unknown stat', () => { + expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"'); + }); +}); + +describe('DEFAULT_STATS_TABLE', () => { + it('defaults to median, eventCount', () => { + expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']); + }); +}); + +describe('DEFAULT_STATS_JSON', () => { + it('includes all stats', () => { + expect(DEFAULT_STATS_JSON).toEqual([ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', + ]); + }); +}); + +describe('custom stats parameter', () => { + const builds = [ + createMockBuild({ + id: 'build-1', + platform: AppPlatform.Ios, + appVersion: '1.0.0', + gitCommitHash: 'aaa1111222233334444', + }), + ]; + + it('table renders only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable( + builds, + metricsMap, + ['expo.app_startup.tti'], + ['p99', 'eventCount'] + ); + + expect(output).toContain('TTI P99'); + expect(output).toContain('TTI Count'); + expect(output).toContain('0.90s'); + expect(output).toContain('42'); + expect(output).not.toContain('TTI Min'); + expect(output).not.toContain('TTI Med'); + expect(output).not.toContain('TTI Max'); + }); + + it("table formats eventCount as integer without 's' suffix", () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: 100, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable( + builds, + metricsMap, + ['expo.app_startup.tti'], + ['eventCount'] + ); + + expect(output).toContain('100'); + expect(output).not.toContain('100s'); + expect(output).not.toContain('100.00s'); + }); + + it('JSON includes only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson( + builds, + metricsMap, + ['expo.app_startup.tti'], + ['p90', 'eventCount'] + ); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + p90: 0.4, + eventCount: 42, + }); + }); + + it('JSON uses default stats when not specified', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson( + builds, + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts new file mode 100644 index 0000000000..f989d22d70 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts @@ -0,0 +1,52 @@ +import { getMetricDisplayName, resolveMetricName } from '../metricNames'; + +describe(resolveMetricName, () => { + it('resolves short alias "tti" to full metric name', () => { + expect(resolveMetricName('tti')).toBe('expo.app_startup.tti'); + }); + + it('resolves short alias "ttr" to full metric name', () => { + expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr'); + }); + + it('resolves short alias "cold_launch" to full metric name', () => { + expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time'); + }); + + it('resolves short alias "warm_launch" to full metric name', () => { + expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time'); + }); + + it('resolves short alias "bundle_load" to full metric name', () => { + expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time'); + }); + + it('passes through full metric names unchanged', () => { + expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti'); + expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe( + 'expo.app_startup.cold_launch_time' + ); + }); + + it('throws on unknown alias', () => { + expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"'); + }); + + it('passes through dot-containing custom metric names', () => { + expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name'); + }); +}); + +describe(getMetricDisplayName, () => { + it('returns short display name for known metrics', () => { + expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch'); + expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch'); + expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI'); + expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR'); + expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load'); + }); + + it('returns the full metric name for unknown metrics', () => { + expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name'); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchEvents.ts b/packages/eas-cli/src/observe/fetchEvents.ts new file mode 100644 index 0000000000..5fc8a5ea12 --- /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 const DEFAULT_EVENTS_LIMIT = 10; + +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; + 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 ObserveQuery.eventsAsync(graphqlClient, { + appId, + filter, + first: options.limit, + orderBy: options.orderBy, + }); +} diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts new file mode 100644 index 0000000000..443ec87c09 --- /dev/null +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -0,0 +1,110 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasCommandError } from '../commandUtils/errors'; +import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated'; +import { ObserveQuery } from '../graphql/queries/ObserveQuery'; +import Log from '../log'; +import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics'; + +export 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', +]; + +export const DEFAULT_LIMIT = 10; +export const DEFAULT_DAYS_BACK = 60; + +const appPlatformToObservePlatform: Record = { + [AppPlatform.Android]: AppObservePlatform.Android, + [AppPlatform.Ios]: AppObservePlatform.Ios, +}; + +const observePlatformToAppPlatform: Record = { + [AppObservePlatform.Android]: AppPlatform.Android, + [AppObservePlatform.Ios]: AppPlatform.Ios, +}; + +interface ObserveQueryResult { + metricName: string; + platform: AppObservePlatform; + markers: AppObserveVersionMarker[]; +} + +export function validateDateFlag(value: string, flagName: string): void { + const parsed = new Date(value); + if (isNaN(parsed.getTime())) { + throw new EasCommandError( + `Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).` + ); + } +} + +export async function fetchObserveMetricsAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + metricNames: string[], + platforms: Set, + startTime: string, + endTime: string +): Promise { + const observeQueries: Promise[] = []; + + for (const metricName of metricNames) { + for (const appPlatform of platforms) { + const observePlatform = appPlatformToObservePlatform[appPlatform]; + observeQueries.push( + ObserveQuery.timeSeriesVersionMarkersAsync(graphqlClient, { + appId, + metricName, + platform: observePlatform, + startTime, + endTime, + }) + .then(markers => ({ + metricName, + platform: observePlatform, + markers, + })) + .catch(error => { + Log.warn( + `Failed to fetch observe data for metric "${metricName}" on ${observePlatform}: ${error.message}` + ); + return null; + }) + ); + } + } + + const observeResults = await Promise.all(observeQueries); + + const metricsMap: ObserveMetricsMap = new Map(); + + for (const result of observeResults) { + if (!result) { + continue; + } + const { metricName, platform, markers } = result; + const appPlatform = observePlatformToAppPlatform[platform]; + for (const marker of markers) { + const key = makeMetricsKey(marker.appVersion, appPlatform); + if (!metricsMap.has(key)) { + metricsMap.set(key, new Map()); + } + const values: MetricValues = { + min: marker.statistics.min, + max: marker.statistics.max, + median: marker.statistics.median, + average: marker.statistics.average, + p80: marker.statistics.p80, + p90: marker.statistics.p90, + p99: marker.statistics.p99, + eventCount: marker.eventCount, + }; + metricsMap.get(key)!.set(metricName, values); + } + } + + return metricsMap; +} diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts new file mode 100644 index 0000000000..e9370b54ba --- /dev/null +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -0,0 +1,73 @@ +import chalk from 'chalk'; + +import { AppObserveEvent } 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[]): 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(' ')); + + return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n'); +} + +export function buildObserveEventsJson(events: AppObserveEvent[]): ObserveEventJson[] { + return 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, + })); +} diff --git a/packages/eas-cli/src/observe/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts new file mode 100644 index 0000000000..6bafaf9265 --- /dev/null +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -0,0 +1,232 @@ +import chalk from 'chalk'; + +import { EasCommandError } from '../commandUtils/errors'; +import { AppPlatform, BuildFragment } from '../graphql/generated'; +import { appPlatformDisplayNames } from '../platform'; +import { getMetricDisplayName } from './metricNames'; + +export type StatisticKey = + | 'min' + | 'max' + | 'median' + | 'average' + | 'p80' + | 'p90' + | 'p99' + | 'eventCount'; + +export const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +export const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +export const STAT_ALIASES: Record = { + min: 'min', + max: 'max', + med: 'median', + median: 'median', + avg: 'average', + average: 'average', + p80: 'p80', + p90: 'p90', + p99: 'p99', + count: 'eventCount', + event_count: 'eventCount', + eventCount: 'eventCount', +}; + +export const STAT_DISPLAY_NAMES: Record = { + min: 'Min', + max: 'Max', + median: 'Med', + average: 'Avg', + p80: 'P80', + p90: 'P90', + p99: 'P99', + eventCount: 'Count', +}; + +export function resolveStatKey(input: string): StatisticKey { + const resolved = STAT_ALIASES[input]; + if (resolved) { + return resolved; + } + throw new EasCommandError( + `Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}` + ); +} + +function formatStatValue(stat: StatisticKey, value: number | null | undefined): string { + if (value == null) { + return '-'; + } + if (stat === 'eventCount') { + return String(value); + } + return `${value.toFixed(2)}s`; +} + +export interface MetricValues { + min: number | null | undefined; + max: number | null | undefined; + median: number | null | undefined; + average: number | null | undefined; + p80: number | null | undefined; + p90: number | null | undefined; + p99: number | null | undefined; + eventCount: number | null | undefined; +} + +type ObserveMetricsKey = `${string}:${AppPlatform}`; + +export type ObserveMetricsMap = Map>; + +export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey { + return `${appVersion}:${platform}`; +} + +interface VersionGroup { + appVersion: string; + platform: AppPlatform; + commits: Set; + lastBuildDate: string | null; +} + +function groupBuildsByVersion(builds: BuildFragment[]): VersionGroup[] { + const grouped = new Map(); + + for (const build of builds) { + const version = build.appVersion ?? '-'; + const key = makeMetricsKey(version, build.platform); + + if (!grouped.has(key)) { + grouped.set(key, { + appVersion: version, + platform: build.platform, + commits: new Set(), + lastBuildDate: build.completedAt ?? null, + }); + } else { + const group = grouped.get(key)!; + if (build.completedAt && (!group.lastBuildDate || build.completedAt > group.lastBuildDate)) { + group.lastBuildDate = build.completedAt; + } + } + + if (build.gitCommitHash) { + grouped.get(key)!.commits.add(build.gitCommitHash.slice(0, 7)); + } + } + + return [...grouped.values()]; +} + +function formatDate(dateString: string | null): string { + if (!dateString) { + return '-'; + } + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export type MetricValuesJson = Partial>; + +export interface ObserveMetricsVersionResult { + appVersion: string | null; + platform: AppPlatform; + lastBuildDate: string | null; + commits: string[]; + metrics: Record; +} + +export function buildObserveMetricsJson( + builds: BuildFragment[], + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): ObserveMetricsVersionResult[] { + const groups = groupBuildsByVersion(builds); + + return groups.map(group => { + const key = group.appVersion !== '-' ? makeMetricsKey(group.appVersion, group.platform) : null; + const versionMetrics = key ? metricsMap.get(key) : undefined; + + const metrics: Record = {}; + for (const metricName of metricNames) { + const values = versionMetrics?.get(metricName); + const statValues: MetricValuesJson = {}; + for (const stat of stats) { + statValues[stat] = values?.[stat] ?? null; + } + metrics[metricName] = statValues; + } + + return { + appVersion: group.appVersion !== '-' ? group.appVersion : null, + platform: group.platform, + lastBuildDate: group.lastBuildDate, + commits: [...group.commits], + metrics, + }; + }); +} + +export function buildObserveMetricsTable( + builds: BuildFragment[], + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): string { + const results = buildObserveMetricsJson(builds, metricsMap, metricNames, stats); + + if (results.length === 0) { + return chalk.yellow('No finished builds found.'); + } + + const fixedHeaders = ['App Version', 'Platform', 'Last Build', 'Commits']; + const metricHeaders: string[] = []; + for (const m of metricNames) { + const name = getMetricDisplayName(m); + for (const stat of stats) { + metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`); + } + } + const headers = [...fixedHeaders, ...metricHeaders]; + + const rows: string[][] = results.map(result => { + const metricCells: string[] = []; + for (const m of metricNames) { + const values = result.metrics[m]; + for (const stat of stats) { + metricCells.push(formatStatValue(stat, values?.[stat] ?? null)); + } + } + + return [ + result.appVersion ?? '-', + appPlatformDisplayNames[result.platform], + formatDate(result.lastBuildDate), + result.commits.length > 0 ? result.commits.join(', ') : '-', + ...metricCells, + ]; + }); + + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); + + const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); + const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); + const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')); + + return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n'); +} diff --git a/packages/eas-cli/src/observe/metricNames.ts b/packages/eas-cli/src/observe/metricNames.ts new file mode 100644 index 0000000000..8d591a3cd2 --- /dev/null +++ b/packages/eas-cli/src/observe/metricNames.ts @@ -0,0 +1,35 @@ +import { EasCommandError } from '../commandUtils/errors'; + +export const METRIC_ALIASES: Record = { + tti: 'expo.app_startup.tti', + ttr: 'expo.app_startup.ttr', + cold_launch: 'expo.app_startup.cold_launch_time', + warm_launch: 'expo.app_startup.warm_launch_time', + bundle_load: 'expo.app_startup.bundle_load_time', +}; + +const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES)); + +export const METRIC_SHORT_NAMES: Record = { + 'expo.app_startup.cold_launch_time': 'Cold Launch', + 'expo.app_startup.warm_launch_time': 'Warm Launch', + 'expo.app_startup.tti': 'TTI', + 'expo.app_startup.ttr': 'TTR', + 'expo.app_startup.bundle_load_time': 'Bundle Load', +}; + +export function resolveMetricName(input: string): string { + if (METRIC_ALIASES[input]) { + return METRIC_ALIASES[input]; + } + if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) { + return input; + } + throw new EasCommandError( + `Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}` + ); +} + +export function getMetricDisplayName(metricName: string): string { + return METRIC_SHORT_NAMES[metricName] ?? metricName; +}