From e361856877cfa8bea29d7315344c8e70c284be29 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 10:34:47 +0100 Subject: [PATCH 01/11] [eas-cli] Add observe:metrics command --- CHANGELOG.md | 2 + packages/eas-cli/package.json | 3 + .../eas-cli/src/commandUtils/pagination.ts | 6 +- .../observe/__tests__/metrics.test.ts | 214 +++++++++++ .../eas-cli/src/commands/observe/metrics.ts | 142 +++++++ .../src/graphql/queries/ObserveQuery.ts | 82 +++++ .../observe/__tests__/fetchMetrics.test.ts | 222 +++++++++++ .../observe/__tests__/formatMetrics.test.ts | 346 ++++++++++++++++++ .../src/observe/__tests__/metricNames.test.ts | 52 +++ packages/eas-cli/src/observe/fetchMetrics.ts | 99 +++++ packages/eas-cli/src/observe/formatMetrics.ts | 165 +++++++++ packages/eas-cli/src/observe/metricNames.ts | 35 ++ 12 files changed, 1367 insertions(+), 1 deletion(-) create mode 100644 packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts create mode 100644 packages/eas-cli/src/commands/observe/metrics.ts create mode 100644 packages/eas-cli/src/graphql/queries/ObserveQuery.ts create mode 100644 packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts create mode 100644 packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts create mode 100644 packages/eas-cli/src/observe/__tests__/metricNames.test.ts create mode 100644 packages/eas-cli/src/observe/fetchMetrics.ts create mode 100644 packages/eas-cli/src/observe/formatMetrics.ts create mode 100644 packages/eas-cli/src/observe/metricNames.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a8728fc9..3f0f4e4211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ 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)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index d262a24fc5..b9749de743 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -203,6 +203,9 @@ "metadata": { "description": "manage store configuration" }, + "observe": { + "description": "monitor app performance metrics" + }, "project": { "description": "manage project" }, diff --git a/packages/eas-cli/src/commandUtils/pagination.ts b/packages/eas-cli/src/commandUtils/pagination.ts index 9566bf9998..142d42d41b 100644 --- a/packages/eas-cli/src/commandUtils/pagination.ts +++ b/packages/eas-cli/src/commandUtils/pagination.ts @@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = ( export const getLimitFlagWithCustomValues = ({ defaultTo, limit, + description, }: { defaultTo: number; limit: number; + description?: string; }): OptionFlag => Flags.integer({ - description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, + description: + description ?? + `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, // eslint-disable-next-line async-protect/async-suffix parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit), }); diff --git a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts new file mode 100644 index 0000000000..52926a8b2a --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts @@ -0,0 +1,214 @@ +import { Config } from '@oclif/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform } from '../../../graphql/generated'; +import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics'; +import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveMetrics from '../metrics'; + +jest.mock('../../../observe/fetchMetrics', () => { + const actual = jest.requireActual('../../../observe/fetchMetrics'); + return { + ...actual, + fetchObserveMetricsAsync: jest.fn(), + }; +}); +jest.mock('../../../observe/formatMetrics', () => ({ + ...jest.requireActual('../../../observe/formatMetrics'), + buildObserveMetricsTable: jest.fn().mockReturnValue('table'), + buildObserveMetricsJson: jest.fn().mockReturnValue([]), +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync); +const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable); +const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +describe(ObserveMetrics, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = {} as unknown as Config; + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveMetricsAsync.mockResolvedValue(new Map()); + }); + + function createCommand(argv: string[]): ObserveMetrics { + const command = new ObserveMetrics(argv, mockConfig); + // @ts-expect-error getContextAsync is a protected method + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + it('fetches metrics with default parameters (both platforms)', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand([]); + await command.runAsync(); + + expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1); + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]); + + jest.useRealTimers(); + }); + + it('queries only Android when --platform android is passed', async () => { + const command = createCommand(['--platform', 'android']); + await command.runAsync(); + + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Android]); + }); + + it('queries only iOS when --platform ios is passed', async () => { + const command = createCommand(['--platform', 'ios']); + await command.runAsync(); + + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Ios]); + }); + + it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']); + await command.runAsync(); + + const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2]; + expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']); + }); + + it('uses default time range (60 days back) when no --start/--end flags', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand([]); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(startTime).toBe('2025-04-16T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('passes resolved --stat flags to buildObserveMetricsTable', async () => { + const command = createCommand(['--stat', 'p90', '--stat', 'count']); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['p90', 'eventCount'] + ); + }); + + it('deduplicates --stat flags that resolve to the same key', async () => { + const command = createCommand(['--stat', 'med', '--stat', 'median']); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['median'] + ); + }); + + it('uses --days-from-now to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--days-from-now', '7']); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(startTime).toBe('2025-06-08T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('rejects --days-from-now combined with --start', async () => { + const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('rejects --days-from-now combined with --end', async () => { + const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('uses default stats when --stat is not provided', async () => { + const command = createCommand([]); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['median', 'eventCount'] + ); + }); + + it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => { + const command = createCommand([ + '--json', + '--non-interactive', + '--stat', + 'min', + '--stat', + 'avg', + ]); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith( + expect.any(Map), + expect.any(Array), + ['min', 'average'] + ); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); + }); +}); + +describe(validateDateFlag, () => { + it('throws on invalid --start date', () => { + expect(() => validateDateFlag('not-a-date', '--start')).toThrow( + 'Invalid --start date: "not-a-date"' + ); + }); + + it('throws on invalid --end date', () => { + expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"'); + }); + + it('accepts valid ISO date in --start', () => { + expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow(); + }); +}); 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..434c4d98e0 --- /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 } from '../../graphql/generated'; +import Log from '../../log'; +import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics'; +import { + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + resolveStatKey, +} from '../../observe/formatMetrics'; +import { resolveMetricName } from '../../observe/metricNames'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +const DEFAULT_METRICS = [ + 'expo.app_startup.cold_launch_time', + 'expo.app_startup.warm_launch_time', + 'expo.app_startup.tti', + 'expo.app_startup.ttr', + 'expo.app_startup.bundle_load_time', +]; + +const DEFAULT_DAYS_BACK = 60; + +const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +export default class ObserveMetrics extends EasCommand { + static override description = 'display app performance metrics grouped by app version'; + + static override flags = { + platform: Flags.enum<'android' | 'ios'>({ + description: 'Filter by platform', + options: ['android', 'ios'], + }), + metric: Flags.string({ + description: + 'Metric name to display (can be specified multiple times). Supports aliases: tti, ttr, cold_launch, warm_launch, bundle_load', + multiple: true, + }), + stat: Flags.string({ + description: + 'Statistic to display per metric (can be specified multiple times). Options: min, max, med, avg, p80, p90, p99, count', + multiple: true, + }), + start: Flags.string({ + description: 'Start of time range for metrics data (ISO date).', + exclusive: ['days-from-now'], + }), + end: Flags.string({ + description: 'End of time range for metrics data (ISO date).', + exclusive: ['days-from-now'], + }), + 'days-from-now': Flags.integer({ + description: 'Show metrics from the last N days (mutually exclusive with --start/--end)', + min: 1, + exclusive: ['start', 'end'], + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveMetrics); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveMetrics, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricNames = flags.metric?.length + ? flags.metric.map(resolveMetricName) + : DEFAULT_METRICS; + + let startTime: string; + let endTime: string; + + if (flags['days-from-now']) { + endTime = new Date().toISOString(); + startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString(); + } else { + endTime = flags.end ?? new Date().toISOString(); + startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + } + + const platforms: AppPlatform[] = flags.platform + ? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios] + : [AppPlatform.Android, AppPlatform.Ios]; + + const metricsMap = await fetchObserveMetricsAsync( + graphqlClient, + projectId, + metricNames, + platforms, + startTime, + endTime + ); + + const argumentsStat = flags.stat?.length + ? Array.from(new Set(flags.stat.map(resolveStatKey))) + : undefined; + + if (flags.json) { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON; + printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats)); + } else { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; + Log.addNewLineIfNone(); + Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats)); + } + } +} 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..54ff577c0b --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -0,0 +1,82 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { AppObservePlatform, AppObserveTimeSeriesInput, AppObserveVersionMarker } from '../generated'; + +type AppObserveTimeSeriesQuery = { + app: { + byId: { + id: string; + observe: { + timeSeries: { + versionMarkers: AppObserveVersionMarker[]; + }; + }; + }; + }; +}; + +type AppObserveTimeSeriesQueryVariables = { + appId: string; + input: Pick; +}; + +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; + }, +}; 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..5dba0fc010 --- /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'], + [AppPlatform.Ios], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should have called the query twice (2 metrics x 1 platform) + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + // Verify metricsMap was assembled correctly + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + expect(metricsMap.has(key)).toBe(true); + + const metricsForVersion = metricsMap.get(key)!; + expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({ + min: 0.01, + max: 0.5, + median: 0.1, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.48, + eventCount: 100, + }); + expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({ + min: 0.05, + max: 1.2, + median: 0.3, + average: 0.4, + p80: 0.8, + p90: 1.0, + p99: 1.15, + eventCount: 80, + }); + }); + + it('fans out across multiple platforms', async () => { + mockTimeSeriesMarkers.mockResolvedValue([]); + + await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Ios, AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // 1 metric x 2 platforms = 2 calls + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform); + expect(platforms).toContain(AppObservePlatform.Ios); + expect(platforms).toContain(AppObservePlatform.Android); + }); + + it('handles partial failures gracefully โ€” successful queries still populate metricsMap', async () => { + mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => { + if (metricName === 'bad.metric') { + throw new Error('Unknown metric'); + } + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '2.0.0', + eventCount: 50, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + }, + }, + ]; + }); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti', 'bad.metric'], + [AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should not throw; the good metric should still be in the map + const key = makeMetricsKey('2.0.0', AppPlatform.Android); + expect(metricsMap.has(key)).toBe(true); + expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({ + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + eventCount: 50, + }); + // The bad metric should not be present + expect(metricsMap.get(key)!.has('bad.metric')).toBe(false); + }); + + it('returns empty map when all queries fail', async () => { + mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error')); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Ios], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + expect(metricsMap.size).toBe(0); + }); + + it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => { + mockTimeSeriesMarkers.mockResolvedValue([ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '3.0.0', + eventCount: 10, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.2, + median: 0.15, + average: 0.15, + p80: 0.18, + p90: 0.19, + p99: 0.2, + }, + }, + ]); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // The key should use AppPlatform (ANDROID), not AppObservePlatform + expect(metricsMap.has('3.0.0:ANDROID')).toBe(true); + expect(metricsMap.has('3.0.0:Android' as any)).toBe(false); + }); +}); 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..8c6452e659 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -0,0 +1,346 @@ +import { AppPlatform } from '../../graphql/generated'; +import { + ObserveMetricsMap, + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + makeMetricsKey, + resolveStatKey, + type MetricValues, +} from '../formatMetrics'; + +const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti']; + +function makeMetricValueWithDefaults(overrides: Partial): MetricValues { + return { + min: 0.1, + median: 0.3, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 100, + ...overrides, + }; +} + +describe(buildObserveMetricsTable, () => { + it('formats metrics grouped by version with metric columns', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios); + metricsMap.set( + iosKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })], + ]) + ); + + const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android); + metricsMap.set( + androidKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })], + ]) + ); + + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + + // The header is bolded, thus the escape characters in the snapshot + expect(output).toMatchInlineSnapshot(` +"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- --------------- ----------------- ------- --------- +1.2.0 iOS 0.35s 110 1.32s 90 +1.1.0 Android 0.25s 120 1.12s 100 " +`); + }); + + it('shows - for versions with no matching metric data', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('2.0.0', AppPlatform.Ios); + metricsMap.set(key, new Map()); + + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- --------------- ----------------- ------- --------- +2.0.0 iOS - - - - " +`); + }); + + it('returns message when no metrics data found', () => { + const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE); + expect(output).toMatchInlineSnapshot(`"No metrics data found."`); + }); +}); + +describe(buildObserveMetricsJson, () => { + it('produces JSON with all stats per metric', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })], + ]) + ); + + const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], DEFAULT_STATS_JSON); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + appVersion: '1.0.0', + platform: AppPlatform.Ios, + metrics: { + 'expo.app_startup.tti': { + min: 0.1, + median: 0.12, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 90, + }, + }, + }); + }); + + it('produces null values when no observe data matches for a metric', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('3.0.0', AppPlatform.Android); + metricsMap.set(key, new Map()); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics).toEqual({ + 'expo.app_startup.tti': { + min: null, + median: null, + max: null, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + }); + }); +}); + +describe(makeMetricsKey, () => { + it('creates a key from version and platform', () => { + expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS'); + expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID'); + }); +}); + +describe(resolveStatKey, () => { + it('resolves canonical stat names', () => { + expect(resolveStatKey('min')).toBe('min'); + expect(resolveStatKey('max')).toBe('max'); + expect(resolveStatKey('median')).toBe('median'); + expect(resolveStatKey('average')).toBe('average'); + expect(resolveStatKey('p80')).toBe('p80'); + expect(resolveStatKey('p90')).toBe('p90'); + expect(resolveStatKey('p99')).toBe('p99'); + expect(resolveStatKey('eventCount')).toBe('eventCount'); + }); + + it('resolves short aliases', () => { + expect(resolveStatKey('med')).toBe('median'); + expect(resolveStatKey('avg')).toBe('average'); + expect(resolveStatKey('count')).toBe('eventCount'); + expect(resolveStatKey('event_count')).toBe('eventCount'); + }); + + it('throws on unknown stat', () => { + expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"'); + }); +}); + +describe('DEFAULT_STATS_TABLE', () => { + it('defaults to median, eventCount', () => { + expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']); + }); +}); + +describe('DEFAULT_STATS_JSON', () => { + it('includes all stats', () => { + expect(DEFAULT_STATS_JSON).toEqual([ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', + ]); + }); +}); + +describe('custom stats parameter', () => { + it('table renders only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], [ + 'p99', + 'eventCount', + ]); + + expect(output).toContain('TTI P99'); + expect(output).toContain('TTI Count'); + expect(output).toContain('0.90s'); + expect(output).toContain('42'); + expect(output).not.toContain('TTI Min'); + expect(output).not.toContain('TTI Med'); + expect(output).not.toContain('TTI Max'); + }); + + it("table formats eventCount as integer without 's' suffix", () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: 100, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']); + + expect(output).toContain('100'); + expect(output).not.toContain('100s'); + expect(output).not.toContain('100.00s'); + }); + + it('JSON includes only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], [ + 'p90', + 'eventCount', + ]); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + p90: 0.4, + eventCount: 42, + }); + }); + + it('JSON uses default stats when not specified', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }); + }); +}); 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/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts new file mode 100644 index 0000000000..e1387c0625 --- /dev/null +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -0,0 +1,99 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasCommandError } from '../commandUtils/errors'; +import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated'; +import { ObserveQuery } from '../graphql/queries/ObserveQuery'; +import Log from '../log'; +import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics'; + +const appPlatformToObservePlatform: Record = { + [AppPlatform.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: AppPlatform[], + 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/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts new file mode 100644 index 0000000000..2adec4ad1d --- /dev/null +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -0,0 +1,165 @@ +import chalk from 'chalk'; + +import { EasCommandError } from '../commandUtils/errors'; +import { AppPlatform } from '../graphql/generated'; +import { appPlatformDisplayNames } from '../platform'; +import { getMetricDisplayName } from './metricNames'; + +export type StatisticKey = + | 'min' + | 'max' + | 'median' + | 'average' + | 'p80' + | 'p90' + | 'p99' + | 'eventCount'; + +export const STAT_ALIASES: Record = { + 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}`; +} + +function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } { + const lastColon = key.lastIndexOf(':'); + return { + appVersion: key.slice(0, lastColon), + platform: key.slice(lastColon + 1) as AppPlatform, + }; +} + +export type MetricValuesJson = Partial>; + +export interface ObserveMetricsVersionResult { + appVersion: string; + platform: AppPlatform; + metrics: Record; +} + +export function buildObserveMetricsJson( + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): ObserveMetricsVersionResult[] { + const results: ObserveMetricsVersionResult[] = []; + + for (const [key, versionMetrics] of metricsMap) { + const { appVersion, platform } = parseMetricsKey(key); + + const metrics: Record = {}; + for (const metricName of metricNames) { + const values = versionMetrics.get(metricName); + const statValues: MetricValuesJson = {}; + for (const stat of stats) { + statValues[stat] = values?.[stat] ?? null; + } + metrics[metricName] = statValues; + } + + results.push({ appVersion, platform, metrics }); + } + + return results; +} + +export function buildObserveMetricsTable( + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): string { + const results = buildObserveMetricsJson(metricsMap, metricNames, stats); + + if (results.length === 0) { + return chalk.yellow('No metrics data found.'); + } + + const fixedHeaders = ['App Version', 'Platform']; + const metricHeaders: string[] = []; + for (const m of metricNames) { + const name = getMetricDisplayName(m); + for (const stat of stats) { + metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`); + } + } + const headers = [...fixedHeaders, ...metricHeaders]; + + const rows: string[][] = results.map(result => { + const metricCells: string[] = []; + for (const m of metricNames) { + const values = result.metrics[m]; + for (const stat of stats) { + metricCells.push(formatStatValue(stat, values?.[stat] ?? null)); + } + } + + return [result.appVersion, appPlatformDisplayNames[result.platform], ...metricCells]; + }); + + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); + + const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); + const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); + const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')); + + return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n'); +} 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; +} From 30dfeb0f9e3cff30eb503d60026b2f67c23818dd Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:16:36 +0100 Subject: [PATCH 02/11] refactor: revert pagination to version from main --- packages/eas-cli/src/commandUtils/pagination.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/eas-cli/src/commandUtils/pagination.ts b/packages/eas-cli/src/commandUtils/pagination.ts index 142d42d41b..9566bf9998 100644 --- a/packages/eas-cli/src/commandUtils/pagination.ts +++ b/packages/eas-cli/src/commandUtils/pagination.ts @@ -35,16 +35,12 @@ const parseFlagInputStringAsInteger = ( export const getLimitFlagWithCustomValues = ({ defaultTo, limit, - description, }: { defaultTo: number; limit: number; - description?: string; }): OptionFlag => Flags.integer({ - description: - description ?? - `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, + description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, // eslint-disable-next-line async-protect/async-suffix parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit), }); From 33654bfdb974f783d0888072bb9bbf0a1028bf9c Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:17:37 +0100 Subject: [PATCH 03/11] refactor: rename DEFAULT_STATS_TABLE to TABLE_FORMAT_DEFAULT_STATS --- .../eas-cli/src/commands/observe/metrics.ts | 8 ++--- .../observe/__tests__/formatMetrics.test.ts | 36 +++++-------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts index 434c4d98e0..783383cd4d 100644 --- a/packages/eas-cli/src/commands/observe/metrics.ts +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -24,8 +24,8 @@ const DEFAULT_METRICS = [ const DEFAULT_DAYS_BACK = 60; -const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; -const DEFAULT_STATS_JSON: StatisticKey[] = [ +const TABLE_FORMAT_DEFAULT_STATS: StatisticKey[] = ['median', 'eventCount']; +const JSON_FORMAT_DEFAULT_STATS: StatisticKey[] = [ 'min', 'median', 'max', @@ -131,10 +131,10 @@ export default class ObserveMetrics extends EasCommand { : undefined; if (flags.json) { - const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON; + const stats: StatisticKey[] = argumentsStat ?? JSON_FORMAT_DEFAULT_STATS; printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats)); } else { - const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; + const stats: StatisticKey[] = argumentsStat ?? TABLE_FORMAT_DEFAULT_STATS; Log.addNewLineIfNone(); Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats)); } diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts index 8c6452e659..052343c9b8 100644 --- a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -9,8 +9,8 @@ import { type MetricValues, } from '../formatMetrics'; -const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; -const DEFAULT_STATS_JSON: StatisticKey[] = [ +const TABLE_FORMAT_DEFAULT_STATS: StatisticKey[] = ['median', 'eventCount']; +const JSON_FORMAT_DEFAULT_STATS: StatisticKey[] = [ 'min', 'median', 'max', @@ -64,7 +64,7 @@ describe(buildObserveMetricsTable, () => { ]) ); - const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); // The header is bolded, thus the escape characters in the snapshot expect(output).toMatchInlineSnapshot(` @@ -80,7 +80,7 @@ describe(buildObserveMetricsTable, () => { const key = makeMetricsKey('2.0.0', AppPlatform.Ios); metricsMap.set(key, new Map()); - const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); expect(output).toMatchInlineSnapshot(` "App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count @@ -90,7 +90,7 @@ describe(buildObserveMetricsTable, () => { }); it('returns message when no metrics data found', () => { - const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE); + const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); expect(output).toMatchInlineSnapshot(`"No metrics data found."`); }); }); @@ -106,7 +106,7 @@ describe(buildObserveMetricsJson, () => { ]) ); - const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], DEFAULT_STATS_JSON); + const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], JSON_FORMAT_DEFAULT_STATS); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -135,7 +135,7 @@ describe(buildObserveMetricsJson, () => { const result = buildObserveMetricsJson( metricsMap, ['expo.app_startup.tti'], - DEFAULT_STATS_JSON + JSON_FORMAT_DEFAULT_STATS ); expect(result[0].metrics).toEqual({ @@ -184,26 +184,6 @@ describe(resolveStatKey, () => { }); }); -describe('DEFAULT_STATS_TABLE', () => { - it('defaults to median, eventCount', () => { - expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']); - }); -}); - -describe('DEFAULT_STATS_JSON', () => { - it('includes all stats', () => { - expect(DEFAULT_STATS_JSON).toEqual([ - 'min', - 'median', - 'max', - 'average', - 'p80', - 'p90', - 'p99', - 'eventCount', - ]); - }); -}); describe('custom stats parameter', () => { it('table renders only selected stats', () => { @@ -329,7 +309,7 @@ describe('custom stats parameter', () => { const result = buildObserveMetricsJson( metricsMap, ['expo.app_startup.tti'], - DEFAULT_STATS_JSON + JSON_FORMAT_DEFAULT_STATS ); expect(result[0].metrics['expo.app_startup.tti']).toEqual({ From 1a6dfd5d0437eb8a46a259d5134ecd63574f31e4 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:19:00 +0100 Subject: [PATCH 04/11] adds comment to resolveStatKey --- packages/eas-cli/src/observe/formatMetrics.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/eas-cli/src/observe/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts index 2adec4ad1d..197b25488c 100644 --- a/packages/eas-cli/src/observe/formatMetrics.ts +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -41,6 +41,9 @@ export const STAT_DISPLAY_NAMES: Record = { eventCount: 'Count', }; +/** + * Resolves a user-provided stat alias (e.g. "avg", "med", "count") to graphql supported StatisticKey. + */ export function resolveStatKey(input: string): StatisticKey { const resolved = STAT_ALIASES[input]; if (resolved) { From 8438fca13680d4b90b825555d1e3af3c3147298d Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:33:20 +0100 Subject: [PATCH 05/11] refactor: move validateDateFlag to utils --- .../eas-cli/src/commands/observe/metrics.ts | 3 ++- .../eas-cli/src/observe/__tests__/utils.test.ts | 17 +++++++++++++++++ packages/eas-cli/src/observe/fetchMetrics.ts | 10 ---------- packages/eas-cli/src/observe/utils.ts | 10 ++++++++++ 4 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 packages/eas-cli/src/observe/__tests__/utils.test.ts create mode 100644 packages/eas-cli/src/observe/utils.ts diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts index 783383cd4d..3e5b37c3d5 100644 --- a/packages/eas-cli/src/commands/observe/metrics.ts +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -4,13 +4,14 @@ import EasCommand from '../../commandUtils/EasCommand'; import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; import { AppPlatform } from '../../graphql/generated'; import Log from '../../log'; -import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics'; +import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics'; import { StatisticKey, buildObserveMetricsJson, buildObserveMetricsTable, resolveStatKey, } from '../../observe/formatMetrics'; +import { validateDateFlag } from '../../observe/utils'; import { resolveMetricName } from '../../observe/metricNames'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; diff --git a/packages/eas-cli/src/observe/__tests__/utils.test.ts b/packages/eas-cli/src/observe/__tests__/utils.test.ts new file mode 100644 index 0000000000..1365d3b491 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/utils.test.ts @@ -0,0 +1,17 @@ +import { validateDateFlag } from '../utils'; + +describe(validateDateFlag, () => { + it('throws on invalid --start date', () => { + expect(() => validateDateFlag('not-a-date', '--start')).toThrow( + 'Invalid --start date: "not-a-date"' + ); + }); + + it('throws on invalid --end date', () => { + expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"'); + }); + + it('accepts valid ISO date in --start', () => { + expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow(); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts index e1387c0625..e3acba031c 100644 --- a/packages/eas-cli/src/observe/fetchMetrics.ts +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -1,5 +1,4 @@ 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'; @@ -21,15 +20,6 @@ interface ObserveQueryResult { 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, diff --git a/packages/eas-cli/src/observe/utils.ts b/packages/eas-cli/src/observe/utils.ts new file mode 100644 index 0000000000..5d7eb04875 --- /dev/null +++ b/packages/eas-cli/src/observe/utils.ts @@ -0,0 +1,10 @@ +import { EasCommandError } from "../commandUtils/errors"; + +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).`, + ); + } +} From 0c77831d15ba22146283de87f6f8e24db27439d6 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:39:49 +0100 Subject: [PATCH 06/11] code format --- .../observe/__tests__/metrics.test.ts | 63 ++++++------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts index 52926a8b2a..c64e06aea6 100644 --- a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts +++ b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts @@ -2,18 +2,14 @@ import { Config } from '@oclif/core'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { AppPlatform } from '../../../graphql/generated'; -import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics'; +import { fetchObserveMetricsAsync } from '../../../observe/fetchMetrics'; import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import ObserveMetrics from '../metrics'; -jest.mock('../../../observe/fetchMetrics', () => { - const actual = jest.requireActual('../../../observe/fetchMetrics'); - return { - ...actual, - fetchObserveMetricsAsync: jest.fn(), - }; -}); +jest.mock('../../../observe/fetchMetrics', () => ({ + fetchObserveMetricsAsync: jest.fn(), +})); jest.mock('../../../observe/formatMetrics', () => ({ ...jest.requireActual('../../../observe/formatMetrics'), buildObserveMetricsTable: jest.fn().mockReturnValue('table'), @@ -120,22 +116,19 @@ describe(ObserveMetrics, () => { const command = createCommand(['--stat', 'p90', '--stat', 'count']); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['p90', 'eventCount'] - ); + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'p90', + 'eventCount', + ]); }); it('deduplicates --stat flags that resolve to the same key', async () => { const command = createCommand(['--stat', 'med', '--stat', 'median']); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['median'] - ); + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'median', + ]); }); it('uses --days-from-now to compute start/end time range', async () => { @@ -169,11 +162,10 @@ describe(ObserveMetrics, () => { const command = createCommand([]); await command.runAsync(); - expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['median', 'eventCount'] - ); + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'median', + 'eventCount', + ]); }); it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => { @@ -188,27 +180,10 @@ describe(ObserveMetrics, () => { await command.runAsync(); expect(mockEnableJsonOutput).toHaveBeenCalled(); - expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith( - expect.any(Map), - expect.any(Array), - ['min', 'average'] - ); + expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'min', + 'average', + ]); expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); }); }); - -describe(validateDateFlag, () => { - it('throws on invalid --start date', () => { - expect(() => validateDateFlag('not-a-date', '--start')).toThrow( - 'Invalid --start date: "not-a-date"' - ); - }); - - it('throws on invalid --end date', () => { - expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"'); - }); - - it('accepts valid ISO date in --start', () => { - expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow(); - }); -}); From 80227eb0704b71cacec713ecfae6165c29fc6126 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:40:10 +0100 Subject: [PATCH 07/11] improve and simplify the tests --- .../observe/__tests__/fetchMetrics.test.ts | 195 ++++++------------ .../observe/__tests__/formatMetrics.test.ts | 66 +++--- 2 files changed, 95 insertions(+), 166 deletions(-) diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts index 5dba0fc010..db9775f8ec 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -4,6 +4,24 @@ import { makeMetricsKey } from '../formatMetrics'; import { fetchObserveMetricsAsync } from '../fetchMetrics'; jest.mock('../../graphql/queries/ObserveQuery'); +jest.mock('../../log'); + +const SIMPLE_MARKER = { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '1.0.0', + eventCount: 100, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.5, + median: 0.2, + average: 0.3, + p80: 0.35, + p90: 0.4, + p99: 0.48, + }, +}; describe('fetchObserveMetricsAsync', () => { const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync); @@ -14,52 +32,9 @@ describe('fetchObserveMetricsAsync', () => { }); 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 []; - }); + mockTimeSeriesMarkers + .mockResolvedValueOnce([{ ...SIMPLE_MARKER, eventCount: 100 }]) + .mockResolvedValueOnce([{ ...SIMPLE_MARKER, eventCount: 80 }]); const metricsMap = await fetchObserveMetricsAsync( mockGraphqlClient, @@ -70,34 +45,30 @@ describe('fetchObserveMetricsAsync', () => { '2025-03-01T00:00:00.000Z' ); - // Should have called the query twice (2 metrics x 1 platform) expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + expect(mockTimeSeriesMarkers).toHaveBeenNthCalledWith(1, mockGraphqlClient, { + appId: 'project-123', + metricName: 'expo.app_startup.tti', + platform: AppObservePlatform.Ios, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + expect(mockTimeSeriesMarkers).toHaveBeenNthCalledWith(2, mockGraphqlClient, { + appId: 'project-123', + metricName: 'expo.app_startup.cold_launch_time', + platform: AppObservePlatform.Ios, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); - // 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, - }); + expect(metricsForVersion.get('expo.app_startup.tti')).toEqual( + expect.objectContaining({ eventCount: 100, min: 0.1, p99: 0.48 }) + ); + expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual( + expect.objectContaining({ eventCount: 80 }) + ); }); it('fans out across multiple platforms', async () => { @@ -112,38 +83,27 @@ describe('fetchObserveMetricsAsync', () => { '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); + expect(mockTimeSeriesMarkers).toHaveBeenNthCalledWith(1, mockGraphqlClient, { + appId: 'project-123', + metricName: 'expo.app_startup.tti', + platform: AppObservePlatform.Ios, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + expect(mockTimeSeriesMarkers).toHaveBeenNthCalledWith(2, mockGraphqlClient, { + appId: 'project-123', + metricName: 'expo.app_startup.tti', + platform: AppObservePlatform.Android, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); }); - 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, - }, - }, - ]; - }); + it('handles partial failures gracefully - successful queries still populate metricsMap', async () => { + mockTimeSeriesMarkers + .mockResolvedValueOnce([SIMPLE_MARKER]) + .mockRejectedValueOnce(new Error('Unknown metric')); const metricsMap = await fetchObserveMetricsAsync( mockGraphqlClient, @@ -154,20 +114,8 @@ describe('fetchObserveMetricsAsync', () => { '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 + const key = makeMetricsKey('1.0.0', AppPlatform.Android); + expect(metricsMap.get(key)!.has('expo.app_startup.tti')).toBe(true); expect(metricsMap.get(key)!.has('bad.metric')).toBe(false); }); @@ -187,24 +135,7 @@ describe('fetchObserveMetricsAsync', () => { }); 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, - }, - }, - ]); + mockTimeSeriesMarkers.mockResolvedValue([{ ...SIMPLE_MARKER, appVersion: '3.0.0' }]); const metricsMap = await fetchObserveMetricsAsync( mockGraphqlClient, @@ -219,4 +150,4 @@ describe('fetchObserveMetricsAsync', () => { expect(metricsMap.has('3.0.0:ANDROID')).toBe(true); expect(metricsMap.has('3.0.0:Android' as any)).toBe(false); }); -}); +}); \ No newline at end of file diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts index 052343c9b8..46ff078686 100644 --- a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -1,7 +1,6 @@ import { AppPlatform } from '../../graphql/generated'; import { ObserveMetricsMap, - StatisticKey, buildObserveMetricsJson, buildObserveMetricsTable, makeMetricsKey, @@ -9,20 +8,6 @@ import { type MetricValues, } from '../formatMetrics'; -const TABLE_FORMAT_DEFAULT_STATS: StatisticKey[] = ['median', 'eventCount']; -const JSON_FORMAT_DEFAULT_STATS: StatisticKey[] = [ - 'min', - 'median', - 'max', - 'average', - 'p80', - 'p90', - 'p99', - 'eventCount', -]; - -const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti']; - function makeMetricValueWithDefaults(overrides: Partial): MetricValues { return { min: 0.1, @@ -64,7 +49,11 @@ describe(buildObserveMetricsTable, () => { ]) ); - const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); + const output = buildObserveMetricsTable( + metricsMap, + ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'], + ['median', 'eventCount'] + ); // The header is bolded, thus the escape characters in the snapshot expect(output).toMatchInlineSnapshot(` @@ -80,7 +69,11 @@ describe(buildObserveMetricsTable, () => { const key = makeMetricsKey('2.0.0', AppPlatform.Ios); metricsMap.set(key, new Map()); - const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); + const output = buildObserveMetricsTable( + metricsMap, + ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'], + ['median', 'eventCount'] + ); expect(output).toMatchInlineSnapshot(` "App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count @@ -90,7 +83,11 @@ describe(buildObserveMetricsTable, () => { }); it('returns message when no metrics data found', () => { - const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, TABLE_FORMAT_DEFAULT_STATS); + const output = buildObserveMetricsTable( + new Map(), + ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'], + ['median', 'eventCount'] + ); expect(output).toMatchInlineSnapshot(`"No metrics data found."`); }); }); @@ -106,7 +103,11 @@ describe(buildObserveMetricsJson, () => { ]) ); - const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], JSON_FORMAT_DEFAULT_STATS); + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + ['min', 'median', 'max', 'p99'] + ); expect(result).toHaveLength(1); expect(result[0]).toEqual({ @@ -117,11 +118,7 @@ describe(buildObserveMetricsJson, () => { min: 0.1, median: 0.12, max: 1.1, - average: 0.5, - p80: 0.8, - p90: 0.9, p99: 1.0, - eventCount: 90, }, }, }); @@ -135,7 +132,7 @@ describe(buildObserveMetricsJson, () => { const result = buildObserveMetricsJson( metricsMap, ['expo.app_startup.tti'], - JSON_FORMAT_DEFAULT_STATS + ['min', 'median', 'max', 'average', 'p80', 'p90', 'p99', 'eventCount'] ); expect(result[0].metrics).toEqual({ @@ -184,7 +181,6 @@ describe(resolveStatKey, () => { }); }); - describe('custom stats parameter', () => { it('table renders only selected stats', () => { const metricsMap: ObserveMetricsMap = new Map(); @@ -208,10 +204,11 @@ describe('custom stats parameter', () => { ]) ); - const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], [ - 'p99', - 'eventCount', - ]); + const output = buildObserveMetricsTable( + metricsMap, + ['expo.app_startup.tti'], + ['p99', 'eventCount'] + ); expect(output).toContain('TTI P99'); expect(output).toContain('TTI Count'); @@ -273,10 +270,11 @@ describe('custom stats parameter', () => { ]) ); - const result = buildObserveMetricsJson(metricsMap, ['expo.app_startup.tti'], [ - 'p90', - 'eventCount', - ]); + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + ['p90', 'eventCount'] + ); expect(result[0].metrics['expo.app_startup.tti']).toEqual({ p90: 0.4, @@ -309,7 +307,7 @@ describe('custom stats parameter', () => { const result = buildObserveMetricsJson( metricsMap, ['expo.app_startup.tti'], - JSON_FORMAT_DEFAULT_STATS + ['min', 'median', 'max', 'average', 'p80', 'p90', 'p99', 'eventCount'] ); expect(result[0].metrics['expo.app_startup.tti']).toEqual({ From 06f6179f8c55cd673a54ee20c4f68f62f1dbebb0 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 13:43:18 +0100 Subject: [PATCH 08/11] add todos about multiple queries --- packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts | 1 + packages/eas-cli/src/observe/fetchMetrics.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts index db9775f8ec..d6dbfacd14 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -31,6 +31,7 @@ describe('fetchObserveMetricsAsync', () => { mockTimeSeriesMarkers.mockClear(); }); + // TODO(@ubax): add support for fetching multiple metrics and platforms in a single query it('fans out queries for each metric+platform combo and assembles metricsMap', async () => { mockTimeSeriesMarkers .mockResolvedValueOnce([{ ...SIMPLE_MARKER, eventCount: 100 }]) diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts index e3acba031c..b08c7a1ff3 100644 --- a/packages/eas-cli/src/observe/fetchMetrics.ts +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -30,6 +30,7 @@ export async function fetchObserveMetricsAsync( ): Promise { const observeQueries: Promise[] = []; + // TODO(@ubax): add support for fetching multiple metrics and platforms in a single query for (const metricName of metricNames) { for (const appPlatform of platforms) { const observePlatform = appPlatformToObservePlatform[appPlatform]; From 49a59f41f38847d31a8aaaac679914badcfe2902 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 14:51:08 +0100 Subject: [PATCH 09/11] cover better null cases in formatMetrics tests --- .../observe/__tests__/formatMetrics.test.ts | 80 +++++++------------ 1 file changed, 27 insertions(+), 53 deletions(-) diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts index 46ff078686..f8ab565d17 100644 --- a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -64,10 +64,18 @@ describe(buildObserveMetricsTable, () => { `); }); - it('shows - for versions with no matching metric data', () => { + it('shows - for metrics with missing values for versions', () => { const metricsMap: ObserveMetricsMap = new Map(); const key = makeMetricsKey('2.0.0', AppPlatform.Ios); - metricsMap.set(key, new Map()); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.25, eventCount: 80 }), + ], + ]) + ); const output = buildObserveMetricsTable( metricsMap, @@ -78,7 +86,7 @@ describe(buildObserveMetricsTable, () => { expect(output).toMatchInlineSnapshot(` "App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count ----------- -------- --------------- ----------------- ------- --------- -2.0.0 iOS - - - - " +2.0.0 iOS 0.25s 80 - - " `); }); @@ -124,26 +132,32 @@ describe(buildObserveMetricsJson, () => { }); }); - it('produces null values when no observe data matches for a metric', () => { + it('produces null values for metrics missing from a version that has other metric data', () => { const metricsMap: ObserveMetricsMap = new Map(); const key = makeMetricsKey('3.0.0', AppPlatform.Android); - metricsMap.set(key, new Map()); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.25, eventCount: 80 }), + ], + ]) + ); const result = buildObserveMetricsJson( metricsMap, - ['expo.app_startup.tti'], - ['min', 'median', 'max', 'average', 'p80', 'p90', 'p99', 'eventCount'] + ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti'], + ['median', 'eventCount'] ); expect(result[0].metrics).toEqual({ + 'expo.app_startup.cold_launch_time': { + median: 0.25, + eventCount: 80, + }, 'expo.app_startup.tti': { - min: null, median: null, - max: null, - average: null, - p80: null, - p90: null, - p99: null, eventCount: null, }, }); @@ -281,44 +295,4 @@ describe('custom stats parameter', () => { eventCount: 42, }); }); - - it('JSON uses default stats when not specified', () => { - const metricsMap: ObserveMetricsMap = new Map(); - const key = makeMetricsKey('1.0.0', AppPlatform.Ios); - metricsMap.set( - key, - new Map([ - [ - 'expo.app_startup.tti', - { - min: 0.02, - median: 0.1, - max: 0.4, - average: null, - p80: null, - p90: null, - p99: null, - eventCount: null, - }, - ], - ]) - ); - - const result = buildObserveMetricsJson( - metricsMap, - ['expo.app_startup.tti'], - ['min', 'median', 'max', 'average', 'p80', 'p90', 'p99', 'eventCount'] - ); - - 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, - }); - }); }); From 09c219bb497c3a6cbb0ceaa3ef0feb5b85bff8bf Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 14:54:04 +0100 Subject: [PATCH 10/11] improve description in fetchMetrics test --- .../eas-cli/src/observe/__tests__/fetchMetrics.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts index d6dbfacd14..4381880c28 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -32,7 +32,7 @@ describe('fetchObserveMetricsAsync', () => { }); // TODO(@ubax): add support for fetching multiple metrics and platforms in a single query - it('fans out queries for each metric+platform combo and assembles metricsMap', async () => { + it('creates queries for each metric+platform combination and assembles metricsMap', async () => { mockTimeSeriesMarkers .mockResolvedValueOnce([{ ...SIMPLE_MARKER, eventCount: 100 }]) .mockResolvedValueOnce([{ ...SIMPLE_MARKER, eventCount: 80 }]); @@ -72,7 +72,7 @@ describe('fetchObserveMetricsAsync', () => { ); }); - it('fans out across multiple platforms', async () => { + it('creates queries for each platform', async () => { mockTimeSeriesMarkers.mockResolvedValue([]); await fetchObserveMetricsAsync( @@ -147,8 +147,7 @@ describe('fetchObserveMetricsAsync', () => { '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); + expect(metricsMap.has(`3.0.0:${AppPlatform.Android}`)).toBe(true); + expect(metricsMap.has(`3.0.0:${AppObservePlatform.Android}`)).toBe(false); }); }); \ No newline at end of file From 6e1e21967dd40a0a3065e36e64291be384446e0f Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Wed, 25 Feb 2026 15:21:21 +0100 Subject: [PATCH 11/11] refactor: extract makeMetricsKey --- .../src/graphql/queries/ObserveQuery.ts | 6 +++- .../observe/__tests__/fetchMetrics.test.ts | 20 ++----------- .../observe/__tests__/formatMetrics.test.ts | 5 ++-- packages/eas-cli/src/observe/fetchMetrics.ts | 3 +- packages/eas-cli/src/observe/formatMetrics.ts | 29 ++----------------- packages/eas-cli/src/observe/metrics.types.ts | 21 ++++++++++++++ packages/eas-cli/src/observe/utils.ts | 21 ++++++++++++-- 7 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 packages/eas-cli/src/observe/metrics.types.ts diff --git a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts index 54ff577c0b..3fa2027487 100644 --- a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -2,7 +2,11 @@ import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { withErrorHandlingAsync } from '../client'; -import { AppObservePlatform, AppObserveTimeSeriesInput, AppObserveVersionMarker } from '../generated'; +import { + AppObservePlatform, + AppObserveTimeSeriesInput, + AppObserveVersionMarker, +} from '../generated'; type AppObserveTimeSeriesQuery = { app: { diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts index 4381880c28..ce7c1c5386 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -1,6 +1,6 @@ import { AppObservePlatform, AppPlatform } from '../../graphql/generated'; import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; -import { makeMetricsKey } from '../formatMetrics'; +import { makeMetricsKey } from '../utils'; import { fetchObserveMetricsAsync } from '../fetchMetrics'; jest.mock('../../graphql/queries/ObserveQuery'); @@ -134,20 +134,4 @@ describe('fetchObserveMetricsAsync', () => { expect(metricsMap.size).toBe(0); }); - - it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => { - mockTimeSeriesMarkers.mockResolvedValue([{ ...SIMPLE_MARKER, appVersion: '3.0.0' }]); - - const metricsMap = await fetchObserveMetricsAsync( - mockGraphqlClient, - 'project-123', - ['expo.app_startup.tti'], - [AppPlatform.Android], - '2025-01-01T00:00:00.000Z', - '2025-03-01T00:00:00.000Z' - ); - - expect(metricsMap.has(`3.0.0:${AppPlatform.Android}`)).toBe(true); - expect(metricsMap.has(`3.0.0:${AppObservePlatform.Android}`)).toBe(false); - }); -}); \ No newline at end of file +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts index f8ab565d17..5a46c3e864 100644 --- a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -1,12 +1,11 @@ import { AppPlatform } from '../../graphql/generated'; import { - ObserveMetricsMap, buildObserveMetricsJson, buildObserveMetricsTable, - makeMetricsKey, resolveStatKey, - type MetricValues, } from '../formatMetrics'; +import type { MetricValues, ObserveMetricsMap } from '../metrics.types'; +import { makeMetricsKey } from '../utils'; function makeMetricValueWithDefaults(overrides: Partial): MetricValues { return { diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts index b08c7a1ff3..78b54f8dc8 100644 --- a/packages/eas-cli/src/observe/fetchMetrics.ts +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -2,7 +2,8 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated'; import { ObserveQuery } from '../graphql/queries/ObserveQuery'; import Log from '../log'; -import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics'; +import type { MetricValues, ObserveMetricsMap } from './metrics.types'; +import { makeMetricsKey } from './utils'; const appPlatformToObservePlatform: Record = { [AppPlatform.Android]: AppObservePlatform.Android, diff --git a/packages/eas-cli/src/observe/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts index 197b25488c..1a834bc73b 100644 --- a/packages/eas-cli/src/observe/formatMetrics.ts +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -4,6 +4,8 @@ import { EasCommandError } from '../commandUtils/errors'; import { AppPlatform } from '../graphql/generated'; import { appPlatformDisplayNames } from '../platform'; import { getMetricDisplayName } from './metricNames'; +import { parseMetricsKey } from './utils'; +import type { ObserveMetricsMap } from './metrics.types'; export type StatisticKey = | 'min' @@ -64,33 +66,6 @@ function formatStatValue(stat: StatisticKey, value: number | null | undefined): 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}`; -} - -function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } { - const lastColon = key.lastIndexOf(':'); - return { - appVersion: key.slice(0, lastColon), - platform: key.slice(lastColon + 1) as AppPlatform, - }; -} - export type MetricValuesJson = Partial>; export interface ObserveMetricsVersionResult { diff --git a/packages/eas-cli/src/observe/metrics.types.ts b/packages/eas-cli/src/observe/metrics.types.ts new file mode 100644 index 0000000000..58e1577a8c --- /dev/null +++ b/packages/eas-cli/src/observe/metrics.types.ts @@ -0,0 +1,21 @@ +import type { AppPlatform } from '../graphql/generated'; + +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; +} + +/** + * ObserveMetricsKey encodes an app version + platform pair into a single string key. + * This is needed because the observe API returns metrics per (version, platform) combination, + * and we use a flat Map + */ +export type ObserveMetricsKey = `${string}:${AppPlatform}`; + +export type ObserveMetricsMap = Map>; diff --git a/packages/eas-cli/src/observe/utils.ts b/packages/eas-cli/src/observe/utils.ts index 5d7eb04875..081aa568b9 100644 --- a/packages/eas-cli/src/observe/utils.ts +++ b/packages/eas-cli/src/observe/utils.ts @@ -1,10 +1,27 @@ -import { EasCommandError } from "../commandUtils/errors"; +import { EasCommandError } from '../commandUtils/errors'; +import { AppPlatform } from '../graphql/generated'; +import type { ObserveMetricsKey } from './metrics.types'; + +export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey { + return `${appVersion}:${platform}`; +} + +export function parseMetricsKey(key: ObserveMetricsKey): { + appVersion: string; + platform: AppPlatform; +} { + const lastColon = key.lastIndexOf(':'); + return { + appVersion: key.slice(0, lastColon), + platform: key.slice(lastColon + 1) as AppPlatform, + }; +} export 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).`, + `Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).` ); } }