From 1827e5e5dfdee7cf1278502199c2da62aebe70b6 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Fri, 13 Feb 2026 15:57:40 +0100 Subject: [PATCH 1/4] feat(eas-cli): add observe metrics command --- packages/eas-cli/package.json | 3 + .../eas-cli/src/commands/observe/metrics.ts | 121 +++++++ .../src/graphql/queries/ObserveQuery.ts | 85 +++++ .../observe/__tests__/fetchMetrics.test.ts | 160 +++++++++ .../observe/__tests__/formatMetrics.test.ts | 317 ++++++++++++++++++ packages/eas-cli/src/observe/fetchMetrics.ts | 109 ++++++ packages/eas-cli/src/observe/formatMetrics.ts | 194 +++++++++++ 7 files changed, 989 insertions(+) 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/fetchMetrics.ts create mode 100644 packages/eas-cli/src/observe/formatMetrics.ts diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 495cd29815..b31eaf7bf3 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -214,6 +214,9 @@ "update": { "description": "manage individual updates" }, + "observe": { + "description": "monitor app performance metrics" + }, "webhook": { "description": "manage webhooks" }, 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..923485a347 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -0,0 +1,121 @@ +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 { buildObserveMetricsJson, buildObserveMetricsTable } from '../../observe/formatMetrics'; +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)', + 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 : 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 + ); + + if (flags.json) { + printJsonOnlyOutput(buildObserveMetricsJson(builds, metricsMap, metricNames)); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveMetricsTable(builds, metricsMap, metricNames)); + } + } +} 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..7b14f5627b --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -0,0 +1,85 @@ +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 + } + } + } + } + } + } + } + `, + { + 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..1e692e9280 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -0,0 +1,160 @@ +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 }, + }]; + } + 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 }, + }]; + } + 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, + }); + expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({ + min: 0.05, + max: 1.2, + median: 0.3, + }); + }); + + 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 }, + }]; + }); + + 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, + }); + // 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 }, + }]); + + 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__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts new file mode 100644 index 0000000000..34fdc1917b --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -0,0 +1,317 @@ +import { + AppPlatform, + BuildPriority, + BuildStatus, +} from "../../graphql/generated"; +import { + ObserveMetricsMap, + buildObserveMetricsJson, + buildObserveMetricsTable, + makeMetricsKey, +} 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 makeMetricValues( + min: number | null, + median: number | null, + max: number | null, +) { + return { min, median, max }; +} + +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", makeMetricValues(0.05, 0.2, 0.8)], + ["expo.app_startup.tti", makeMetricValues(0.03, 0.12, 0.5)], + ]), + ); + + const androidKey = makeMetricsKey("1.1.0", AppPlatform.Android); + metricsMap.set( + androidKey, + new Map([ + ["expo.app_startup.cold_launch_time", makeMetricValues(0.06, 0.25, 0.9)], + ["expo.app_startup.tti", makeMetricValues(0.04, 0.15, 0.6)], + ]), + ); + + const output = buildObserveMetricsTable(builds, metricsMap, DEFAULT_METRICS); + + // The header is bolded, thus the escape characters in the snapshot + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max +----------- -------- ------------ ---------------- --------------- --------------- --------------- ------- ------- ------- +1.2.0 iOS Jan 15, 2025 aaa1111, bbb2222 0.05s 0.20s 0.80s 0.03s 0.12s 0.50s +1.1.0 Android Jan 15, 2025 ccc3333 0.06s 0.25s 0.90s 0.04s 0.15s 0.60s " +`); + }); + + 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); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max +----------- -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- +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); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max +----------- -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- +- iOS Jan 15, 2025 abc1234 - - - - - - " +`); + }); + + it("returns message when no builds found", () => { + const output = buildObserveMetricsTable([], new Map(), DEFAULT_METRICS); + 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); + + 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); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max +----------- -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- +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", makeMetricValues(0.02, 0.1, 0.4)]]), + ); + + const result = buildObserveMetricsJson(builds, metricsMap, [ + "expo.app_startup.tti", + ]); + + 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.02, median: 0.1, max: 0.4 }, + }, + }); + }); + + 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", + ]); + + expect(result[0].metrics).toEqual({ + "expo.app_startup.tti": { min: null, median: null, max: 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", + ]); + + expect(result[0].appVersion).toBeNull(); + expect(result[0].metrics["expo.app_startup.tti"]).toEqual({ + min: null, + median: null, + max: 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"); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts new file mode 100644 index 0000000000..03ca9a700a --- /dev/null +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -0,0 +1,109 @@ +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, + }; + 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..34ef400dae --- /dev/null +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -0,0 +1,194 @@ +import chalk from "chalk"; + +import { AppPlatform, BuildFragment } from "../graphql/generated"; +import { appPlatformDisplayNames } from "../platform"; + +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", +}; + +function getMetricDisplayName(metricName: string): string { + return METRIC_SHORT_NAMES[metricName] ?? metricName; +} + +function formatSeconds(value: number | null | undefined): string { + if (value == null) { + return "-"; + } + return `${value.toFixed(2)}s`; +} + +export interface MetricValues { + min: number | null | undefined; + max: number | null | undefined; + median: number | null | undefined; +} + +type ObserveMetricsKey = `${string}:${AppPlatform}`; + +export type ObserveMetricsMap = Map< + ObserveMetricsKey, + Map // metricName → { min, max, median } +>; + +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 interface MetricValuesJson { + min: number | null; + median: number | null; + max: number | null; +} + +export interface ObserveMetricsVersionResult { + appVersion: string | null; + platform: AppPlatform; + lastBuildDate: string | null; + commits: string[]; + metrics: Record; +} + +export function buildObserveMetricsJson( + builds: BuildFragment[], + metricsMap: ObserveMetricsMap, + metricNames: string[], +): 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); + metrics[metricName] = { + min: values?.min ?? null, + median: values?.median ?? null, + max: values?.max ?? null, + }; + } + + 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[], +): string { + const results = buildObserveMetricsJson(builds, metricsMap, metricNames); + + 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); + metricHeaders.push(`${name} Min`, `${name} Med`, `${name} Max`); + } + const headers = [...fixedHeaders, ...metricHeaders]; + + const rows: string[][] = results.map((result) => { + const metricCells: string[] = []; + for (const m of metricNames) { + const values = result.metrics[m]; + metricCells.push( + formatSeconds(values?.min ?? null), + formatSeconds(values?.median ?? null), + formatSeconds(values?.max ?? 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"); +} From 967c430ad684055c3c81f58122450b73a080215d Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Fri, 13 Feb 2026 16:54:54 +0100 Subject: [PATCH 2/4] feat: add observe:events --- .../eas-cli/src/commands/observe/events.ts | 112 ++++++++++ .../src/graphql/queries/ObserveQuery.ts | 94 ++++++++ .../src/observe/__tests__/fetchEvents.test.ts | 207 ++++++++++++++++++ .../observe/__tests__/formatEvents.test.ts | 165 ++++++++++++++ packages/eas-cli/src/observe/fetchEvents.ts | 99 +++++++++ packages/eas-cli/src/observe/formatEvents.ts | 88 ++++++++ 6 files changed, 765 insertions(+) create mode 100644 packages/eas-cli/src/commands/observe/events.ts create mode 100644 packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts create mode 100644 packages/eas-cli/src/observe/__tests__/formatEvents.test.ts create mode 100644 packages/eas-cli/src/observe/fetchEvents.ts create mode 100644 packages/eas-cli/src/observe/formatEvents.ts diff --git a/packages/eas-cli/src/commands/observe/events.ts b/packages/eas-cli/src/commands/observe/events.ts new file mode 100644 index 0000000000..7afa05b10f --- /dev/null +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -0,0 +1,112 @@ +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, + resolveMetricName, + resolveOrderBy, +} from '../../observe/fetchEvents'; +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)', + }), + end: Flags.string({ + description: 'End of time range (ISO date)', + }), + 'app-version': Flags.string({ + description: 'Filter by app version', + }), + ...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); + + const endTime = flags.end ?? new Date().toISOString(); + const 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'], + }); + + if (flags.json) { + printJsonOnlyOutput(buildObserveEventsJson(events)); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveEventsTable(events)); + } + } +} diff --git a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts index 7b14f5627b..7ce5b43517 100644 --- a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -3,9 +3,13 @@ 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 = { @@ -26,6 +30,31 @@ type AppObserveTimeSeriesQueryVariables = { input: Pick; }; +type AppObserveEventsQuery = { + app: { + byId: { + id: string; + observe: { + events: { + pageInfo: PageInfo; + edges: Array<{ + cursor: string; + node: AppObserveEvent; + }>; + }; + }; + }; + }; +}; + +type AppObserveEventsQueryVariables = { + appId: string; + filter?: AppObserveEventsFilter; + first?: number; + after?: string; + orderBy?: AppObserveEventsOrderBy; +}; + export const ObserveQuery = { async timeSeriesVersionMarkersAsync( graphqlClient: ExpoGraphqlClient, @@ -82,4 +111,69 @@ export const ObserveQuery = { return data.app.byId.observe.timeSeries.versionMarkers; }, + + async eventsAsync( + graphqlClient: ExpoGraphqlClient, + variables: AppObserveEventsQueryVariables + ): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveEvents( + $appId: String! + $filter: AppObserveEventsFilter + $first: Int + $after: String + $orderBy: AppObserveEventsOrderBy + ) { + app { + byId(appId: $appId) { + id + observe { + events( + filter: $filter + first: $first + after: $after + orderBy: $orderBy + ) { + pageInfo { + hasNextPage + hasPreviousPage + endCursor + } + edges { + cursor + node { + id + metricName + metricValue + timestamp + appVersion + appBuildNumber + deviceModel + deviceOs + deviceOsVersion + countryCode + sessionId + easClientId + } + } + } + } + } + } + } + `, + variables + ) + .toPromise() + ); + + const { edges, pageInfo } = data.app.byId.observe.events; + return { + events: edges.map(edge => edge.node), + pageInfo, + }; + }, }; diff --git a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts new file mode 100644 index 0000000000..b090a92dbc --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -0,0 +1,207 @@ +import { AppObserveEventsOrderByDirection, AppObserveEventsOrderByField, AppObservePlatform } from '../../graphql/generated'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { fetchObserveEventsAsync, resolveMetricName, 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(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(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('omits platform and appVersion 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'); + }); + + 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__/formatEvents.test.ts b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts new file mode 100644 index 0000000000..b4a43d1ea0 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts @@ -0,0 +1,165 @@ +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/fetchEvents.ts b/packages/eas-cli/src/observe/fetchEvents.ts new file mode 100644 index 0000000000..d3557c14ec --- /dev/null +++ b/packages/eas-cli/src/observe/fetchEvents.ts @@ -0,0 +1,99 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasCommandError } from '../commandUtils/errors'; +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; + +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 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, + }; + } +} + +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(', ')}` + ); +} + +interface FetchObserveEventsOptions { + metricName: string; + orderBy: AppObserveEventsOrderBy; + limit: number; + startTime: string; + endTime: string; + platform?: AppObservePlatform; + appVersion?: 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 }), + }; + + return ObserveQuery.eventsAsync(graphqlClient, { + appId, + filter, + first: options.limit, + orderBy: options.orderBy, + }); +} diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts new file mode 100644 index 0000000000..cb8af4d2b5 --- /dev/null +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -0,0 +1,88 @@ +import chalk from 'chalk'; + +import { AppObserveEvent } from '../graphql/generated'; + +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', +}; + +function getMetricDisplayName(metricName: string): string { + return METRIC_SHORT_NAMES[metricName] ?? metricName; +} + +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, + })); +} From 4cdeb118b0910ddba2b57ece9139e36b5513a035 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Thu, 19 Feb 2026 12:19:10 +0100 Subject: [PATCH 3/4] feat: add --days-from-now and --update-id filters to observe:events --- .../commands/observe/__tests__/events.test.ts | 129 ++++++++++++++++++ .../eas-cli/src/commands/observe/events.ts | 29 +++- .../src/observe/__tests__/fetchEvents.test.ts | 25 +++- packages/eas-cli/src/observe/fetchEvents.ts | 2 + 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 packages/eas-cli/src/commands/observe/__tests__/events.test.ts 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..9d1b66303f --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -0,0 +1,129 @@ +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 index 7afa05b10f..0f308fed6c 100644 --- a/packages/eas-cli/src/commands/observe/events.ts +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -41,13 +41,23 @@ export default class ObserveEvents extends EasCommand { }), 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, }; @@ -81,10 +91,20 @@ export default class ObserveEvents extends EasCommand { const metricName = resolveMetricName(flags.metric); const orderBy = resolveOrderBy(flags.sort); - const endTime = flags.end ?? new Date().toISOString(); - const startTime = - flags.start ?? - new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + 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' @@ -100,6 +120,7 @@ export default class ObserveEvents extends EasCommand { endTime, platform, appVersion: flags['app-version'], + updateId: flags['update-id'], }); if (flags.json) { diff --git a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts index b090a92dbc..0e565eb188 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -150,7 +150,29 @@ describe(fetchObserveEventsAsync, () => { })); }); - it('omits platform and appVersion from filter when not provided', async () => { + 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 }, @@ -167,6 +189,7 @@ describe(fetchObserveEventsAsync, () => { 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 () => { diff --git a/packages/eas-cli/src/observe/fetchEvents.ts b/packages/eas-cli/src/observe/fetchEvents.ts index d3557c14ec..3db2eb4b22 100644 --- a/packages/eas-cli/src/observe/fetchEvents.ts +++ b/packages/eas-cli/src/observe/fetchEvents.ts @@ -70,6 +70,7 @@ interface FetchObserveEventsOptions { endTime: string; platform?: AppObservePlatform; appVersion?: string; + updateId?: string; } interface FetchObserveEventsResult { @@ -88,6 +89,7 @@ export async function fetchObserveEventsAsync( endTime: options.endTime, ...(options.platform && { platform: options.platform }), ...(options.appVersion && { appVersion: options.appVersion }), + ...(options.updateId && { appUpdateId: options.updateId }), }; return ObserveQuery.eventsAsync(graphqlClient, { From 22f1872873b2d4a0c25c20b6938c3d4e5c9f454e Mon Sep 17 00:00:00 2001 From: Jakub Tkacz Date: Thu, 19 Feb 2026 16:09:00 +0100 Subject: [PATCH 4/4] feat: add --stat argument to observe:metrics --- packages/eas-cli/package.json | 6 +- .../commands/observe/__tests__/events.test.ts | 7 +- .../eas-cli/src/commands/observe/events.ts | 9 +- .../eas-cli/src/commands/observe/metrics.ts | 35 +- .../src/graphql/queries/ObserveQuery.ts | 16 +- .../src/observe/__tests__/fetchEvents.test.ts | 119 ++-- .../observe/__tests__/fetchMetrics.test.ts | 120 +++- .../observe/__tests__/formatEvents.test.ts | 161 +++--- .../observe/__tests__/formatMetrics.test.ts | 528 +++++++++++++----- .../src/observe/__tests__/metricNames.test.ts | 52 ++ packages/eas-cli/src/observe/fetchEvents.ts | 23 - packages/eas-cli/src/observe/fetchMetrics.ts | 11 +- packages/eas-cli/src/observe/formatEvents.ts | 21 +- packages/eas-cli/src/observe/formatMetrics.ts | 176 +++--- packages/eas-cli/src/observe/metricNames.ts | 35 ++ 15 files changed, 871 insertions(+), 448 deletions(-) create mode 100644 packages/eas-cli/src/observe/__tests__/metricNames.test.ts create mode 100644 packages/eas-cli/src/observe/metricNames.ts diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index b31eaf7bf3..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" }, @@ -214,9 +217,6 @@ "update": { "description": "manage individual updates" }, - "observe": { - "description": "monitor app performance metrics" - }, "webhook": { "description": "manage webhooks" }, diff --git a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts index 9d1b66303f..c5d3b07199 100644 --- a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -86,12 +86,7 @@ describe(ObserveEvents, () => { 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', - ]); + const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']); await command.runAsync(); const options = mockFetchObserveEventsAsync.mock.calls[0][2]; diff --git a/packages/eas-cli/src/commands/observe/events.ts b/packages/eas-cli/src/commands/observe/events.ts index 0f308fed6c..5b26d98ed1 100644 --- a/packages/eas-cli/src/commands/observe/events.ts +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -8,9 +8,9 @@ import { DEFAULT_EVENTS_LIMIT, type EventsOrderPreset, fetchObserveEventsAsync, - resolveMetricName, 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'; @@ -96,14 +96,11 @@ export default class ObserveEvents extends EasCommand { if (flags['days-from-now']) { endTime = new Date().toISOString(); - startTime = new Date( - Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000 - ).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(); + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); } const platform = flags.platform diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts index 923485a347..907c49893a 100644 --- a/packages/eas-cli/src/commands/observe/metrics.ts +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -12,7 +12,15 @@ import { fetchObserveMetricsAsync, validateDateFlag, } from '../../observe/fetchMetrics'; -import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../observe/formatMetrics'; +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 { @@ -24,7 +32,13 @@ export default class ObserveMetrics extends EasCommand { options: ['android', 'ios'], }), metric: Flags.string({ - description: 'Metric name to display (can be specified multiple times)', + 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({ @@ -68,12 +82,13 @@ export default class ObserveMetrics extends EasCommand { validateDateFlag(flags.end, '--end'); } - const metricNames = flags.metric?.length ? flags.metric : DEFAULT_METRICS; + 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(); + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); const platformFilter = flags.platform ? flags.platform === 'android' @@ -111,11 +126,17 @@ export default class ObserveMetrics extends EasCommand { endTime ); + const argumentsStat = flags.stat?.length + ? Array.from(new Set(flags.stat.map(resolveStatKey))) + : undefined; + if (flags.json) { - printJsonOnlyOutput(buildObserveMetricsJson(builds, metricsMap, metricNames)); + 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)); + 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 index 7ce5b43517..aeb247944d 100644 --- a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -76,10 +76,7 @@ export const ObserveQuery = { graphqlClient .query( gql` - query AppObserveTimeSeries( - $appId: String! - $input: AppObserveTimeSeriesInput! - ) { + query AppObserveTimeSeries($appId: String!, $input: AppObserveTimeSeriesInput!) { app { byId(appId: $appId) { id @@ -93,6 +90,10 @@ export const ObserveQuery = { min max median + average + p80 + p90 + p99 } } } @@ -131,12 +132,7 @@ export const ObserveQuery = { byId(appId: $appId) { id observe { - events( - filter: $filter - first: $first - after: $after - orderBy: $orderBy - ) { + events(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { pageInfo { hasNextPage hasPreviousPage diff --git a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts index 0e565eb188..4ca17c7a29 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -1,6 +1,10 @@ -import { AppObserveEventsOrderByDirection, AppObserveEventsOrderByField, AppObservePlatform } from '../../graphql/generated'; +import { + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, +} from '../../graphql/generated'; import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; -import { fetchObserveEventsAsync, resolveMetricName, resolveOrderBy } from '../fetchEvents'; +import { fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents'; jest.mock('../../graphql/queries/ObserveQuery'); @@ -34,43 +38,6 @@ describe(resolveOrderBy, () => { }); }); -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(fetchObserveEventsAsync, () => { const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync); const mockGraphqlClient = {} as any; @@ -87,7 +54,10 @@ describe(fetchObserveEventsAsync, () => { await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, limit: 10, startTime: '2025-01-01T00:00:00.000Z', endTime: '2025-03-01T00:00:00.000Z', @@ -102,7 +72,10 @@ describe(fetchObserveEventsAsync, () => { endTime: '2025-03-01T00:00:00.000Z', }, first: 10, - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, }); }); @@ -114,18 +87,24 @@ describe(fetchObserveEventsAsync, () => { await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + 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, - }), - })); + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + platform: AppObservePlatform.Ios, + }), + }) + ); }); it('includes appVersion in filter when provided', async () => { @@ -136,18 +115,24 @@ describe(fetchObserveEventsAsync, () => { await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + 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', - }), - })); + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + appVersion: '1.2.0', + }), + }) + ); }); it('includes appUpdateId in filter when updateId is provided', async () => { @@ -158,18 +143,24 @@ describe(fetchObserveEventsAsync, () => { await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + 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', - }), - })); + 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 () => { @@ -180,7 +171,10 @@ describe(fetchObserveEventsAsync, () => { await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, limit: 10, startTime: '2025-01-01T00:00:00.000Z', endTime: '2025-03-01T00:00:00.000Z', @@ -217,7 +211,10 @@ describe(fetchObserveEventsAsync, () => { const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { metricName: 'expo.app_startup.tti', - orderBy: { field: AppObserveEventsOrderByField.MetricValue, direction: AppObserveEventsOrderByDirection.Desc }, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, limit: 10, startTime: '2025-01-01T00:00:00.000Z', endTime: '2025-03-01T00:00:00.000Z', diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts index 1e692e9280..d2ed0758c6 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -16,22 +16,47 @@ 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 }, - }]; + 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 }, - }]; + 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 []; }); @@ -57,11 +82,21 @@ describe('fetchObserveMetricsAsync', () => { 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, }); }); @@ -90,13 +125,24 @@ describe('fetchObserveMetricsAsync', () => { 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 }, - }]; + 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( @@ -115,6 +161,11 @@ describe('fetchObserveMetricsAsync', () => { 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); @@ -136,13 +187,24 @@ 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 }, - }]); + 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, diff --git a/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts index b4a43d1ea0..2c9877bc3b 100644 --- a/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts @@ -1,59 +1,54 @@ -import { AppObserveEvent } from "../../graphql/generated"; -import { - buildObserveEventsJson, - buildObserveEventsTable, -} from "../formatEvents"; - -function createMockEvent( - overrides: Partial = {}, -): AppObserveEvent { +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", + __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", + 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", () => { + it('formats events into aligned columns', () => { const events = [ createMockEvent({ - metricName: "expo.app_startup.tti", + 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", + 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", + 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", + appVersion: '1.1.0', + appBuildNumber: '38', + deviceOs: 'Android', + deviceOsVersion: '14', + deviceModel: 'Pixel 8', + countryCode: 'PL', + timestamp: '2025-01-14T08:15:00.000Z', }), ]; @@ -68,60 +63,60 @@ TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 `); }); - it("returns yellow warning for empty array", () => { + it('returns yellow warning for empty array', () => { const output = buildObserveEventsTable([]); - expect(output).toContain("No events found."); + expect(output).toContain('No events found.'); }); - it("uses short names for known metrics", () => { + it('uses short names for known metrics', () => { const events = [ - createMockEvent({ metricName: "expo.app_startup.cold_launch_time" }), + createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }), createMockEvent({ - id: "evt-2", - metricName: "expo.app_startup.warm_launch_time", + id: 'evt-2', + metricName: 'expo.app_startup.warm_launch_time', }), - createMockEvent({ id: "evt-3", metricName: "expo.app_startup.ttr" }), + createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }), createMockEvent({ - id: "evt-4", - metricName: "expo.app_startup.bundle_load_time", + 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"); + expect(output).toContain('Cold Launch'); + expect(output).toContain('Warm Launch'); + expect(output).toContain('TTR'); + expect(output).toContain('Bundle Load'); }); - it("shows - for null countryCode", () => { + 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 lines = output.split('\n'); const dataLine = lines[2]; // header, separator, first data row - expect(dataLine).toContain("-"); + expect(dataLine).toContain('-'); }); }); describe(buildObserveEventsJson, () => { - it("maps event to JSON shape with all relevant fields", () => { + it('maps event to JSON shape with all relevant fields', () => { const events = [ createMockEvent({ - id: "evt-1", - metricName: "expo.app_startup.tti", + 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", + 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', }), ]; @@ -129,23 +124,23 @@ describe(buildObserveEventsJson, () => { expect(result).toEqual([ { - id: "evt-1", - metricName: "expo.app_startup.tti", + 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", + 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", () => { + it('handles null optional fields', () => { const events = [ createMockEvent({ countryCode: null, @@ -159,7 +154,7 @@ describe(buildObserveEventsJson, () => { expect(result[0].sessionId).toBeNull(); }); - it("returns empty array for empty input", () => { + 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 index 34fdc1917b..0cde36ae9f 100644 --- a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -1,14 +1,14 @@ +import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated'; import { - AppPlatform, - BuildPriority, - BuildStatus, -} from "../../graphql/generated"; -import { + DEFAULT_STATS_JSON, + DEFAULT_STATS_TABLE, ObserveMetricsMap, buildObserveMetricsJson, buildObserveMetricsTable, makeMetricsKey, -} from "../formatMetrics"; + resolveStatKey, + type MetricValues, +} from '../formatMetrics'; function createMockBuild(overrides: { id: string; @@ -30,23 +30,23 @@ function createBuildFragment(overrides: { gitCommitHash?: string | null; }) { return { - __typename: "Build" as const, + __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", + 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", + sdkVersion: '52.0.0', + runtimeVersion: '1.0.0', + gitCommitHash: overrides.gitCommitHash ?? 'abc1234567890', + gitCommitMessage: 'test commit', initialQueuePosition: null, queuePosition: null, estimatedWaitTimeLeftSeconds: null, @@ -60,258 +60,530 @@ function createBuildFragment(overrides: { initiatingActor: null, logFiles: [], project: { - __typename: "App" as const, - id: "project-id", - name: "test-app", - slug: "test-app", - ownerAccount: { id: "account-id", name: "test-owner" }, + __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 makeMetricValues( - min: number | null, - median: number | null, - max: number | null, -) { - return { min, median, max }; +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", () => { + it('formats builds grouped by version with min, median, max columns', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, - appVersion: "1.2.0", - gitCommitHash: "aaa1111222233334444", + appVersion: '1.2.0', + gitCommitHash: 'aaa1111222233334444', }), createMockBuild({ - id: "build-2", + id: 'build-2', platform: AppPlatform.Ios, - appVersion: "1.2.0", - gitCommitHash: "bbb2222333344445555", + appVersion: '1.2.0', + gitCommitHash: 'bbb2222333344445555', }), createMockBuild({ - id: "build-3", + id: 'build-3', platform: AppPlatform.Android, - appVersion: "1.1.0", - gitCommitHash: "ccc3333444455556666", + appVersion: '1.1.0', + gitCommitHash: 'ccc3333444455556666', }), ]; const metricsMap: ObserveMetricsMap = new Map(); - const iosKey = makeMetricsKey("1.2.0", AppPlatform.Ios); + const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios); metricsMap.set( iosKey, new Map([ - ["expo.app_startup.cold_launch_time", makeMetricValues(0.05, 0.2, 0.8)], - ["expo.app_startup.tti", makeMetricValues(0.03, 0.12, 0.5)], - ]), + [ + '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); + const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android); metricsMap.set( androidKey, new Map([ - ["expo.app_startup.cold_launch_time", makeMetricValues(0.06, 0.25, 0.9)], - ["expo.app_startup.tti", makeMetricValues(0.04, 0.15, 0.6)], - ]), + [ + '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); + 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 Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max ------------ -------- ------------ ---------------- --------------- --------------- --------------- ------- ------- ------- -1.2.0 iOS Jan 15, 2025 aaa1111, bbb2222 0.05s 0.20s 0.80s 0.03s 0.12s 0.50s -1.1.0 Android Jan 15, 2025 ccc3333 0.06s 0.25s 0.90s 0.04s 0.15s 0.60s " +"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", () => { + it('shows - for builds with no matching observe data', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, - appVersion: "2.0.0", + appVersion: '2.0.0', }), ]; - const output = buildObserveMetricsTable(builds, new Map(), DEFAULT_METRICS); + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); expect(output).toMatchInlineSnapshot(` -"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max ------------ -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- -2.0.0 iOS Jan 15, 2025 abc1234 - - - - - - " +"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", () => { + it('shows - for builds with null appVersion', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, appVersion: null, }), ]; - const output = buildObserveMetricsTable(builds, new Map(), DEFAULT_METRICS); + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); expect(output).toMatchInlineSnapshot(` -"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max ------------ -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- -- iOS Jan 15, 2025 abc1234 - - - - - - " +"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); + 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", () => { + it('shows the latest build date when multiple builds share a version', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, - appVersion: "1.0.0", - completedAt: "2025-01-10T10:00:00.000Z", - gitCommitHash: "aaa1111222233334444", + appVersion: '1.0.0', + completedAt: '2025-01-10T10:00:00.000Z', + gitCommitHash: 'aaa1111222233334444', }), createMockBuild({ - id: "build-2", + id: 'build-2', platform: AppPlatform.Ios, - appVersion: "1.0.0", - completedAt: "2025-01-20T10:00:00.000Z", - gitCommitHash: "bbb2222333344445555", + appVersion: '1.0.0', + completedAt: '2025-01-20T10:00:00.000Z', + gitCommitHash: 'bbb2222333344445555', }), ]; - const output = buildObserveMetricsTable(builds, new Map(), DEFAULT_METRICS); + 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"); + 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", () => { + it('deduplicates commit hashes for same version+platform', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, - appVersion: "1.0.0", - gitCommitHash: "same123456789", + appVersion: '1.0.0', + gitCommitHash: 'same123456789', }), createMockBuild({ - id: "build-2", + id: 'build-2', platform: AppPlatform.Ios, - appVersion: "1.0.0", - gitCommitHash: "same123456789", + appVersion: '1.0.0', + gitCommitHash: 'same123456789', }), ]; - const output = buildObserveMetricsTable(builds, new Map(), DEFAULT_METRICS); + const output = buildObserveMetricsTable( + builds, + new Map(), + DEFAULT_METRICS, + DEFAULT_STATS_TABLE + ); expect(output).toMatchInlineSnapshot(` -"App Version Platform Last Build Commits Cold Launch Min Cold Launch Med Cold Launch Max TTI Min TTI Med TTI Max ------------ -------- ------------ ------- --------------- --------------- --------------- ------- ------- ------- -1.0.0 iOS Jan 15, 2025 same123 - - - - - - " +"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", () => { + it('produces grouped JSON with min, median, max per metric', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, - appVersion: "1.0.0", - gitCommitHash: "aaa1111222233334444", + appVersion: '1.0.0', + gitCommitHash: 'aaa1111222233334444', }), createMockBuild({ - id: "build-2", + id: 'build-2', platform: AppPlatform.Ios, - appVersion: "1.0.0", - gitCommitHash: "bbb2222333344445555", + appVersion: '1.0.0', + gitCommitHash: 'bbb2222333344445555', }), ]; const metricsMap: ObserveMetricsMap = new Map(); - const key = makeMetricsKey("1.0.0", AppPlatform.Ios); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); metricsMap.set( key, - new Map([["expo.app_startup.tti", makeMetricValues(0.02, 0.1, 0.4)]]), + new Map([ + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })], + ]) ); - const result = buildObserveMetricsJson(builds, metricsMap, [ - "expo.app_startup.tti", - ]); + const result = buildObserveMetricsJson( + builds, + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); expect(result).toHaveLength(1); expect(result[0]).toEqual({ - appVersion: "1.0.0", + appVersion: '1.0.0', platform: AppPlatform.Ios, - lastBuildDate: "2025-01-15T10:00:00.000Z", - commits: ["aaa1111", "bbb2222"], + lastBuildDate: '2025-01-15T10:00:00.000Z', + commits: ['aaa1111', 'bbb2222'], metrics: { - "expo.app_startup.tti": { min: 0.02, median: 0.1, max: 0.4 }, + '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", () => { + it('produces null min/median/max when no observe data matches', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Android, - appVersion: "3.0.0", + appVersion: '3.0.0', }), ]; - const result = buildObserveMetricsJson(builds, new Map(), [ - "expo.app_startup.tti", - ]); + 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 }, + '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", () => { + it('produces null appVersion when build has no appVersion', () => { const builds = [ createMockBuild({ - id: "build-1", + id: 'build-1', platform: AppPlatform.Ios, appVersion: null, }), ]; - const result = buildObserveMetricsJson(builds, new Map(), [ - "expo.app_startup.tti", - ]); + 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({ + 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"); + 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 index 3db2eb4b22..5fc8a5ea12 100644 --- a/packages/eas-cli/src/observe/fetchEvents.ts +++ b/packages/eas-cli/src/observe/fetchEvents.ts @@ -1,5 +1,4 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; -import { EasCommandError } from '../commandUtils/errors'; import { AppObserveEvent, AppObserveEventsFilter, @@ -15,16 +14,6 @@ export type EventsOrderPreset = 'slowest' | 'fastest' | 'newest' | 'oldest'; export const DEFAULT_EVENTS_LIMIT = 10; -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 function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy { switch (preset) { case 'slowest': @@ -50,18 +39,6 @@ export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrder } } -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(', ')}` - ); -} - interface FetchObserveEventsOptions { metricName: string; orderBy: AppObserveEventsOrderBy; diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts index 03ca9a700a..443ec87c09 100644 --- a/packages/eas-cli/src/observe/fetchMetrics.ts +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -1,10 +1,6 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { EasCommandError } from '../commandUtils/errors'; -import { - AppObservePlatform, - AppObserveVersionMarker, - AppPlatform, -} from '../graphql/generated'; +import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated'; import { ObserveQuery } from '../graphql/queries/ObserveQuery'; import Log from '../log'; import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics'; @@ -100,6 +96,11 @@ export async function fetchObserveMetricsAsync( 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); } diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts index cb8af4d2b5..e9370b54ba 100644 --- a/packages/eas-cli/src/observe/formatEvents.ts +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -1,18 +1,7 @@ import chalk from 'chalk'; import { AppObserveEvent } from '../graphql/generated'; - -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', -}; - -function getMetricDisplayName(metricName: string): string { - return METRIC_SHORT_NAMES[metricName] ?? metricName; -} +import { getMetricDisplayName } from './metricNames'; function formatTimestamp(isoString: string): string { const date = new Date(isoString); @@ -57,15 +46,11 @@ export function buildObserveEventsTable(events: AppObserveEvent[]): string { formatTimestamp(event.timestamp), ]); - const colWidths = headers.map((h, i) => - Math.max(h.length, ...rows.map(r => r[i].length)) - ); + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); - const dataLines = rows.map(row => - row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ') - ); + const 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/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts index 34ef400dae..6bafaf9265 100644 --- a/packages/eas-cli/src/observe/formatMetrics.ts +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -1,23 +1,74 @@ -import chalk from "chalk"; - -import { AppPlatform, BuildFragment } from "../graphql/generated"; -import { appPlatformDisplayNames } from "../platform"; +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', +}; -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 const STAT_DISPLAY_NAMES: Record = { + min: 'Min', + max: 'Max', + median: 'Med', + average: 'Avg', + p80: 'P80', + p90: 'P90', + p99: 'P99', + eventCount: 'Count', }; -function getMetricDisplayName(metricName: string): string { - return METRIC_SHORT_NAMES[metricName] ?? metricName; +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 formatSeconds(value: number | null | undefined): string { +function formatStatValue(stat: StatisticKey, value: number | null | undefined): string { if (value == null) { - return "-"; + return '-'; + } + if (stat === 'eventCount') { + return String(value); } return `${value.toFixed(2)}s`; } @@ -26,19 +77,18 @@ 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< - ObserveMetricsKey, - Map // metricName → { min, max, median } ->; +export type ObserveMetricsMap = Map>; -export function makeMetricsKey( - appVersion: string, - platform: AppPlatform, -): ObserveMetricsKey { +export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey { return `${appVersion}:${platform}`; } @@ -53,7 +103,7 @@ function groupBuildsByVersion(builds: BuildFragment[]): VersionGroup[] { const grouped = new Map(); for (const build of builds) { - const version = build.appVersion ?? "-"; + const version = build.appVersion ?? '-'; const key = makeMetricsKey(version, build.platform); if (!grouped.has(key)) { @@ -65,10 +115,7 @@ function groupBuildsByVersion(builds: BuildFragment[]): VersionGroup[] { }); } else { const group = grouped.get(key)!; - if ( - build.completedAt && - (!group.lastBuildDate || build.completedAt > group.lastBuildDate) - ) { + if (build.completedAt && (!group.lastBuildDate || build.completedAt > group.lastBuildDate)) { group.lastBuildDate = build.completedAt; } } @@ -83,21 +130,17 @@ function groupBuildsByVersion(builds: BuildFragment[]): VersionGroup[] { function formatDate(dateString: string | null): string { if (!dateString) { - return "-"; + return '-'; } const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', }); } -export interface MetricValuesJson { - min: number | null; - median: number | null; - max: number | null; -} +export type MetricValuesJson = Partial>; export interface ObserveMetricsVersionResult { appVersion: string | null; @@ -111,28 +154,26 @@ 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; + 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); - metrics[metricName] = { - min: values?.min ?? null, - median: values?.median ?? null, - max: values?.max ?? null, - }; + const statValues: MetricValuesJson = {}; + for (const stat of stats) { + statValues[stat] = values?.[stat] ?? null; + } + metrics[metricName] = statValues; } return { - appVersion: group.appVersion !== "-" ? group.appVersion : null, + appVersion: group.appVersion !== '-' ? group.appVersion : null, platform: group.platform, lastBuildDate: group.lastBuildDate, commits: [...group.commits], @@ -145,50 +186,47 @@ export function buildObserveMetricsTable( builds: BuildFragment[], metricsMap: ObserveMetricsMap, metricNames: string[], + stats: StatisticKey[] ): string { - const results = buildObserveMetricsJson(builds, metricsMap, metricNames); + const results = buildObserveMetricsJson(builds, metricsMap, metricNames, stats); if (results.length === 0) { - return chalk.yellow("No finished builds found."); + return chalk.yellow('No finished builds found.'); } - const fixedHeaders = ["App Version", "Platform", "Last Build", "Commits"]; + const fixedHeaders = ['App Version', 'Platform', 'Last Build', 'Commits']; const metricHeaders: string[] = []; for (const m of metricNames) { const name = getMetricDisplayName(m); - metricHeaders.push(`${name} Min`, `${name} Med`, `${name} Max`); + for (const stat of stats) { + metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`); + } } const headers = [...fixedHeaders, ...metricHeaders]; - const rows: string[][] = results.map((result) => { + const rows: string[][] = results.map(result => { const metricCells: string[] = []; for (const m of metricNames) { const values = result.metrics[m]; - metricCells.push( - formatSeconds(values?.min ?? null), - formatSeconds(values?.median ?? null), - formatSeconds(values?.max ?? null), - ); + for (const stat of stats) { + metricCells.push(formatStatValue(stat, values?.[stat] ?? null)); + } } return [ - result.appVersion ?? "-", + result.appVersion ?? '-', appPlatformDisplayNames[result.platform], formatDate(result.lastBuildDate), - result.commits.length > 0 ? result.commits.join(", ") : "-", + 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 colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); - const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(" "); - const separatorLine = colWidths.map((w) => "-".repeat(w)).join(" "); - const dataLines = rows.map((row) => - row.map((cell, i) => cell.padEnd(colWidths[i])).join(" "), - ); + const 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"); + 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; +}