diff --git a/CHANGELOG.md b/CHANGELOG.md index 74de427db6..13ad58baca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon)) - [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon)) +- Add `eas observe:metrics` command for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)) +- Add `eas observe:events` command for monitoring app performance metrics. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index ec5baec603..0af0827ef6 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -6788,22 +6788,6 @@ "name": "createFcmV1Credential", "description": "Create a GoogleServiceAccountKeyEntity to store credential and\nconnect it with an edge from AndroidAppCredential", "args": [ - { - "name": "accountId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "androidAppCredentialsId", "description": null, @@ -8904,6 +8888,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "appStoreConnectApp", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appStoreUrl", "description": "ios.appStoreUrl field from most recent classic update manifest", @@ -15967,6 +15963,43 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "remoteAppStoreConnectApps", + "description": null, + "args": [ + { + "name": "bundleIdentifier", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "roles", "description": null, @@ -16321,6 +16354,305 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "description": null, + "fields": [ + { + "name": "app", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "App", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appStoreConnectApiKey", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectApiKey", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remoteAppStoreConnectApp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEventTypes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AppStoreConnectAppInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "appId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appStoreConnectApiKeyId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppStoreConnectAppMutation", + "description": null, + "fields": [ + { + "name": "createAppStoreConnectApp", + "description": "Create an App Store Connect app for an Expo app.", + "args": [ + { + "name": "appStoreConnectAppInput", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AppStoreConnectAppInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectApp", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deleteAppStoreConnectApp", + "description": "Delete an App Store Connect app by ID.", + "args": [ + { + "name": "appStoreConnectAppId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeleteAppStoreConnectAppResult", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "AppStoreConnectUserRole", @@ -30359,6 +30691,33 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeleteAppStoreConnectAppResult", + "description": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DeleteAppleDeviceResult", @@ -33294,7 +33653,7 @@ "fields": [ { "name": "completeMessage", - "description": "Mark a message as completed (sets completedAt and optionally updates metadata with tokens)", + "description": "Mark a message as completed (sets completedAt and creates billing ledger entry)", "args": [ { "name": "id", @@ -36225,7 +36584,7 @@ "deprecationReason": null }, { - "name": "AppStoreConnectAppWebhookEntity", + "name": "AppStoreConnectAppEntity", "description": null, "isDeprecated": false, "deprecationReason": null @@ -41072,6 +41431,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "lastDeletionAttemptTime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "metadata", "description": null, @@ -41375,6 +41746,39 @@ "ofType": null } }, + "isDeprecated": true, + "deprecationReason": "Use scheduleGitHubRepositoryDeletion instead" + }, + { + "name": "scheduleGitHubRepositoryDeletion", + "description": "Delete a GitHub repository by ID in the background", + "args": [ + { + "name": "githubRepositoryId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BackgroundJobReceipt", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null } @@ -49873,6 +50277,73 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "RemoteAppStoreConnectApp", + "description": null, + "fields": [ + { + "name": "appStoreIconUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ascAppIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bundleIdentifier", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "RequestMethod", @@ -51160,6 +51631,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "appStoreConnectApp", + "description": "Mutations for App Store Connect apps.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppStoreConnectAppMutation", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appVersion", "description": "Mutations that modify an AppVersion", @@ -54455,6 +54942,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -55476,6 +55979,65 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "SizeBreakdownCategory", + "description": null, + "fields": [ + { + "name": "assetCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "category", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalBytes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Snack", @@ -61425,6 +61987,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "sizeBreakdownByCategory", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SizeBreakdownCategory", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "totalUniqueUsers", "description": null, @@ -63062,6 +63648,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -63889,6 +64491,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "isStaffModeEnabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "lastDeletionAttemptTime", "description": null, @@ -75758,6 +76376,30 @@ "inputFields": null, "interfaces": null, "enumValues": [ + { + "name": "APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "EAS_SUBMIT", "description": null, diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 8e8b17d4db..c674371cd4 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -204,6 +204,9 @@ "metadata": { "description": "manage store configuration" }, + "observe": { + "description": "monitor app performance metrics" + }, "project": { "description": "manage project" }, diff --git a/packages/eas-cli/src/commandUtils/pagination.ts b/packages/eas-cli/src/commandUtils/pagination.ts index 77f617bf45..24cb7e4ea6 100644 --- a/packages/eas-cli/src/commandUtils/pagination.ts +++ b/packages/eas-cli/src/commandUtils/pagination.ts @@ -35,12 +35,16 @@ const parseFlagInputStringAsInteger = ( export const getLimitFlagWithCustomValues = ({ defaultTo, limit, + description, }: { defaultTo: number; limit: number; + description?: string; }): Interfaces.OptionFlag => Flags.integer({ - description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, + description: + description ?? + `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`, // eslint-disable-next-line async-protect/async-suffix parse: async input => parseFlagInputStringAsInteger(input, 'limit', 1, limit), }); diff --git a/packages/eas-cli/src/commands/observe/__tests__/events.test.ts b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts new file mode 100644 index 0000000000..dbbe7fcbce --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/events.test.ts @@ -0,0 +1,236 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { AppObservePlatform } from '../../../graphql/generated'; +import { fetchObserveEventsAsync } from '../../../observe/fetchEvents'; +import { buildObserveEventsJson } from '../../../observe/formatEvents'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveEvents from '../events'; + +jest.mock('../../../observe/fetchEvents'); +jest.mock('../../../observe/formatEvents', () => ({ + buildObserveEventsTable: jest.fn().mockReturnValue('table'), + buildObserveEventsJson: jest.fn().mockReturnValue({}), +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); +const mockBuildObserveEventsJson = jest.mocked(buildObserveEventsJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +describe(ObserveEvents, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + }); + + function createCommand(argv: string[]): ObserveEvents { + const command = new ObserveEvents(argv, mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + it('uses --days-from-now to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--days-from-now', '7']); + await command.runAsync(); + + expect(mockFetchObserveEventsAsync).toHaveBeenCalledTimes(1); + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(options.startTime).toBe('2025-06-08T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses DEFAULT_DAYS_BACK (60 days) when neither --days-from-now nor --start/--end are provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-04-16T12:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('defaults endTime to now when only --start is provided', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--metric', 'tti', '--start', '2025-01-01T00:00:00.000Z']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-06-15T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('rejects --days-from-now combined with --start', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--start', + '2025-01-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('passes --limit to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--limit', '42']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.limit).toBe(42); + }); + + it('passes --after cursor to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--after', 'cursor-xyz']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.after).toBe('cursor-xyz'); + }); + + it('does not pass after when --after flag is not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options).not.toHaveProperty('after'); + }); + + it('rejects --days-from-now combined with --end', async () => { + const command = createCommand([ + '--metric', + 'tti', + '--days-from-now', + '7', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('passes --platform ios to fetchObserveEventsAsync as AppObservePlatform.Ios', async () => { + const command = createCommand(['--metric', 'tti', '--platform', 'ios']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Ios); + }); + + it('passes --platform android to fetchObserveEventsAsync as AppObservePlatform.Android', async () => { + const command = createCommand(['--metric', 'tti', '--platform', 'android']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBe(AppObservePlatform.Android); + }); + + it('passes --app-version to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--app-version', '2.1.0']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.appVersion).toBe('2.1.0'); + }); + + it('passes --update-id to fetchObserveEventsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--update-id', 'update-xyz']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.updateId).toBe('update-xyz'); + }); + + it('does not pass platform, appVersion, or updateId when flags are not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.platform).toBeUndefined(); + expect(options.appVersion).toBeUndefined(); + expect(options.updateId).toBeUndefined(); + }); + + it('calls enableJsonOutput and printJsonOnlyOutput when --json is provided', async () => { + const mockEvents = [ + { + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + }, + ]; + mockFetchObserveEventsAsync.mockResolvedValue({ + events: mockEvents as any, + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const command = createCommand(['--metric', 'tti', '--json', '--non-interactive']); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveEventsJson).toHaveBeenCalledWith( + mockEvents, + expect.objectContaining({ hasNextPage: false }) + ); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); + }); + + it('does not call enableJsonOutput when --json is not provided', async () => { + const command = createCommand(['--metric', 'tti']); + await command.runAsync(); + + expect(mockEnableJsonOutput).not.toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts new file mode 100644 index 0000000000..ccbc6e4385 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/metrics.test.ts @@ -0,0 +1,208 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { AppPlatform } from '../../../graphql/generated'; +import { fetchObserveMetricsAsync, validateDateFlag } from '../../../observe/fetchMetrics'; +import { buildObserveMetricsJson, buildObserveMetricsTable } from '../../../observe/formatMetrics'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveMetrics from '../metrics'; + +jest.mock('../../../observe/fetchMetrics', () => { + const actual = jest.requireActual('../../../observe/fetchMetrics'); + return { + ...actual, + fetchObserveMetricsAsync: jest.fn(), + }; +}); +jest.mock('../../../observe/formatMetrics', () => ({ + ...jest.requireActual('../../../observe/formatMetrics'), + buildObserveMetricsTable: jest.fn().mockReturnValue('table'), + buildObserveMetricsJson: jest.fn().mockReturnValue([]), +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockFetchObserveMetricsAsync = jest.mocked(fetchObserveMetricsAsync); +const mockBuildObserveMetricsTable = jest.mocked(buildObserveMetricsTable); +const mockBuildObserveMetricsJson = jest.mocked(buildObserveMetricsJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +describe(ObserveMetrics, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveMetricsAsync.mockResolvedValue(new Map()); + }); + + function createCommand(argv: string[]): ObserveMetrics { + const command = new ObserveMetrics(argv, mockConfig); + // @ts-expect-error getContextAsync is a protected method + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + it('fetches metrics with default parameters (both platforms)', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand([]); + await command.runAsync(); + + expect(mockFetchObserveMetricsAsync).toHaveBeenCalledTimes(1); + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]); + + jest.useRealTimers(); + }); + + it('queries only Android when --platform android is passed', async () => { + const command = createCommand(['--platform', 'android']); + await command.runAsync(); + + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Android]); + }); + + it('queries only iOS when --platform ios is passed', async () => { + const command = createCommand(['--platform', 'ios']); + await command.runAsync(); + + const platforms = mockFetchObserveMetricsAsync.mock.calls[0][3]; + expect(platforms).toEqual([AppPlatform.Ios]); + }); + + it('resolves --metric aliases before passing to fetchObserveMetricsAsync', async () => { + const command = createCommand(['--metric', 'tti', '--metric', 'cold_launch']); + await command.runAsync(); + + const metricNames = mockFetchObserveMetricsAsync.mock.calls[0][2]; + expect(metricNames).toEqual(['expo.app_startup.tti', 'expo.app_startup.cold_launch_time']); + }); + + it('uses default time range (60 days back) when no --start/--end flags', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand([]); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(startTime).toBe('2025-04-16T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('uses explicit --start and --end when provided', async () => { + const command = createCommand([ + '--start', + '2025-01-01T00:00:00.000Z', + '--end', + '2025-02-01T00:00:00.000Z', + ]); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('passes resolved --stat flags to buildObserveMetricsTable', async () => { + const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'p90', + 'eventCount', + ]); + }); + + it('deduplicates --stat flags that resolve to the same key', async () => { + const command = createCommand(['--stat', 'median', '--stat', 'median']); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'median', + ]); + }); + + it('uses --days-from-now to compute start/end time range', async () => { + const now = new Date('2025-06-15T12:00:00.000Z'); + jest.useFakeTimers({ now }); + + const command = createCommand(['--days-from-now', '7']); + await command.runAsync(); + + const startTime = mockFetchObserveMetricsAsync.mock.calls[0][4]; + const endTime = mockFetchObserveMetricsAsync.mock.calls[0][5]; + expect(endTime).toBe('2025-06-15T12:00:00.000Z'); + expect(startTime).toBe('2025-06-08T12:00:00.000Z'); + + jest.useRealTimers(); + }); + + it('rejects --days-from-now combined with --start', async () => { + const command = createCommand(['--days-from-now', '7', '--start', '2025-01-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('rejects --days-from-now combined with --end', async () => { + const command = createCommand(['--days-from-now', '7', '--end', '2025-02-01T00:00:00.000Z']); + + await expect(command.runAsync()).rejects.toThrow(); + }); + + it('uses default stats when --stat is not provided', async () => { + const command = createCommand([]); + await command.runAsync(); + + expect(mockBuildObserveMetricsTable).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'median', + 'eventCount', + ]); + }); + + it('passes resolved --stat flags to buildObserveMetricsJson when --json is used', async () => { + const command = createCommand([ + '--json', + '--non-interactive', + '--stat', + 'min', + '--stat', + 'average', + ]); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockBuildObserveMetricsJson).toHaveBeenCalledWith(expect.any(Map), expect.any(Array), [ + 'min', + 'average', + ]); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalled(); + }); +}); + +describe(validateDateFlag, () => { + it('throws on invalid --start date', () => { + expect(() => validateDateFlag('not-a-date', '--start')).toThrow( + 'Invalid --start date: "not-a-date"' + ); + }); + + it('throws on invalid --end date', () => { + expect(() => validateDateFlag('also-bad', '--end')).toThrow('Invalid --end date: "also-bad"'); + }); + + it('accepts valid ISO date in --start', () => { + expect(() => validateDateFlag('2025-01-01', '--start')).not.toThrow(); + }); +}); diff --git a/packages/eas-cli/src/commands/observe/events.ts b/packages/eas-cli/src/commands/observe/events.ts new file mode 100644 index 0000000000..a410a7d058 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/events.ts @@ -0,0 +1,140 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; +import { AppObservePlatform } from '../../graphql/generated'; +import Log from '../../log'; +import { + EventsOrderPreset, + fetchObserveEventsAsync, + resolveOrderBy, +} from '../../observe/fetchEvents'; +import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames'; +import { validateDateFlag } from '../../observe/fetchMetrics'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +const DEFAULT_EVENTS_LIMIT = 10; +const DEFAULT_DAYS_BACK = 60; + +export default class ObserveEvents extends EasCommand { + static override description = 'display individual app performance events ordered by metric value'; + + static override flags = { + metric: Flags.option({ + description: 'Metric to query (full name or alias)', + required: true, + options: [ + ...Object.keys(METRIC_ALIASES), + ...Object.keys(METRIC_ALIASES).map(key => METRIC_ALIASES[key]), + ], + })(), + sort: Flags.option({ + description: 'Sort order for events', + options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()), + required: false, + default: EventsOrderPreset.Oldest.valueOf().toLowerCase(), + })(), + platform: Flags.option({ + description: 'Filter by platform', + options: Object.values(AppObservePlatform).map(s => s.toLowerCase()), + })(), + after: Flags.string({ + description: + 'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.', + }), + limit: getLimitFlagWithCustomValues({ + defaultTo: DEFAULT_EVENTS_LIMIT, + limit: 100, + }), + start: Flags.string({ + description: 'Start of time range (ISO date)', + exclusive: ['days-from-now'], + }), + end: Flags.string({ + description: 'End of time range (ISO date)', + exclusive: ['days-from-now'], + }), + 'days-from-now': Flags.integer({ + description: 'Show events from the last N days (mutually exclusive with --start/--end)', + min: 1, + exclusive: ['start', 'end'], + }), + 'app-version': Flags.string({ + description: 'Filter by app version', + }), + 'update-id': Flags.string({ + description: 'Filter by EAS update ID', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveEvents); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveEvents, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricName = resolveMetricName(flags.metric); + const orderBy = resolveOrderBy(flags.sort as EventsOrderPreset); + + let startTime: string; + let endTime: string; + + if (flags['days-from-now']) { + endTime = new Date().toISOString(); + startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString(); + } else { + endTime = flags.end ?? new Date().toISOString(); + startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + } + + const platform = flags.platform + ? flags.platform === 'android' + ? AppObservePlatform.Android + : AppObservePlatform.Ios + : undefined; + + const { events, pageInfo } = await fetchObserveEventsAsync(graphqlClient, projectId, { + metricName, + orderBy, + limit: flags.limit ?? DEFAULT_EVENTS_LIMIT, + ...(flags.after && { after: flags.after }), + startTime, + endTime, + platform, + appVersion: flags['app-version'], + updateId: flags['update-id'], + }); + + if (flags.json) { + printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo)); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveEventsTable(events, pageInfo)); + } + } +} diff --git a/packages/eas-cli/src/commands/observe/metrics.ts b/packages/eas-cli/src/commands/observe/metrics.ts new file mode 100644 index 0000000000..c686ff0be7 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/metrics.ts @@ -0,0 +1,142 @@ +import { Flags } from '@oclif/core'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { AppObservePlatform, AppPlatform } from '../../graphql/generated'; +import Log from '../../log'; +import { fetchObserveMetricsAsync, validateDateFlag } from '../../observe/fetchMetrics'; +import { + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + resolveStatKey, +} from '../../observe/formatMetrics'; +import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +const DEFAULT_METRICS = [ + 'expo.app_startup.cold_launch_time', + 'expo.app_startup.warm_launch_time', + 'expo.app_startup.tti', + 'expo.app_startup.ttr', + 'expo.app_startup.bundle_load_time', +]; + +const DEFAULT_DAYS_BACK = 60; + +const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +export default class ObserveMetrics extends EasCommand { + static override description = 'display app performance metrics grouped by app version'; + + static override flags = { + platform: Flags.option({ + description: 'Filter by platform', + options: Object.values(AppObservePlatform).map(s => s.toLowerCase()), + })(), + metric: Flags.option({ + description: 'Metric name to display (can be specified multiple times).', + multiple: true, + options: Object.keys(METRIC_ALIASES), + })(), + stat: Flags.option({ + description: 'Statistic to display per metric (can be specified multiple times)', + multiple: true, + options: DEFAULT_STATS_JSON, + })(), + start: Flags.string({ + description: 'Start of time range for metrics data (ISO date).', + exclusive: ['days-from-now'], + }), + end: Flags.string({ + description: 'End of time range for metrics data (ISO date).', + exclusive: ['days-from-now'], + }), + 'days-from-now': Flags.integer({ + description: 'Show metrics from the last N days (mutually exclusive with --start/--end)', + min: 1, + exclusive: ['start', 'end'], + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(ObserveMetrics); + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ObserveMetrics, { + nonInteractive: flags['non-interactive'], + }); + + if (flags.json) { + enableJsonOutput(); + } else { + Log.warn('EAS Observe is in preview and subject to breaking changes.'); + } + + if (flags.start) { + validateDateFlag(flags.start, '--start'); + } + if (flags.end) { + validateDateFlag(flags.end, '--end'); + } + + const metricNames = flags.metric?.length + ? flags.metric.map(resolveMetricName) + : DEFAULT_METRICS; + + let startTime: string; + let endTime: string; + + if (flags['days-from-now']) { + endTime = new Date().toISOString(); + startTime = new Date(Date.now() - flags['days-from-now'] * 24 * 60 * 60 * 1000).toISOString(); + } else { + endTime = flags.end ?? new Date().toISOString(); + startTime = + flags.start ?? new Date(Date.now() - DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000).toISOString(); + } + + const platforms: AppPlatform[] = flags.platform + ? [flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios] + : [AppPlatform.Android, AppPlatform.Ios]; + + const metricsMap = await fetchObserveMetricsAsync( + graphqlClient, + projectId, + metricNames, + platforms, + startTime, + endTime + ); + + const argumentsStat = flags.stat?.length + ? Array.from(new Set(flags.stat.map(resolveStatKey))) + : undefined; + + if (flags.json) { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON; + printJsonOnlyOutput(buildObserveMetricsJson(metricsMap, metricNames, stats)); + } else { + const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE; + Log.addNewLineIfNone(); + Log.log(buildObserveMetricsTable(metricsMap, metricNames, stats)); + } + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 82fccef79d..30bfa7f30c 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -1116,7 +1116,6 @@ export type AndroidAppCredentialsMutationCreateAndroidAppCredentialsArgs = { export type AndroidAppCredentialsMutationCreateFcmV1CredentialArgs = { - accountId: Scalars['ID']['input']; androidAppCredentialsId: Scalars['String']['input']; credential: Scalars['String']['input']; }; @@ -1358,6 +1357,7 @@ export type App = Project & { activityTimelineProjectActivities: Array; /** Android app credentials for the project */ androidAppCredentials: Array; + appStoreConnectApp?: Maybe; /** * ios.appStoreUrl field from most recent classic update manifest * @deprecated Classic updates have been deprecated. @@ -2376,10 +2376,16 @@ export type AppStoreConnectApiKey = { keyIdentifier: Scalars['String']['output']; keyP8: Scalars['String']['output']; name?: Maybe; + remoteAppStoreConnectApps: Array; roles?: Maybe>; updatedAt: Scalars['DateTime']['output']; }; + +export type AppStoreConnectApiKeyRemoteAppStoreConnectAppsArgs = { + bundleIdentifier?: InputMaybe; +}; + export type AppStoreConnectApiKeyInput = { appleTeamId?: InputMaybe; issuerIdentifier: Scalars['String']['input']; @@ -2430,6 +2436,43 @@ export type AppStoreConnectApiKeyUpdateInput = { appleTeamId?: InputMaybe; }; +export type AppStoreConnectApp = { + __typename?: 'AppStoreConnectApp'; + app: App; + appStoreConnectApiKey: AppStoreConnectApiKey; + ascAppIdentifier: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + remoteAppStoreConnectApp: RemoteAppStoreConnectApp; + updatedAt: Scalars['DateTime']['output']; + webhookEventTypes: Array; + webhookIdentifier: Scalars['ID']['output']; +}; + +export type AppStoreConnectAppInput = { + appId: Scalars['ID']['input']; + appStoreConnectApiKeyId: Scalars['ID']['input']; + ascAppIdentifier: Scalars['String']['input']; +}; + +export type AppStoreConnectAppMutation = { + __typename?: 'AppStoreConnectAppMutation'; + /** Create an App Store Connect app for an Expo app. */ + createAppStoreConnectApp: AppStoreConnectApp; + /** Delete an App Store Connect app by ID. */ + deleteAppStoreConnectApp: DeleteAppStoreConnectAppResult; +}; + + +export type AppStoreConnectAppMutationCreateAppStoreConnectAppArgs = { + appStoreConnectAppInput: AppStoreConnectAppInput; +}; + + +export type AppStoreConnectAppMutationDeleteAppStoreConnectAppArgs = { + appStoreConnectAppId: Scalars['ID']['input']; +}; + export enum AppStoreConnectUserRole { AccessToReports = 'ACCESS_TO_REPORTS', AccountHolder = 'ACCOUNT_HOLDER', @@ -4312,6 +4355,11 @@ export type DeleteAndroidKeystoreResult = { id: Scalars['ID']['output']; }; +export type DeleteAppStoreConnectAppResult = { + __typename?: 'DeleteAppStoreConnectAppResult'; + id: Scalars['ID']['output']; +}; + export type DeleteAppleDeviceResult = { __typename?: 'DeleteAppleDeviceResult'; id: Scalars['ID']['output']; @@ -4788,7 +4836,7 @@ export type EchoMessageEdge = { export type EchoMessageMutation = { __typename?: 'EchoMessageMutation'; - /** Mark a message as completed (sets completedAt and optionally updates metadata with tokens) */ + /** Mark a message as completed (sets completedAt and creates billing ledger entry) */ completeMessage: EchoMessage; /** Create a new message */ createMessage: EchoMessage; @@ -5223,7 +5271,7 @@ export enum EntityTypeName { AndroidKeystoreEntity = 'AndroidKeystoreEntity', AppEntity = 'AppEntity', AppStoreConnectApiKeyEntity = 'AppStoreConnectApiKeyEntity', - AppStoreConnectAppWebhookEntity = 'AppStoreConnectAppWebhookEntity', + AppStoreConnectAppEntity = 'AppStoreConnectAppEntity', AppleDeviceEntity = 'AppleDeviceEntity', AppleDistributionCertificateEntity = 'AppleDistributionCertificateEntity', AppleProvisioningProfileEntity = 'AppleProvisioningProfileEntity', @@ -5880,6 +5928,7 @@ export type GitHubRepository = { githubRepositoryIdentifier: Scalars['Int']['output']; githubRepositoryUrl?: Maybe; id: Scalars['ID']['output']; + lastDeletionAttemptTime?: Maybe; metadata: GitHubRepositoryMetadata; nodeIdentifier: Scalars['String']['output']; }; @@ -5903,8 +5952,13 @@ export type GitHubRepositoryMutation = { createAndConfigureRepository: BackgroundJobReceipt; /** Create a GitHub repository for an App */ createGitHubRepository: GitHubRepository; - /** Delete a GitHub repository by ID */ + /** + * Delete a GitHub repository by ID + * @deprecated Use scheduleGitHubRepositoryDeletion instead + */ deleteGitHubRepository: GitHubRepository; + /** Delete a GitHub repository by ID in the background */ + scheduleGitHubRepositoryDeletion: BackgroundJobReceipt; }; @@ -5927,6 +5981,11 @@ export type GitHubRepositoryMutationDeleteGitHubRepositoryArgs = { githubRepositoryId: Scalars['ID']['input']; }; + +export type GitHubRepositoryMutationScheduleGitHubRepositoryDeletionArgs = { + githubRepositoryId: Scalars['ID']['input']; +}; + export type GitHubRepositoryOwner = { __typename?: 'GitHubRepositoryOwner'; avatarUrl: Scalars['String']['output']; @@ -7091,6 +7150,14 @@ export type PublishUpdateGroupInput = { updateInfoGroup?: InputMaybe; }; +export type RemoteAppStoreConnectApp = { + __typename?: 'RemoteAppStoreConnectApp'; + appStoreIconUrl?: Maybe; + ascAppIdentifier: Scalars['String']['output']; + bundleIdentifier: Scalars['String']['output']; + name?: Maybe; +}; + export enum RequestMethod { Delete = 'DELETE', Get = 'GET', @@ -7270,6 +7337,8 @@ export type RootMutation = { app?: Maybe; /** Mutations that modify an App Store Connect Api Key */ appStoreConnectApiKey: AppStoreConnectApiKeyMutation; + /** Mutations for App Store Connect apps. */ + appStoreConnectApp: AppStoreConnectAppMutation; /** Mutations that modify an AppVersion */ appVersion: AppVersionMutation; /** Mutations that modify an Identifier for an iOS App */ @@ -7705,6 +7774,7 @@ export type SsoUser = Actor & UserActor & { /** @deprecated No longer supported */ industry?: Maybe; isExpoAdmin: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -7867,6 +7937,13 @@ export type SentryProjectMutationDeleteSentryProjectArgs = { sentryProjectId: Scalars['ID']['input']; }; +export type SizeBreakdownCategory = { + __typename?: 'SizeBreakdownCategory'; + assetCount: Scalars['Int']['output']; + category: Scalars['String']['output']; + totalBytes: Scalars['Float']['output']; +}; + export type Snack = Project & { __typename?: 'Snack'; /** Description of the Snack */ @@ -8674,6 +8751,7 @@ export type UpdateInsights = { cumulativeAverageMetrics: CumulativeAverageMetrics; cumulativeMetrics: CumulativeMetrics; id: Scalars['ID']['output']; + sizeBreakdownByCategory: Array; totalUniqueUsers: Scalars['Int']['output']; }; @@ -8903,6 +8981,7 @@ export type User = Actor & UserActor & { /** @deprecated No longer supported */ isLegacy: Scalars['Boolean']['output']; isSecondFactorAuthenticationEnabled: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -9010,6 +9089,7 @@ export type UserActor = { /** @deprecated No longer supported */ industry?: Maybe; isExpoAdmin: Scalars['Boolean']['output']; + isStaffModeEnabled: Scalars['Boolean']['output']; lastDeletionAttemptTime?: Maybe; lastName?: Maybe; /** @deprecated No longer supported */ @@ -10587,6 +10667,10 @@ export type WorkflowRunTimeRangeInput = { }; export enum WorkflowRunTriggerEventType { + AppStoreConnectAppVersionStateChanged = 'APP_STORE_CONNECT_APP_VERSION_STATE_CHANGED', + AppStoreConnectBetaFeedbackSubmitted = 'APP_STORE_CONNECT_BETA_FEEDBACK_SUBMITTED', + AppStoreConnectBuildUploadStateChanged = 'APP_STORE_CONNECT_BUILD_UPLOAD_STATE_CHANGED', + AppStoreConnectExternalBetaStateChanged = 'APP_STORE_CONNECT_EXTERNAL_BETA_STATE_CHANGED', EasSubmit = 'EAS_SUBMIT', ExpoLaunch = 'EXPO_LAUNCH', GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED', @@ -11791,6 +11875,25 @@ export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{ export type GoogleServiceAccountKeyByIdQuery = { __typename?: 'RootQuery', googleServiceAccountKey: { __typename?: 'GoogleServiceAccountKeyQuery', byId: { __typename?: 'GoogleServiceAccountKey', id: string, keyJson: string } } }; +export type AppObserveTimeSeriesQueryVariables = Exact<{ + appId: Scalars['String']['input']; + input: AppObserveTimeSeriesInput; +}>; + + +export type AppObserveTimeSeriesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', timeSeries: { __typename?: 'AppObserveTimeSeries', versionMarkers: Array<{ __typename?: 'AppObserveVersionMarker', appVersion: string, eventCount: number, firstSeenAt: any, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> } } } } }; + +export type AppObserveEventsQueryVariables = Exact<{ + appId: Scalars['String']['input']; + filter?: InputMaybe; + first?: InputMaybe; + after?: InputMaybe; + orderBy?: InputMaybe; +}>; + + +export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } }; + export type GetAssetMetadataQueryVariables = Exact<{ storageKeys: Array | Scalars['String']['input']; }>; @@ -11971,6 +12074,10 @@ export type EnvironmentVariableWithSecretFragment = { __typename?: 'EnvironmentV export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } }; +export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', versionMarkers: Array<{ __typename?: 'AppObserveVersionMarker', appVersion: string, eventCount: number, firstSeenAt: any, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }; + +export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string }; + export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string }; export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> }; 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..3c388710dc --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -0,0 +1,164 @@ +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'; +import { print } from 'graphql'; +import { AppObserveTimeSeriesFragmentNode, AppObserveEventFragmentNode } from '../types/Observe'; + +type AppObserveTimeSeriesQuery = { + app: { + byId: { + id: string; + observe: { + timeSeries: { + versionMarkers: AppObserveVersionMarker[]; + }; + }; + }; + }; +}; + +type AppObserveTimeSeriesQueryVariables = { + appId: string; + input: Pick; +}; + +type AppObserveEventsQuery = { + app: { + byId: { + id: string; + observe: { + events: { + pageInfo: PageInfo; + edges: Array<{ + cursor: string; + node: AppObserveEvent; + }>; + }; + }; + }; + }; +}; + +type AppObserveEventsQueryVariables = { + appId: string; + filter?: AppObserveEventsFilter; + first?: number; + after?: string; + orderBy?: AppObserveEventsOrderBy; +}; + +export const ObserveQuery = { + async timeSeriesVersionMarkersAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + metricName, + platform, + startTime, + endTime, + }: { + appId: string; + metricName: string; + platform: AppObservePlatform; + startTime: string; + endTime: string; + } + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveTimeSeries( + $appId: String! + $input: AppObserveTimeSeriesInput! + ) { + app { + byId(appId: $appId) { + id + observe { + timeSeries(input: $input) { + ...AppObserveTimeSeriesFragment + } + } + } + } + } + ${print(AppObserveTimeSeriesFragmentNode)} + `, + { + appId, + input: { metricName, platform, startTime, endTime }, + } + ) + .toPromise() + ); + + return data.app.byId.observe.timeSeries.versionMarkers; + }, + + async eventsAsync( + graphqlClient: ExpoGraphqlClient, + variables: AppObserveEventsQueryVariables + ): Promise<{ events: AppObserveEvent[]; pageInfo: PageInfo }> { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveEvents( + $appId: String! + $filter: AppObserveEventsFilter + $first: Int + $after: String + $orderBy: AppObserveEventsOrderBy + ) { + app { + byId(appId: $appId) { + id + observe { + events( + filter: $filter + first: $first + after: $after + orderBy: $orderBy + ) { + pageInfo { + hasNextPage + hasPreviousPage + endCursor + } + edges { + cursor + node { + id + ...AppObserveEventFragment + } + } + } + } + } + } + } + ${print(AppObserveEventFragmentNode)} + `, + 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/graphql/types/Observe.ts b/packages/eas-cli/src/graphql/types/Observe.ts new file mode 100644 index 0000000000..e0e83669b4 --- /dev/null +++ b/packages/eas-cli/src/graphql/types/Observe.ts @@ -0,0 +1,37 @@ +import gql from 'graphql-tag'; + +export const AppObserveTimeSeriesFragmentNode = gql` + fragment AppObserveTimeSeriesFragment on AppObserveTimeSeries { + versionMarkers { + appVersion + eventCount + firstSeenAt + statistics { + min + max + median + average + p80 + p90 + p99 + } + } + } +`; + +export const AppObserveEventFragmentNode = gql` + fragment AppObserveEventFragment on AppObserveEvent { + id + metricName + metricValue + timestamp + appVersion + appBuildNumber + deviceModel + deviceOs + deviceOsVersion + countryCode + sessionId + easClientId + } +`; 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..338316fad1 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchEvents.test.ts @@ -0,0 +1,276 @@ +import { + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, +} from '../../graphql/generated'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { EventsOrderPreset, fetchObserveEventsAsync, resolveOrderBy } from '../fetchEvents'; + +jest.mock('../../graphql/queries/ObserveQuery'); + +describe(resolveOrderBy, () => { + it('maps "slowest" to METRIC_VALUE DESC', () => { + expect(resolveOrderBy(EventsOrderPreset.Slowest)).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('maps "fastest" to METRIC_VALUE ASC', () => { + expect(resolveOrderBy(EventsOrderPreset.Fastest)).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Asc, + }); + }); + + it('maps "newest" to TIMESTAMP DESC', () => { + expect(resolveOrderBy(EventsOrderPreset.Newest)).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('maps "oldest" to TIMESTAMP ASC', () => { + expect(resolveOrderBy(EventsOrderPreset.Oldest)).toEqual({ + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Asc, + }); + }); +}); + +describe(fetchObserveEventsAsync, () => { + const mockEventsAsync = jest.mocked(ObserveQuery.eventsAsync); + const mockGraphqlClient = {} as any; + + beforeEach(() => { + mockEventsAsync.mockClear(); + }); + + it('calls ObserveQuery.eventsAsync with assembled filter', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(mockEventsAsync).toHaveBeenCalledTimes(1); + expect(mockEventsAsync).toHaveBeenCalledWith(mockGraphqlClient, { + appId: 'app-123', + filter: { + metricName: 'expo.app_startup.tti', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }, + first: 10, + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + }); + }); + + it('includes platform in filter when provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 5, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + platform: AppObservePlatform.Ios, + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + platform: AppObservePlatform.Ios, + }), + }) + ); + }); + + it('includes appVersion in filter when provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + appVersion: '1.2.0', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + appVersion: '1.2.0', + }), + }) + ); + }); + + it('includes appUpdateId in filter when updateId is provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + updateId: 'update-abc-123', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ + filter: expect.objectContaining({ + appUpdateId: 'update-abc-123', + }), + }) + ); + }); + + it('omits platform, appVersion, and appUpdateId from filter when not provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + const calledFilter = mockEventsAsync.mock.calls[0][1].filter; + expect(calledFilter).not.toHaveProperty('platform'); + expect(calledFilter).not.toHaveProperty('appVersion'); + expect(calledFilter).not.toHaveProperty('appUpdateId'); + }); + + it('returns events and pageInfo from the query result', async () => { + const mockEvents = [ + { + __typename: 'AppObserveEvent' as const, + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + }, + ]; + mockEventsAsync.mockResolvedValue({ + events: mockEvents as any, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + endCursor: 'cursor-1', + }, + }); + + const result = await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(result.events).toHaveLength(1); + expect(result.events[0].metricValue).toBe(1.23); + expect(result.pageInfo.hasNextPage).toBe(true); + }); + + it('passes after cursor to ObserveQuery.eventsAsync', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + after: 'cursor-abc', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + expect(mockEventsAsync).toHaveBeenCalledWith( + mockGraphqlClient, + expect.objectContaining({ first: 10, after: 'cursor-abc' }) + ); + }); + + it('omits after when not provided', async () => { + mockEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + await fetchObserveEventsAsync(mockGraphqlClient, 'app-123', { + metricName: 'expo.app_startup.tti', + orderBy: { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }, + limit: 10, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-03-01T00:00:00.000Z', + }); + + const calledVars = mockEventsAsync.mock.calls[0][1]; + expect(calledVars).not.toHaveProperty('after'); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts new file mode 100644 index 0000000000..5dba0fc010 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts @@ -0,0 +1,222 @@ +import { AppObservePlatform, AppPlatform } from '../../graphql/generated'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { makeMetricsKey } from '../formatMetrics'; +import { fetchObserveMetricsAsync } from '../fetchMetrics'; + +jest.mock('../../graphql/queries/ObserveQuery'); + +describe('fetchObserveMetricsAsync', () => { + const mockTimeSeriesMarkers = jest.mocked(ObserveQuery.timeSeriesVersionMarkersAsync); + const mockGraphqlClient = {} as any; + + beforeEach(() => { + mockTimeSeriesMarkers.mockClear(); + }); + + it('fans out queries for each metric+platform combo and assembles metricsMap', async () => { + mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName, platform }) => { + if (metricName === 'expo.app_startup.tti' && platform === AppObservePlatform.Ios) { + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '1.0.0', + eventCount: 100, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.01, + max: 0.5, + median: 0.1, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.48, + }, + }, + ]; + } + if ( + metricName === 'expo.app_startup.cold_launch_time' && + platform === AppObservePlatform.Ios + ) { + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '1.0.0', + eventCount: 80, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.05, + max: 1.2, + median: 0.3, + average: 0.4, + p80: 0.8, + p90: 1.0, + p99: 1.15, + }, + }, + ]; + } + return []; + }); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti', 'expo.app_startup.cold_launch_time'], + [AppPlatform.Ios], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should have called the query twice (2 metrics x 1 platform) + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + // Verify metricsMap was assembled correctly + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + expect(metricsMap.has(key)).toBe(true); + + const metricsForVersion = metricsMap.get(key)!; + expect(metricsForVersion.get('expo.app_startup.tti')).toEqual({ + min: 0.01, + max: 0.5, + median: 0.1, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.48, + eventCount: 100, + }); + expect(metricsForVersion.get('expo.app_startup.cold_launch_time')).toEqual({ + min: 0.05, + max: 1.2, + median: 0.3, + average: 0.4, + p80: 0.8, + p90: 1.0, + p99: 1.15, + eventCount: 80, + }); + }); + + it('fans out across multiple platforms', async () => { + mockTimeSeriesMarkers.mockResolvedValue([]); + + await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Ios, AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // 1 metric x 2 platforms = 2 calls + expect(mockTimeSeriesMarkers).toHaveBeenCalledTimes(2); + + const platforms = mockTimeSeriesMarkers.mock.calls.map(call => call[1].platform); + expect(platforms).toContain(AppObservePlatform.Ios); + expect(platforms).toContain(AppObservePlatform.Android); + }); + + it('handles partial failures gracefully — successful queries still populate metricsMap', async () => { + mockTimeSeriesMarkers.mockImplementation(async (_client, { metricName }) => { + if (metricName === 'bad.metric') { + throw new Error('Unknown metric'); + } + return [ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '2.0.0', + eventCount: 50, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + }, + }, + ]; + }); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti', 'bad.metric'], + [AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // Should not throw; the good metric should still be in the map + const key = makeMetricsKey('2.0.0', AppPlatform.Android); + expect(metricsMap.has(key)).toBe(true); + expect(metricsMap.get(key)!.get('expo.app_startup.tti')).toEqual({ + min: 0.1, + max: 0.9, + median: 0.5, + average: 0.5, + p80: 0.7, + p90: 0.8, + p99: 0.85, + eventCount: 50, + }); + // The bad metric should not be present + expect(metricsMap.get(key)!.has('bad.metric')).toBe(false); + }); + + it('returns empty map when all queries fail', async () => { + mockTimeSeriesMarkers.mockRejectedValue(new Error('Network error')); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Ios], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + expect(metricsMap.size).toBe(0); + }); + + it('maps AppObservePlatform back to AppPlatform correctly in metricsMap keys', async () => { + mockTimeSeriesMarkers.mockResolvedValue([ + { + __typename: 'AppObserveVersionMarker' as const, + appVersion: '3.0.0', + eventCount: 10, + firstSeenAt: '2025-01-01T00:00:00.000Z', + statistics: { + __typename: 'AppObserveVersionMarkerStatistics' as const, + min: 0.1, + max: 0.2, + median: 0.15, + average: 0.15, + p80: 0.18, + p90: 0.19, + p99: 0.2, + }, + }, + ]); + + const metricsMap = await fetchObserveMetricsAsync( + mockGraphqlClient, + 'project-123', + ['expo.app_startup.tti'], + [AppPlatform.Android], + '2025-01-01T00:00:00.000Z', + '2025-03-01T00:00:00.000Z' + ); + + // The key should use AppPlatform (ANDROID), not AppObservePlatform + expect(metricsMap.has('3.0.0:ANDROID')).toBe(true); + expect(metricsMap.has('3.0.0:Android' as any)).toBe(false); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts new file mode 100644 index 0000000000..05f33b6483 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatEvents.test.ts @@ -0,0 +1,189 @@ +import { AppObserveEvent, PageInfo } from '../../graphql/generated'; +import { buildObserveEventsJson, buildObserveEventsTable } from '../formatEvents'; + +function createMockEvent(overrides: Partial = {}): AppObserveEvent { + return { + __typename: 'AppObserveEvent', + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:30:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + appIdentifier: 'com.example.app', + appName: 'ExampleApp', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + eventBatchId: 'batch-1', + tags: {}, + ...overrides, + }; +} + +const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false }; +const withNextPage: PageInfo = { + hasNextPage: true, + hasPreviousPage: false, + endCursor: 'cursor-abc', +}; + +describe(buildObserveEventsTable, () => { + it('formats events into aligned columns', () => { + const events = [ + createMockEvent({ + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.2.0', + appBuildNumber: '42', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + deviceModel: 'iPhone 15', + countryCode: 'US', + timestamp: '2025-01-15T10:30:00.000Z', + }), + createMockEvent({ + id: 'evt-2', + metricName: 'expo.app_startup.tti', + metricValue: 0.85, + appVersion: '1.1.0', + appBuildNumber: '38', + deviceOs: 'Android', + deviceOsVersion: '14', + deviceModel: 'Pixel 8', + countryCode: 'PL', + timestamp: '2025-01-14T08:15:00.000Z', + }), + ]; + + const output = buildObserveEventsTable(events, noNextPage); + + // Escape codes are included, because the header is bolded. + expect(output).toMatchInlineSnapshot(` +"Metric Value App Version Platform Device Country Timestamp  +------ ----- ----------- ---------- --------- ------- ---------------------- +TTI 1.23s 1.2.0 (42) iOS 17.0 iPhone 15 US Jan 15, 2025, 10:30 AM +TTI 0.85s 1.1.0 (38) Android 14 Pixel 8 PL Jan 14, 2025, 08:15 AM" +`); + }); + + it('returns yellow warning for empty array', () => { + const output = buildObserveEventsTable([], noNextPage); + expect(output).toContain('No events found.'); + }); + + it('uses short names for known metrics', () => { + const events = [ + createMockEvent({ metricName: 'expo.app_startup.cold_launch_time' }), + createMockEvent({ + id: 'evt-2', + metricName: 'expo.app_startup.warm_launch_time', + }), + createMockEvent({ id: 'evt-3', metricName: 'expo.app_startup.ttr' }), + createMockEvent({ + id: 'evt-4', + metricName: 'expo.app_startup.bundle_load_time', + }), + ]; + + const output = buildObserveEventsTable(events, noNextPage); + + expect(output).toContain('Cold Launch'); + expect(output).toContain('Warm Launch'); + expect(output).toContain('TTR'); + expect(output).toContain('Bundle Load'); + }); + + it('shows - for null countryCode', () => { + const events = [createMockEvent({ countryCode: null })]; + const output = buildObserveEventsTable(events, noNextPage); + + // The country column should contain a dash + const lines = output.split('\n'); + const dataLine = lines[2]; // header, separator, first data row + expect(dataLine).toContain('-'); + }); + + it('appends next page hint when hasNextPage is true', () => { + const events = [createMockEvent()]; + const output = buildObserveEventsTable(events, withNextPage); + + expect(output).toContain('Next page: --after cursor-abc'); + }); + + it('does not append next page hint when hasNextPage is false', () => { + const events = [createMockEvent()]; + const output = buildObserveEventsTable(events, noNextPage); + + expect(output).not.toContain('Next page'); + }); +}); + +describe(buildObserveEventsJson, () => { + it('maps event to JSON shape with all relevant fields and pageInfo', () => { + const events = [ + createMockEvent({ + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + timestamp: '2025-01-15T10:30:00.000Z', + }), + ]; + + const result = buildObserveEventsJson(events, withNextPage); + + expect(result).toEqual({ + events: [ + { + id: 'evt-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + appVersion: '1.0.0', + appBuildNumber: '42', + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-1', + easClientId: 'client-1', + timestamp: '2025-01-15T10:30:00.000Z', + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-abc', + }, + }); + }); + + it('handles null optional fields', () => { + const events = [ + createMockEvent({ + countryCode: null, + sessionId: null, + }), + ]; + + const result = buildObserveEventsJson(events, noNextPage); + + expect(result.events[0].countryCode).toBeNull(); + expect(result.events[0].sessionId).toBeNull(); + }); + + it('returns empty events array for empty input', () => { + const result = buildObserveEventsJson([], noNextPage); + expect(result.events).toEqual([]); + expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts new file mode 100644 index 0000000000..2a33596b04 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/formatMetrics.test.ts @@ -0,0 +1,352 @@ +import { AppPlatform } from '../../graphql/generated'; +import { + ObserveMetricsMap, + StatisticKey, + buildObserveMetricsJson, + buildObserveMetricsTable, + makeMetricsKey, + resolveStatKey, + type MetricValues, +} from '../formatMetrics'; + +const DEFAULT_STATS_TABLE: StatisticKey[] = ['median', 'eventCount']; +const DEFAULT_STATS_JSON: StatisticKey[] = [ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', +]; + +const DEFAULT_METRICS = ['expo.app_startup.cold_launch_time', 'expo.app_startup.tti']; + +function makeMetricValueWithDefaults(overrides: Partial): MetricValues { + return { + min: 0.1, + median: 0.3, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 100, + ...overrides, + }; +} + +describe(buildObserveMetricsTable, () => { + it('formats metrics grouped by version with metric columns', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const iosKey = makeMetricsKey('1.2.0', AppPlatform.Ios); + metricsMap.set( + iosKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.35, eventCount: 110 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.32123, eventCount: 90 })], + ]) + ); + + const androidKey = makeMetricsKey('1.1.0', AppPlatform.Android); + metricsMap.set( + androidKey, + new Map([ + [ + 'expo.app_startup.cold_launch_time', + makeMetricValueWithDefaults({ median: 0.25, eventCount: 120 }), + ], + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 1.12111, eventCount: 100 })], + ]) + ); + + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + + // The header is bolded, thus the escape characters in the snapshot + expect(output).toMatchInlineSnapshot(` +"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- --------------- ----------------- ------- --------- +1.2.0 iOS 0.35s 110 1.32s 90 +1.1.0 Android 0.25s 120 1.12s 100 " +`); + }); + + it('shows - for versions with no matching metric data', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('2.0.0', AppPlatform.Ios); + metricsMap.set(key, new Map()); + + const output = buildObserveMetricsTable(metricsMap, DEFAULT_METRICS, DEFAULT_STATS_TABLE); + + expect(output).toMatchInlineSnapshot(` +"App Version Platform Cold Launch Med Cold Launch Count TTI Med TTI Count +----------- -------- --------------- ----------------- ------- --------- +2.0.0 iOS - - - - " +`); + }); + + it('returns message when no metrics data found', () => { + const output = buildObserveMetricsTable(new Map(), DEFAULT_METRICS, DEFAULT_STATS_TABLE); + expect(output).toMatchInlineSnapshot(`"No metrics data found."`); + }); +}); + +describe(buildObserveMetricsJson, () => { + it('produces JSON with all stats per metric', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + ['expo.app_startup.tti', makeMetricValueWithDefaults({ median: 0.12, eventCount: 90 })], + ]) + ); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + appVersion: '1.0.0', + platform: AppPlatform.Ios, + metrics: { + 'expo.app_startup.tti': { + min: 0.1, + median: 0.12, + max: 1.1, + average: 0.5, + p80: 0.8, + p90: 0.9, + p99: 1.0, + eventCount: 90, + }, + }, + }); + }); + + it('produces null values when no observe data matches for a metric', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('3.0.0', AppPlatform.Android); + metricsMap.set(key, new Map()); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics).toEqual({ + 'expo.app_startup.tti': { + min: null, + median: null, + max: null, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + }); + }); +}); + +describe(makeMetricsKey, () => { + it('creates a key from version and platform', () => { + expect(makeMetricsKey('1.0.0', AppPlatform.Ios)).toBe('1.0.0:IOS'); + expect(makeMetricsKey('2.0.0', AppPlatform.Android)).toBe('2.0.0:ANDROID'); + }); +}); + +describe(resolveStatKey, () => { + it('resolves canonical stat names', () => { + expect(resolveStatKey('min')).toBe('min'); + expect(resolveStatKey('max')).toBe('max'); + expect(resolveStatKey('median')).toBe('median'); + expect(resolveStatKey('average')).toBe('average'); + expect(resolveStatKey('p80')).toBe('p80'); + expect(resolveStatKey('p90')).toBe('p90'); + expect(resolveStatKey('p99')).toBe('p99'); + expect(resolveStatKey('eventCount')).toBe('eventCount'); + }); + + it('resolves short aliases', () => { + expect(resolveStatKey('med')).toBe('median'); + expect(resolveStatKey('avg')).toBe('average'); + expect(resolveStatKey('count')).toBe('eventCount'); + expect(resolveStatKey('event_count')).toBe('eventCount'); + }); + + it('throws on unknown stat', () => { + expect(() => resolveStatKey('unknown')).toThrow('Unknown statistic: "unknown"'); + }); +}); + +describe('DEFAULT_STATS_TABLE', () => { + it('defaults to median, eventCount', () => { + expect(DEFAULT_STATS_TABLE).toEqual(['median', 'eventCount']); + }); +}); + +describe('DEFAULT_STATS_JSON', () => { + it('includes all stats', () => { + expect(DEFAULT_STATS_JSON).toEqual([ + 'min', + 'median', + 'max', + 'average', + 'p80', + 'p90', + 'p99', + 'eventCount', + ]); + }); +}); + +describe('custom stats parameter', () => { + it('table renders only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable( + metricsMap, + ['expo.app_startup.tti'], + ['p99', 'eventCount'] + ); + + expect(output).toContain('TTI P99'); + expect(output).toContain('TTI Count'); + expect(output).toContain('0.90s'); + expect(output).toContain('42'); + expect(output).not.toContain('TTI Min'); + expect(output).not.toContain('TTI Med'); + expect(output).not.toContain('TTI Max'); + }); + + it("table formats eventCount as integer without 's' suffix", () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: 100, + }, + ], + ]) + ); + + const output = buildObserveMetricsTable(metricsMap, ['expo.app_startup.tti'], ['eventCount']); + + expect(output).toContain('100'); + expect(output).not.toContain('100s'); + expect(output).not.toContain('100.00s'); + }); + + it('JSON includes only selected stats', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.01, + median: 0.1, + max: 0.5, + average: 0.15, + p80: 0.3, + p90: 0.4, + p99: 0.9, + eventCount: 42, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + ['p90', 'eventCount'] + ); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + p90: 0.4, + eventCount: 42, + }); + }); + + it('JSON uses default stats when not specified', () => { + const metricsMap: ObserveMetricsMap = new Map(); + const key = makeMetricsKey('1.0.0', AppPlatform.Ios); + metricsMap.set( + key, + new Map([ + [ + 'expo.app_startup.tti', + { + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }, + ], + ]) + ); + + const result = buildObserveMetricsJson( + metricsMap, + ['expo.app_startup.tti'], + DEFAULT_STATS_JSON + ); + + expect(result[0].metrics['expo.app_startup.tti']).toEqual({ + min: 0.02, + median: 0.1, + max: 0.4, + average: null, + p80: null, + p90: null, + p99: null, + eventCount: null, + }); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts new file mode 100644 index 0000000000..f989d22d70 --- /dev/null +++ b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts @@ -0,0 +1,52 @@ +import { getMetricDisplayName, resolveMetricName } from '../metricNames'; + +describe(resolveMetricName, () => { + it('resolves short alias "tti" to full metric name', () => { + expect(resolveMetricName('tti')).toBe('expo.app_startup.tti'); + }); + + it('resolves short alias "ttr" to full metric name', () => { + expect(resolveMetricName('ttr')).toBe('expo.app_startup.ttr'); + }); + + it('resolves short alias "cold_launch" to full metric name', () => { + expect(resolveMetricName('cold_launch')).toBe('expo.app_startup.cold_launch_time'); + }); + + it('resolves short alias "warm_launch" to full metric name', () => { + expect(resolveMetricName('warm_launch')).toBe('expo.app_startup.warm_launch_time'); + }); + + it('resolves short alias "bundle_load" to full metric name', () => { + expect(resolveMetricName('bundle_load')).toBe('expo.app_startup.bundle_load_time'); + }); + + it('passes through full metric names unchanged', () => { + expect(resolveMetricName('expo.app_startup.tti')).toBe('expo.app_startup.tti'); + expect(resolveMetricName('expo.app_startup.cold_launch_time')).toBe( + 'expo.app_startup.cold_launch_time' + ); + }); + + it('throws on unknown alias', () => { + expect(() => resolveMetricName('unknown_metric')).toThrow('Unknown metric: "unknown_metric"'); + }); + + it('passes through dot-containing custom metric names', () => { + expect(resolveMetricName('custom.metric.name')).toBe('custom.metric.name'); + }); +}); + +describe(getMetricDisplayName, () => { + it('returns short display name for known metrics', () => { + expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch'); + expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch'); + expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI'); + expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR'); + expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load'); + }); + + it('returns the full metric name for unknown metrics', () => { + expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name'); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchEvents.ts b/packages/eas-cli/src/observe/fetchEvents.ts new file mode 100644 index 0000000000..928dee7f3e --- /dev/null +++ b/packages/eas-cli/src/observe/fetchEvents.ts @@ -0,0 +1,83 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { + AppObserveEvent, + AppObserveEventsFilter, + AppObserveEventsOrderBy, + AppObserveEventsOrderByDirection, + AppObserveEventsOrderByField, + AppObservePlatform, + PageInfo, +} from '../graphql/generated'; +import { ObserveQuery } from '../graphql/queries/ObserveQuery'; + +export enum EventsOrderPreset { + Slowest = 'SLOWEST', + Fastest = 'FASTEST', + Newest = 'NEWEST', + Oldest = 'OLDEST', +} + +export function resolveOrderBy(preset: EventsOrderPreset): AppObserveEventsOrderBy { + switch (preset) { + case EventsOrderPreset.Slowest: + return { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }; + case EventsOrderPreset.Fastest: + return { + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Asc, + }; + case EventsOrderPreset.Newest: + return { + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Desc, + }; + case EventsOrderPreset.Oldest: + return { + field: AppObserveEventsOrderByField.Timestamp, + direction: AppObserveEventsOrderByDirection.Asc, + }; + } +} + +interface FetchObserveEventsOptions { + metricName: string; + orderBy: AppObserveEventsOrderBy; + limit: number; + after?: string; + startTime: string; + endTime: string; + platform?: AppObservePlatform; + appVersion?: string; + updateId?: string; +} + +interface FetchObserveEventsResult { + events: AppObserveEvent[]; + pageInfo: PageInfo; +} + +export async function fetchObserveEventsAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + options: FetchObserveEventsOptions +): Promise { + const filter: AppObserveEventsFilter = { + metricName: options.metricName, + startTime: options.startTime, + endTime: options.endTime, + ...(options.platform && { platform: options.platform }), + ...(options.appVersion && { appVersion: options.appVersion }), + ...(options.updateId && { appUpdateId: options.updateId }), + }; + + return await ObserveQuery.eventsAsync(graphqlClient, { + appId, + filter, + first: options.limit, + ...(options.after && { after: options.after }), + orderBy: options.orderBy, + }); +} diff --git a/packages/eas-cli/src/observe/fetchMetrics.ts b/packages/eas-cli/src/observe/fetchMetrics.ts new file mode 100644 index 0000000000..e1387c0625 --- /dev/null +++ b/packages/eas-cli/src/observe/fetchMetrics.ts @@ -0,0 +1,99 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasCommandError } from '../commandUtils/errors'; +import { AppObservePlatform, AppObserveVersionMarker, AppPlatform } from '../graphql/generated'; +import { ObserveQuery } from '../graphql/queries/ObserveQuery'; +import Log from '../log'; +import { MetricValues, ObserveMetricsMap, makeMetricsKey } from './formatMetrics'; + +const appPlatformToObservePlatform: Record = { + [AppPlatform.Android]: AppObservePlatform.Android, + [AppPlatform.Ios]: AppObservePlatform.Ios, +}; + +const observePlatformToAppPlatform: Record = { + [AppObservePlatform.Android]: AppPlatform.Android, + [AppObservePlatform.Ios]: AppPlatform.Ios, +}; + +interface ObserveQueryResult { + metricName: string; + platform: AppObservePlatform; + markers: AppObserveVersionMarker[]; +} + +export function validateDateFlag(value: string, flagName: string): void { + const parsed = new Date(value); + if (isNaN(parsed.getTime())) { + throw new EasCommandError( + `Invalid ${flagName} date: "${value}". Provide a valid ISO 8601 date (e.g. 2025-01-01).` + ); + } +} + +export async function fetchObserveMetricsAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + metricNames: string[], + platforms: AppPlatform[], + startTime: string, + endTime: string +): Promise { + const observeQueries: Promise[] = []; + + for (const metricName of metricNames) { + for (const appPlatform of platforms) { + const observePlatform = appPlatformToObservePlatform[appPlatform]; + observeQueries.push( + ObserveQuery.timeSeriesVersionMarkersAsync(graphqlClient, { + appId, + metricName, + platform: observePlatform, + startTime, + endTime, + }) + .then(markers => ({ + metricName, + platform: observePlatform, + markers, + })) + .catch(error => { + Log.warn( + `Failed to fetch observe data for metric "${metricName}" on ${observePlatform}: ${error.message}` + ); + return null; + }) + ); + } + } + + const observeResults = await Promise.all(observeQueries); + + const metricsMap: ObserveMetricsMap = new Map(); + + for (const result of observeResults) { + if (!result) { + continue; + } + const { metricName, platform, markers } = result; + const appPlatform = observePlatformToAppPlatform[platform]; + for (const marker of markers) { + const key = makeMetricsKey(marker.appVersion, appPlatform); + if (!metricsMap.has(key)) { + metricsMap.set(key, new Map()); + } + const values: MetricValues = { + min: marker.statistics.min, + max: marker.statistics.max, + median: marker.statistics.median, + average: marker.statistics.average, + p80: marker.statistics.p80, + p90: marker.statistics.p90, + p99: marker.statistics.p99, + eventCount: marker.eventCount, + }; + metricsMap.get(key)!.set(metricName, values); + } + } + + return metricsMap; +} diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts new file mode 100644 index 0000000000..4ab5448e3e --- /dev/null +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -0,0 +1,88 @@ +import chalk from 'chalk'; + +import { AppObserveEvent, PageInfo } from '../graphql/generated'; +import { getMetricDisplayName } from './metricNames'; + +function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export interface ObserveEventJson { + id: string; + metricName: string; + metricValue: number; + appVersion: string; + appBuildNumber: string; + deviceModel: string; + deviceOs: string; + deviceOsVersion: string; + countryCode: string | null; + sessionId: string | null; + easClientId: string; + timestamp: string; +} + +export function buildObserveEventsTable(events: AppObserveEvent[], pageInfo: PageInfo): string { + if (events.length === 0) { + return chalk.yellow('No events found.'); + } + + const headers = ['Metric', 'Value', 'App Version', 'Platform', 'Device', 'Country', 'Timestamp']; + + const rows: string[][] = events.map(event => [ + getMetricDisplayName(event.metricName), + `${event.metricValue.toFixed(2)}s`, + `${event.appVersion} (${event.appBuildNumber})`, + `${event.deviceOs} ${event.deviceOsVersion}`, + event.deviceModel, + event.countryCode ?? '-', + formatTimestamp(event.timestamp), + ]); + + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); + + const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); + const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); + const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')); + + const lines = [chalk.bold(headerLine), separatorLine, ...dataLines]; + + if (pageInfo.hasNextPage && pageInfo.endCursor) { + lines.push('', `Next page: --after ${pageInfo.endCursor}`); + } + + return lines.join('\n'); +} + +export function buildObserveEventsJson( + events: AppObserveEvent[], + pageInfo: PageInfo +): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } { + return { + events: events.map(event => ({ + id: event.id, + metricName: event.metricName, + metricValue: event.metricValue, + appVersion: event.appVersion, + appBuildNumber: event.appBuildNumber, + deviceModel: event.deviceModel, + deviceOs: event.deviceOs, + deviceOsVersion: event.deviceOsVersion, + countryCode: event.countryCode ?? null, + sessionId: event.sessionId ?? null, + easClientId: event.easClientId, + timestamp: event.timestamp, + })), + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + endCursor: pageInfo.endCursor ?? null, + }, + }; +} diff --git a/packages/eas-cli/src/observe/formatMetrics.ts b/packages/eas-cli/src/observe/formatMetrics.ts new file mode 100644 index 0000000000..2adec4ad1d --- /dev/null +++ b/packages/eas-cli/src/observe/formatMetrics.ts @@ -0,0 +1,165 @@ +import chalk from 'chalk'; + +import { EasCommandError } from '../commandUtils/errors'; +import { AppPlatform } from '../graphql/generated'; +import { appPlatformDisplayNames } from '../platform'; +import { getMetricDisplayName } from './metricNames'; + +export type StatisticKey = + | 'min' + | 'max' + | 'median' + | 'average' + | 'p80' + | 'p90' + | 'p99' + | 'eventCount'; + +export const STAT_ALIASES: Record = { + min: 'min', + max: 'max', + med: 'median', + median: 'median', + avg: 'average', + average: 'average', + p80: 'p80', + p90: 'p90', + p99: 'p99', + count: 'eventCount', + event_count: 'eventCount', + eventCount: 'eventCount', +}; + +export const STAT_DISPLAY_NAMES: Record = { + min: 'Min', + max: 'Max', + median: 'Med', + average: 'Avg', + p80: 'P80', + p90: 'P90', + p99: 'P99', + eventCount: 'Count', +}; + +export function resolveStatKey(input: string): StatisticKey { + const resolved = STAT_ALIASES[input]; + if (resolved) { + return resolved; + } + throw new EasCommandError( + `Unknown statistic: "${input}". Valid options: ${Object.keys(STAT_ALIASES).join(', ')}` + ); +} + +function formatStatValue(stat: StatisticKey, value: number | null | undefined): string { + if (value == null) { + return '-'; + } + if (stat === 'eventCount') { + return String(value); + } + return `${value.toFixed(2)}s`; +} + +export interface MetricValues { + min: number | null | undefined; + max: number | null | undefined; + median: number | null | undefined; + average: number | null | undefined; + p80: number | null | undefined; + p90: number | null | undefined; + p99: number | null | undefined; + eventCount: number | null | undefined; +} + +type ObserveMetricsKey = `${string}:${AppPlatform}`; + +export type ObserveMetricsMap = Map>; + +export function makeMetricsKey(appVersion: string, platform: AppPlatform): ObserveMetricsKey { + return `${appVersion}:${platform}`; +} + +function parseMetricsKey(key: ObserveMetricsKey): { appVersion: string; platform: AppPlatform } { + const lastColon = key.lastIndexOf(':'); + return { + appVersion: key.slice(0, lastColon), + platform: key.slice(lastColon + 1) as AppPlatform, + }; +} + +export type MetricValuesJson = Partial>; + +export interface ObserveMetricsVersionResult { + appVersion: string; + platform: AppPlatform; + metrics: Record; +} + +export function buildObserveMetricsJson( + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): ObserveMetricsVersionResult[] { + const results: ObserveMetricsVersionResult[] = []; + + for (const [key, versionMetrics] of metricsMap) { + const { appVersion, platform } = parseMetricsKey(key); + + const metrics: Record = {}; + for (const metricName of metricNames) { + const values = versionMetrics.get(metricName); + const statValues: MetricValuesJson = {}; + for (const stat of stats) { + statValues[stat] = values?.[stat] ?? null; + } + metrics[metricName] = statValues; + } + + results.push({ appVersion, platform, metrics }); + } + + return results; +} + +export function buildObserveMetricsTable( + metricsMap: ObserveMetricsMap, + metricNames: string[], + stats: StatisticKey[] +): string { + const results = buildObserveMetricsJson(metricsMap, metricNames, stats); + + if (results.length === 0) { + return chalk.yellow('No metrics data found.'); + } + + const fixedHeaders = ['App Version', 'Platform']; + const metricHeaders: string[] = []; + for (const m of metricNames) { + const name = getMetricDisplayName(m); + for (const stat of stats) { + metricHeaders.push(`${name} ${STAT_DISPLAY_NAMES[stat]}`); + } + } + const headers = [...fixedHeaders, ...metricHeaders]; + + const rows: string[][] = results.map(result => { + const metricCells: string[] = []; + for (const m of metricNames) { + const values = result.metrics[m]; + for (const stat of stats) { + metricCells.push(formatStatValue(stat, values?.[stat] ?? null)); + } + } + + return [result.appVersion, appPlatformDisplayNames[result.platform], ...metricCells]; + }); + + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length))); + + const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' '); + const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' '); + const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')); + + return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n'); +} diff --git a/packages/eas-cli/src/observe/metricNames.ts b/packages/eas-cli/src/observe/metricNames.ts new file mode 100644 index 0000000000..8d591a3cd2 --- /dev/null +++ b/packages/eas-cli/src/observe/metricNames.ts @@ -0,0 +1,35 @@ +import { EasCommandError } from '../commandUtils/errors'; + +export const METRIC_ALIASES: Record = { + tti: 'expo.app_startup.tti', + ttr: 'expo.app_startup.ttr', + cold_launch: 'expo.app_startup.cold_launch_time', + warm_launch: 'expo.app_startup.warm_launch_time', + bundle_load: 'expo.app_startup.bundle_load_time', +}; + +const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES)); + +export const METRIC_SHORT_NAMES: Record = { + 'expo.app_startup.cold_launch_time': 'Cold Launch', + 'expo.app_startup.warm_launch_time': 'Warm Launch', + 'expo.app_startup.tti': 'TTI', + 'expo.app_startup.ttr': 'TTR', + 'expo.app_startup.bundle_load_time': 'Bundle Load', +}; + +export function resolveMetricName(input: string): string { + if (METRIC_ALIASES[input]) { + return METRIC_ALIASES[input]; + } + if (KNOWN_FULL_NAMES.has(input) || input.includes('.')) { + return input; + } + throw new EasCommandError( + `Unknown metric: "${input}". Use a full metric name (e.g. expo.app_startup.tti) or a short alias: ${Object.keys(METRIC_ALIASES).join(', ')}` + ); +} + +export function getMetricDisplayName(metricName: string): string { + return METRIC_SHORT_NAMES[metricName] ?? metricName; +}