From 0ef83d7ab794e6d73af44373b85a72ac2222a0b0 Mon Sep 17 00:00:00 2001 From: Douglas Lowder Date: Tue, 7 Jul 2026 14:23:27 -0700 Subject: [PATCH 1/2] [eas-cli] Support drill-down from event lists in observe:session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the sessionId positional arg optional. When it's omitted and the command is running interactively, walk the user through picking an event whose session they want to inspect. Non-interactive without a session ID still errors as before. New flags on observe:session: - --event-name: metric alias/full-name or custom log event name. When it names a known metric (via isKnownMetricName from metricNames.ts) the picker queries fetchObserveEventsAsync; otherwise it queries fetchObserveCustomEventsAsync. - --sort: reuses EventsOrderPreset (fastest/slowest/newest/oldest) from observe/fetchEvents.ts. If omitted in interactive mode the user is prompted; the choice list adapts to the event kind (fastest/slowest are metric-only). - --days/--start/--end via ObserveTimeRangeFlags for the candidate window (default 60 days). Interactive picker flow when no session ID is given: 1. Event-name prompt (skipped if --event-name is passed). Options merge METRIC_SHORT_NAMES with ObserveQuery.customEventNamesAsync results, labelled per kind and with per-name counts for log events. 2. Sort-order prompt (skipped if --sort is passed). Log events see only newest/oldest. 3. Candidate-event prompt over the first 25 events fetched with that sort/window. Candidates without a sessionId are filtered out. On selection, the sessionId feeds the existing single-session timeline. Combining picker flags with a positional sessionId is now an explicit error since they describe how to find a session, not how to display one. The existing single-session path is unchanged. Also adds isKnownMetricName to observe/metricNames.ts — a non-throwing predicate over METRIC_ALIASES, NAVIGATION_METRIC_ALIASES, and their full name sets, used by the picker to route to metric- vs log-event fetchers without a try/catch around resolveMetricName. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../observe/__tests__/session.test.ts | 293 +++++++++++++++++- .../eas-cli/src/commands/observe/session.ts | 216 ++++++++++++- .../observe/__tests__/fetchSessions.test.ts | 150 ++++++++- .../observe/__tests__/formatSessions.test.ts | 97 +++++- .../src/observe/__tests__/metricNames.test.ts | 29 ++ packages/eas-cli/src/observe/fetchSessions.ts | 81 ++++- .../eas-cli/src/observe/formatSessions.ts | 38 +++ packages/eas-cli/src/observe/metricNames.ts | 15 + 8 files changed, 898 insertions(+), 21 deletions(-) diff --git a/packages/eas-cli/src/commands/observe/__tests__/session.test.ts b/packages/eas-cli/src/commands/observe/__tests__/session.test.ts index 02675063a3..3395ca5add 100644 --- a/packages/eas-cli/src/commands/observe/__tests__/session.test.ts +++ b/packages/eas-cli/src/commands/observe/__tests__/session.test.ts @@ -1,27 +1,99 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { getMockOclifConfig } from '../../../__tests__/commands/utils'; -import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions'; +import { ObserveQuery } from '../../../graphql/queries/ObserveQuery'; +import { + fetchObserveSessionEventsAsync, + fetchSessionLogCandidatesAsync, + fetchSessionMetricCandidatesAsync, +} from '../../../observe/fetchSessions'; import { buildObserveSessionEventsJson, buildObserveSessionEventsTable, } from '../../../observe/formatSessions'; +import { selectAsync } from '../../../prompts'; import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import ObserveSession from '../session'; jest.mock('../../../observe/fetchSessions'); -jest.mock('../../../observe/formatSessions', () => ({ - buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'), - buildObserveSessionEventsJson: jest.fn().mockReturnValue({}), +jest.mock('../../../observe/formatSessions', () => { + const actual = jest.requireActual('../../../observe/formatSessions'); + return { + ...actual, + buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'), + buildObserveSessionEventsJson: jest.fn().mockReturnValue({}), + }; +}); +jest.mock('../../../graphql/queries/ObserveQuery', () => ({ + ObserveQuery: { + customEventNamesAsync: jest.fn(), + }, })); +jest.mock('../../../prompts', () => { + const actual = jest.requireActual('../../../prompts'); + return { + ...actual, + selectAsync: jest.fn(), + }; +}); jest.mock('../../../log'); jest.mock('../../../utils/json'); const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync); +const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync); +const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync); +const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync); +const mockSelectAsync = jest.mocked(selectAsync); const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable); const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); +function makeMetricEvent(overrides: any = {}): any { + return { + __typename: 'AppObserveEvent', + id: 'evt-m-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:00:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + appUpdateId: null, + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-metric-1', + easClientId: 'client-1', + customParams: null, + routeName: null, + ...overrides, + }; +} + +function makeCustomEvent(overrides: any = {}): any { + return { + __typename: 'AppObserveCustomEvent', + id: 'evt-c-1', + eventName: 'login_pressed', + timestamp: '2025-01-15T10:00:00.000Z', + sessionId: 'session-log-1', + severityNumber: null, + severityText: null, + appVersion: '1.0.0', + appBuildNumber: '42', + appUpdateId: null, + appEasBuildId: null, + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + environment: 'production', + easClientId: 'client-1', + countryCode: 'US', + properties: [], + ...overrides, + }; +} + describe(ObserveSession, () => { const graphqlClient = {} as any as ExpoGraphqlClient; const mockConfig = getMockOclifConfig(); @@ -35,6 +107,9 @@ describe(ObserveSession, () => { hasMoreMetricEvents: false, hasMoreLogEvents: false, }); + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]); + mockFetchSessionLogCandidatesAsync.mockResolvedValue([]); + mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false }); }); function createCommand(argv: string[]): ObserveSession { @@ -47,11 +122,7 @@ describe(ObserveSession, () => { return command; } - it('requires a session ID argument', async () => { - const command = createCommand([]); - await expect(command.runAsync()).rejects.toThrow(); - expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled(); - }); + // ---- existing single-session behavior ---- it('passes the session ID argument to the fetcher', async () => { const command = createCommand(['session-abc']); @@ -76,7 +147,7 @@ describe(ObserveSession, () => { expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled(); }); - it('emits JSON output when --json is set', async () => { + it('emits JSON output when --json is set with a session ID', async () => { const command = createCommand(['session-abc', '--json', '--non-interactive']); await command.runAsync(); @@ -90,4 +161,206 @@ describe(ObserveSession, () => { const command = createCommand(['session-abc', '--platform', 'ios']); await expect(command.runAsync()).rejects.toThrow(); }); + + // ---- new picker-mode behavior ---- + + it('errors when no session ID is provided in non-interactive mode', async () => { + const command = createCommand(['--non-interactive']); + await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/); + expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled(); + }); + + it('rejects picker flags when a session ID is also provided', async () => { + const command = createCommand(['session-abc', '--event-name', 'tti']); + await expect(command.runAsync()).rejects.toThrow(/picker flags/); + }); + + it('rejects --days when a session ID is also provided', async () => { + const command = createCommand(['session-abc', '--days', '7']); + await expect(command.runAsync()).rejects.toThrow(/picker flags/); + }); + + it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([ + makeMetricEvent({ sessionId: 'picked-session-1' }), + ]); + // 1) sort prompt (no --sort flag), 2) event picker + mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-session-1'); + + const command = createCommand(['--event-name', 'tti']); + await command.runAsync(); + + expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1); + const options = mockFetchSessionMetricCandidatesAsync.mock.calls[0][2]; + expect(options.metricName).toBe('expo.app_startup.tti'); + expect(options.limit).toBe(25); + + expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled(); + expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith( + graphqlClient, + projectId, + expect.objectContaining({ sessionId: 'picked-session-1' }) + ); + }); + + it('picker mode with an unknown --event-name treats it as a log event and fetches custom events', async () => { + mockFetchSessionLogCandidatesAsync.mockResolvedValue([ + makeCustomEvent({ sessionId: 'picked-log-session-1' }), + ]); + // 1) sort prompt (no --sort flag), 2) event picker + mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-1'); + + const command = createCommand(['--event-name', 'login_pressed']); + await command.runAsync(); + + expect(mockFetchSessionLogCandidatesAsync).toHaveBeenCalledTimes(1); + expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].eventName).toBe('login_pressed'); + expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled(); + expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith( + graphqlClient, + projectId, + expect.objectContaining({ sessionId: 'picked-log-session-1' }) + ); + }); + + it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => { + mockCustomEventNamesAsync.mockResolvedValue({ + names: [ + { __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any, + ], + isTruncated: false, + }); + // 1) event-name picker → metric choice + // 2) sort picker → newest + // 3) event picker → sessionId + mockSelectAsync + .mockResolvedValueOnce({ name: 'expo.app_startup.tti', isMetric: true } as any) + .mockResolvedValueOnce('newest') + .mockResolvedValueOnce('picked-session-2'); + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([ + makeMetricEvent({ sessionId: 'picked-session-2' }), + ]); + + const command = createCommand([]); + await command.runAsync(); + + expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1); + expect(mockSelectAsync).toHaveBeenCalledTimes(3); + expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1); + expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].metricName).toBe( + 'expo.app_startup.tti' + ); + expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('picked-session-2'); + }); + + it('prompts for sort order when --sort is not provided and offers metric-only options for metric events', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([ + makeMetricEvent({ sessionId: 'picked-session-3' }), + ]); + mockSelectAsync.mockResolvedValueOnce('slowest').mockResolvedValueOnce('picked-session-3'); + + const command = createCommand(['--event-name', 'tti']); + await command.runAsync(); + + // First selectAsync is the sort picker; verify choice values + const sortChoices = mockSelectAsync.mock.calls[0][1]; + expect(sortChoices.map((c: any) => c.value)).toEqual([ + 'newest', + 'oldest', + 'slowest', + 'fastest', + ]); + }); + + it('prompts for sort order without fastest/slowest for log events', async () => { + mockFetchSessionLogCandidatesAsync.mockResolvedValue([ + makeCustomEvent({ sessionId: 'picked-log-session-2' }), + ]); + mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-2'); + + const command = createCommand(['--event-name', 'login_pressed']); + await command.runAsync(); + + const sortChoices = mockSelectAsync.mock.calls[0][1]; + expect(sortChoices.map((c: any) => c.value)).toEqual(['newest', 'oldest']); + }); + + it('skips the sort prompt when --sort is provided explicitly', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([ + makeMetricEvent({ sessionId: 'picked-session-4' }), + ]); + mockSelectAsync.mockResolvedValueOnce('picked-session-4'); + + const command = createCommand(['--event-name', 'tti', '--sort', 'oldest']); + await command.runAsync(); + + // Only the event picker was called, not the sort picker + expect(mockSelectAsync).toHaveBeenCalledTimes(1); + }); + + it('forwards --sort=slowest to the metric candidate fetcher', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([makeMetricEvent()]); + mockSelectAsync.mockResolvedValueOnce('session-metric-1'); + + const command = createCommand(['--event-name', 'tti', '--sort', 'slowest']); + await command.runAsync(); + + expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].sort).toBe('slowest'); + }); + + it('throws when --sort=slowest is combined with a log event name', async () => { + const command = createCommand(['--event-name', 'login_pressed', '--sort', 'slowest']); + await expect(command.runAsync()).rejects.toThrow(/only supported for metric events/); + expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled(); + }); + + it('passes orderAscending=true to the log candidate fetcher for --sort=oldest', async () => { + mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]); + mockSelectAsync.mockResolvedValueOnce('session-log-1'); + + const command = createCommand(['--event-name', 'login_pressed', '--sort', 'oldest']); + await command.runAsync(); + + expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(true); + }); + + it('passes orderAscending=false to the log candidate fetcher for --sort=newest', async () => { + mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]); + mockSelectAsync.mockResolvedValueOnce('session-log-1'); + + const command = createCommand(['--event-name', 'login_pressed', '--sort', 'newest']); + await command.runAsync(); + + expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(false); + }); + + it('throws when the metric picker returns no candidate events', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]); + mockSelectAsync.mockResolvedValueOnce('newest'); + const command = createCommand(['--event-name', 'tti']); + await expect(command.runAsync()).rejects.toThrow(/No events found/); + expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled(); + }); + + it('offers each returned candidate as a choice in the event picker', async () => { + mockFetchSessionMetricCandidatesAsync.mockResolvedValue([ + makeMetricEvent({ sessionId: 'session-a' }), + makeMetricEvent({ id: 'evt-m-2', sessionId: 'session-b' }), + ]); + // 1) sort prompt, 2) event picker + mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('session-a'); + + const command = createCommand(['--event-name', 'tti']); + await command.runAsync(); + + // Second selectAsync call is the event picker + const eventChoices = mockSelectAsync.mock.calls[1][1]; + expect(eventChoices).toHaveLength(2); + expect(eventChoices.map((c: any) => c.value)).toEqual(['session-a', 'session-b']); + }); + + it('rejects --sort when a session ID is also provided', async () => { + const command = createCommand(['session-abc', '--sort', 'slowest']); + await expect(command.runAsync()).rejects.toThrow(/picker flags/); + }); }); diff --git a/packages/eas-cli/src/commands/observe/session.ts b/packages/eas-cli/src/commands/observe/session.ts index 2c7aaf115f..518df0519f 100644 --- a/packages/eas-cli/src/commands/observe/session.ts +++ b/packages/eas-cli/src/commands/observe/session.ts @@ -1,15 +1,32 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import EasCommand from '../../commandUtils/EasCommand'; +import { EasCommandError } from '../../commandUtils/errors'; import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import Log from '../../log'; -import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions'; -import { ObserveProjectIdFlag } from '../../observe/flags'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import { EventsOrderPreset } from '../../observe/fetchEvents'; +import { + fetchObserveSessionEventsAsync, + fetchSessionLogCandidatesAsync, + fetchSessionMetricCandidatesAsync, +} from '../../observe/fetchSessions'; +import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags'; import { buildObserveSessionEventsJson, buildObserveSessionEventsTable, + formatLogCandidateTitle, + formatMetricCandidateTitle, } from '../../observe/formatSessions'; +import { + METRIC_SHORT_NAMES, + isKnownMetricName, + resolveMetricName, +} from '../../observe/metricNames'; import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; +import { resolveTimeRange } from '../../observe/startAndEndTime'; +import { ExpoChoice, selectAsync } from '../../prompts'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; // Fixed at 100 — the maximum page size accepted by the underlying events and @@ -17,18 +34,33 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; // command pulls one page of each and merges client-side. const SESSION_PAGE_SIZE = 100; +// How many candidate events to present in the picker. Small enough to browse; +// users narrow further with --days / --sort. +const PICKER_CANDIDATE_LIMIT = 25; + export default class ObserveSession extends EasCommand { static override description = 'display the timeline of metric and log events for a specific session'; static override args = { sessionId: Args.string({ - description: 'Session ID to inspect', - required: true, + description: 'Session ID to inspect (omit in interactive mode to pick one from a list)', + required: false, }), }; static override flags = { + sort: Flags.option({ + description: + 'Sort order for candidate events when picking a session (if omitted in interactive mode, you will be prompted)', + options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()), + required: false, + })(), + 'event-name': Flags.string({ + description: + 'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.', + }), + ...ObserveTimeRangeFlags, ...ObserveProjectIdFlag, ...EasNonInteractiveAndJsonFlags, }; @@ -57,9 +89,37 @@ export default class ObserveSession extends EasCommand { enableJsonOutput(); } + let sessionId: string; + if (args.sessionId) { + const pickerFlagsProvided = + flags['event-name'] !== undefined || + flags.sort !== undefined || + flags.days !== undefined || + flags.start !== undefined || + flags.end !== undefined; + if (pickerFlagsProvided) { + throw new EasCommandError( + 'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.' + ); + } + sessionId = args.sessionId; + } else if (flags['non-interactive']) { + throw new EasCommandError( + 'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.' + ); + } else { + sessionId = await pickSessionIdInteractivelyAsync({ + graphqlClient, + projectId, + eventNameFlag: flags['event-name'], + sort: flags.sort, + timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end }, + }); + } + const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } = await fetchObserveSessionEventsAsync(graphqlClient, projectId, { - sessionId: args.sessionId, + sessionId, limit: SESSION_PAGE_SIZE, }); @@ -67,7 +127,7 @@ export default class ObserveSession extends EasCommand { printJsonOnlyOutput( buildObserveSessionEventsJson( entries, - args.sessionId, + sessionId, metadata, hasMoreMetricEvents, hasMoreLogEvents @@ -85,3 +145,145 @@ export default class ObserveSession extends EasCommand { } } } + +interface EventNameChoice { + name: string; + isMetric: boolean; +} + +async function pickSessionIdInteractivelyAsync({ + graphqlClient, + projectId, + eventNameFlag, + sort, + timeRangeFlags, +}: { + graphqlClient: ExpoGraphqlClient; + projectId: string; + eventNameFlag: string | undefined; + sort: string | undefined; + timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined }; +}): Promise { + const { startTime, endTime } = resolveTimeRange(timeRangeFlags); + + const eventNameChoice: EventNameChoice = eventNameFlag + ? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) } + : await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime }); + + let sortValue: string; + if (sort) { + const preset = sort.toUpperCase() as EventsOrderPreset; + const sortIsValueBased = + preset === EventsOrderPreset.Slowest || preset === EventsOrderPreset.Fastest; + if (!eventNameChoice.isMetric && sortIsValueBased) { + throw new EasCommandError( + `--sort=${sort} is only supported for metric events. Use newest or oldest for log events.` + ); + } + sortValue = sort; + } else { + sortValue = await promptForSortOrderAsync(eventNameChoice.isMetric); + } + + const candidates: CandidateEvent[] = eventNameChoice.isMetric + ? ( + await fetchSessionMetricCandidatesAsync(graphqlClient, projectId, { + metricName: resolveMetricName(eventNameChoice.name), + sort: sortValue, + startTime, + endTime, + limit: PICKER_CANDIDATE_LIMIT, + }) + ).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) })) + : ( + await fetchSessionLogCandidatesAsync(graphqlClient, projectId, { + eventName: eventNameChoice.name, + orderAscending: sortValue.toUpperCase() === EventsOrderPreset.Oldest, + startTime, + endTime, + limit: PICKER_CANDIDATE_LIMIT, + }) + ).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) })); + + if (candidates.length === 0) { + throw new EasCommandError( + `No events found for "${eventNameChoice.name}" in the selected time range. Try widening the window with --days or picking a different --event-name.` + ); + } + + return await promptForSessionIdAsync(candidates); +} + +async function promptForSortOrderAsync(isMetric: boolean): Promise { + const choices: ExpoChoice[] = [ + { title: 'Newest first', value: EventsOrderPreset.Newest.valueOf().toLowerCase() }, + { title: 'Oldest first', value: EventsOrderPreset.Oldest.valueOf().toLowerCase() }, + ...(isMetric + ? [ + { + title: 'Slowest first (highest metric value)', + value: EventsOrderPreset.Slowest.valueOf().toLowerCase(), + }, + { + title: 'Fastest first (lowest metric value)', + value: EventsOrderPreset.Fastest.valueOf().toLowerCase(), + }, + ] + : []), + ]; + return await selectAsync('Sort candidate events by', choices); +} + +async function promptForEventNameAsync({ + graphqlClient, + projectId, + startTime, + endTime, +}: { + graphqlClient: ExpoGraphqlClient; + projectId: string; + startTime: string; + endTime: string; +}): Promise { + const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, { + appId: projectId, + startTime, + endTime, + }); + + const metricChoices: ExpoChoice[] = Object.entries(METRIC_SHORT_NAMES).map( + ([fullName, displayName]) => ({ + title: `${displayName} (metric)`, + value: { name: fullName, isMetric: true }, + }) + ); + + const logChoices: ExpoChoice[] = customEventNames.map( + ({ eventName, count }) => ({ + title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`, + value: { name: eventName, isMetric: false }, + }) + ); + + const choices = [...metricChoices, ...logChoices]; + if (choices.length === 0) { + throw new EasCommandError( + 'No metric or log events found for the selected time range. Widen the window with --days or wait for data to arrive.' + ); + } + + return await selectAsync('Select an event name', choices); +} + +interface CandidateEvent { + sessionId: string; + title: string; +} + +async function promptForSessionIdAsync(candidates: CandidateEvent[]): Promise { + const choices: ExpoChoice[] = candidates.map(c => ({ + title: c.title, + value: c.sessionId, + })); + return await selectAsync('Select an event (its session will be shown)', choices); +} diff --git a/packages/eas-cli/src/observe/__tests__/fetchSessions.test.ts b/packages/eas-cli/src/observe/__tests__/fetchSessions.test.ts index f7eb35ef4b..04c334cda8 100644 --- a/packages/eas-cli/src/observe/__tests__/fetchSessions.test.ts +++ b/packages/eas-cli/src/observe/__tests__/fetchSessions.test.ts @@ -6,10 +6,20 @@ import { } from '../../graphql/generated'; import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents'; import { fetchObserveEventsAsync } from '../fetchEvents'; -import { fetchObserveSessionEventsAsync } from '../fetchSessions'; +import { + fetchObserveSessionEventsAsync, + fetchSessionLogCandidatesAsync, + fetchSessionMetricCandidatesAsync, +} from '../fetchSessions'; jest.mock('../fetchCustomEvents'); -jest.mock('../fetchEvents'); +jest.mock('../fetchEvents', () => { + const actual = jest.requireActual('../fetchEvents'); + return { + ...actual, + fetchObserveEventsAsync: jest.fn(), + }; +}); const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync); const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync); @@ -191,3 +201,139 @@ describe('fetchObserveSessionEventsAsync', () => { expect(result.hasMoreLogEvents).toBe(false); }); }); + +describe('fetchSessionMetricCandidatesAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + }); + + it('forwards metricName, sort, window, and limit into fetchObserveEventsAsync', async () => { + await fetchSessionMetricCandidatesAsync({} as any, 'project-1', { + metricName: 'expo.app_startup.tti', + sort: 'slowest', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + + const options = mockFetchObserveEventsAsync.mock.calls[0][2]; + expect(options.metricName).toBe('expo.app_startup.tti'); + expect(options.limit).toBe(25); + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-02-01T00:00:00.000Z'); + expect(options.orderBy).toEqual({ + field: AppObserveEventsOrderByField.MetricValue, + direction: AppObserveEventsOrderByDirection.Desc, + }); + }); + + it('filters out events without a sessionId', async () => { + mockFetchObserveEventsAsync.mockResolvedValue({ + events: [ + makeMetricEvent({ id: 'evt-1', sessionId: 'session-a' }), + makeMetricEvent({ id: 'evt-2', sessionId: null }), + makeMetricEvent({ id: 'evt-3', sessionId: 'session-b' }), + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const result = await fetchSessionMetricCandidatesAsync({} as any, 'project-1', { + metricName: 'expo.app_startup.tti', + sort: 'newest', + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + + expect(result.map(e => e.id)).toEqual(['evt-1', 'evt-3']); + }); +}); + +describe('fetchSessionLogCandidatesAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + }); + + it('forwards eventName, window, and limit into fetchObserveCustomEventsAsync', async () => { + await fetchSessionLogCandidatesAsync({} as any, 'project-1', { + eventName: 'login_pressed', + orderAscending: false, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + + const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2]; + expect(options.eventName).toBe('login_pressed'); + expect(options.limit).toBe(25); + expect(options.startTime).toBe('2025-01-01T00:00:00.000Z'); + expect(options.endTime).toBe('2025-02-01T00:00:00.000Z'); + }); + + it('sorts client-side descending when orderAscending is false (newest first)', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [ + makeCustomEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }), + makeCustomEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }), + makeCustomEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }), + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', { + eventName: 'login_pressed', + orderAscending: false, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + expect(result.map(e => e.id)).toEqual(['c', 'b', 'a']); + }); + + it('sorts client-side ascending when orderAscending is true (oldest first)', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [ + makeCustomEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }), + makeCustomEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }), + makeCustomEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }), + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', { + eventName: 'login_pressed', + orderAscending: true, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + expect(result.map(e => e.id)).toEqual(['a', 'b', 'c']); + }); + + it('filters out events without a sessionId', async () => { + mockFetchObserveCustomEventsAsync.mockResolvedValue({ + events: [ + makeCustomEvent({ id: 'a', sessionId: 'session-a' }), + makeCustomEvent({ id: 'b', sessionId: null }), + ], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + }); + + const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', { + eventName: 'login_pressed', + orderAscending: false, + startTime: '2025-01-01T00:00:00.000Z', + endTime: '2025-02-01T00:00:00.000Z', + limit: 25, + }); + expect(result.map(e => e.id)).toEqual(['a']); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/formatSessions.test.ts b/packages/eas-cli/src/observe/__tests__/formatSessions.test.ts index ca3be3dae2..23f8fb8cab 100644 --- a/packages/eas-cli/src/observe/__tests__/formatSessions.test.ts +++ b/packages/eas-cli/src/observe/__tests__/formatSessions.test.ts @@ -1,5 +1,12 @@ +import { AppObserveCustomEvent, AppObserveEvent } from '../../graphql/generated'; import { SessionEventEntry, SessionMetadata } from '../fetchSessions'; -import { buildObserveSessionEventsJson, buildObserveSessionEventsTable } from '../formatSessions'; +import { + buildObserveSessionEventsJson, + buildObserveSessionEventsTable, + formatLogCandidateTitle, + formatMetricCandidateTitle, + shortSessionId, +} from '../formatSessions'; function makeMetricEntry(overrides: Partial = {}): SessionEventEntry { return { @@ -240,3 +247,91 @@ describe(buildObserveSessionEventsJson, () => { expect(result.metadata).toBeNull(); }); }); + +describe(shortSessionId, () => { + it('returns the full id when it is 12 characters or fewer', () => { + expect(shortSessionId('abc')).toBe('abc'); + expect(shortSessionId('abcdefghijkl')).toBe('abcdefghijkl'); + }); + + it('collapses long ids to ', () => { + expect(shortSessionId('abcdefgh1234567890zyxw')).toBe('abcdefgh…zyxw'); + }); +}); + +function makeMetricEvent(overrides: Partial = {}): AppObserveEvent { + return { + __typename: 'AppObserveEvent' as const, + id: 'evt-m-1', + metricName: 'expo.app_startup.tti', + metricValue: 1.23, + timestamp: '2025-01-15T10:00:00.000Z', + appVersion: '1.0.0', + appBuildNumber: '42', + appUpdateId: null, + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + countryCode: 'US', + sessionId: 'session-abcdefgh1234567890', + easClientId: 'client-1', + customParams: null, + routeName: null, + ...overrides, + } as AppObserveEvent; +} + +function makeCustomEventEvt(overrides: Partial = {}): AppObserveCustomEvent { + return { + __typename: 'AppObserveCustomEvent' as const, + id: 'evt-c-1', + eventName: 'login_pressed', + timestamp: '2025-01-15T10:00:00.000Z', + sessionId: 'session-abcdefgh1234567890', + severityNumber: null, + severityText: null, + appVersion: '1.0.0', + appBuildNumber: '42', + appUpdateId: null, + appEasBuildId: null, + deviceModel: 'iPhone 15', + deviceOs: 'iOS', + deviceOsVersion: '17.0', + environment: 'production', + easClientId: 'client-1', + countryCode: 'US', + properties: [], + ...overrides, + } as AppObserveCustomEvent; +} + +describe(formatMetricCandidateTitle, () => { + it('includes timestamp, display name, value, version, device, and truncated session id', () => { + const title = formatMetricCandidateTitle(makeMetricEvent()); + expect(title).toContain('Startup TTI 1.23s'); + expect(title).toContain('1.0.0'); + expect(title).toContain('iOS 17.0'); + expect(title).toContain('session session-…7890'); + }); +}); + +describe(formatLogCandidateTitle, () => { + it('includes timestamp, event name, and severity (using severityText when set)', () => { + const title = formatLogCandidateTitle(makeCustomEventEvt({ severityText: 'WARN' })); + expect(title).toContain('login_pressed'); + expect(title).toContain('WARN'); + expect(title).toContain('1.0.0'); + }); + + it('falls back to severityNumber when severityText is null', () => { + const title = formatLogCandidateTitle( + makeCustomEventEvt({ severityText: null, severityNumber: 13 }) + ); + expect(title).toContain('13'); + }); + + it('shows "-" for severity when both fields are null', () => { + const title = formatLogCandidateTitle(makeCustomEventEvt()); + expect(title).toContain('login_pressed · -'); + }); +}); diff --git a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts index 75219c8be9..ddbff84221 100644 --- a/packages/eas-cli/src/observe/__tests__/metricNames.test.ts +++ b/packages/eas-cli/src/observe/__tests__/metricNames.test.ts @@ -1,5 +1,6 @@ import { getMetricDisplayName, + isKnownMetricName, resolveMetricName, resolveNavigationMetricName, } from '../metricNames'; @@ -110,3 +111,31 @@ describe(getMetricDisplayName, () => { expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name'); }); }); + +describe(isKnownMetricName, () => { + it('returns true for app-startup short aliases', () => { + expect(isKnownMetricName('tti')).toBe(true); + expect(isKnownMetricName('cold_launch')).toBe(true); + expect(isKnownMetricName('bundle_load')).toBe(true); + }); + + it('returns true for navigation short aliases', () => { + expect(isKnownMetricName('cold_ttr')).toBe(true); + expect(isKnownMetricName('warm_ttr')).toBe(true); + expect(isKnownMetricName('nav_tti')).toBe(true); + }); + + it('returns true for full metric names', () => { + expect(isKnownMetricName('expo.app_startup.tti')).toBe(true); + expect(isKnownMetricName('expo.navigation.cold_ttr')).toBe(true); + }); + + it('returns false for unknown names (including custom event names)', () => { + expect(isKnownMetricName('login_pressed')).toBe(false); + expect(isKnownMetricName('some_random_event')).toBe(false); + expect(isKnownMetricName('')).toBe(false); + // A dotted name that isn't in the known-full set is still not "known" + // for picker purposes; the picker treats it as a log event. + expect(isKnownMetricName('custom.metric.name')).toBe(false); + }); +}); diff --git a/packages/eas-cli/src/observe/fetchSessions.ts b/packages/eas-cli/src/observe/fetchSessions.ts index edd108e763..94e824bb19 100644 --- a/packages/eas-cli/src/observe/fetchSessions.ts +++ b/packages/eas-cli/src/observe/fetchSessions.ts @@ -6,7 +6,7 @@ import { AppObserveEventsOrderByField, } from '../graphql/generated'; import { fetchObserveCustomEventsAsync } from './fetchCustomEvents'; -import { fetchObserveEventsAsync } from './fetchEvents'; +import { fetchObserveEventsAsync, resolveOrderBy } from './fetchEvents'; export interface SessionEventEntry { source: 'metric' | 'log'; @@ -146,3 +146,82 @@ export async function fetchObserveSessionEventsAsync( hasMoreLogEvents: logResult.pageInfo.hasNextPage, }; } + +/** + * A metric event that is guaranteed to belong to a session — used as a + * candidate when picking a session to inspect via `observe:session`. + */ +export type SessionMetricCandidate = AppObserveEvent & { sessionId: string }; + +export interface FetchSessionMetricCandidatesOptions { + metricName: string; + /** One of EventsOrderPreset (case-insensitive). */ + sort: string; + startTime: string; + endTime: string; + limit: number; +} + +/** + * Fetch a page of metric events for the given metricName + window, ordered + * per `sort`, and filtered to events that have a sessionId. The events query + * supports server-side ordering, so `sort` is passed straight through. + */ +export async function fetchSessionMetricCandidatesAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + options: FetchSessionMetricCandidatesOptions +): Promise { + const { events } = await fetchObserveEventsAsync(graphqlClient, appId, { + metricName: options.metricName, + orderBy: resolveOrderBy(options.sort), + limit: options.limit, + startTime: options.startTime, + endTime: options.endTime, + }); + return events.filter((e): e is SessionMetricCandidate => !!e.sessionId); +} + +/** + * A custom log event that is guaranteed to belong to a session — used as a + * candidate when picking a session to inspect via `observe:session`. + */ +export type SessionLogCandidate = AppObserveCustomEvent & { sessionId: string }; + +export interface FetchSessionLogCandidatesOptions { + eventName: string; + /** True → oldest-first (ascending timestamp); false → newest-first. */ + orderAscending: boolean; + startTime: string; + endTime: string; + limit: number; +} + +/** + * Fetch a page of custom log events for the given eventName + window, + * sorted client-side by timestamp (the customEventList query has no + * orderBy), and filtered to events that have a sessionId. + */ +export async function fetchSessionLogCandidatesAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + options: FetchSessionLogCandidatesOptions +): Promise { + const { events } = await fetchObserveCustomEventsAsync(graphqlClient, appId, { + eventName: options.eventName, + limit: options.limit, + startTime: options.startTime, + endTime: options.endTime, + }); + const ascending = options.orderAscending; + const sorted = [...events].sort((a, b) => { + if (a.timestamp < b.timestamp) { + return ascending ? -1 : 1; + } + if (a.timestamp > b.timestamp) { + return ascending ? 1 : -1; + } + return 0; + }); + return sorted.filter((e): e is SessionLogCandidate => !!e.sessionId); +} diff --git a/packages/eas-cli/src/observe/formatSessions.ts b/packages/eas-cli/src/observe/formatSessions.ts index efe4d44fe3..1325ad7bff 100644 --- a/packages/eas-cli/src/observe/formatSessions.ts +++ b/packages/eas-cli/src/observe/formatSessions.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; +import { AppObserveCustomEvent, AppObserveEvent } from '../graphql/generated'; import { SessionEventEntry, SessionMetadata } from './fetchSessions'; import { formatLogTimestamp } from './formatUtils'; import { getMetricDisplayName } from './metricNames'; @@ -142,3 +143,40 @@ export function buildObserveSessionEventsJson( hasMoreLogEvents, }; } + +/** + * Compact session ID for display in candidate lists — long UUIDs are + * truncated to `` so a row stays scannable. + */ +export function shortSessionId(sessionId: string): string { + if (sessionId.length <= 12) { + return sessionId; + } + return `${sessionId.slice(0, 8)}…${sessionId.slice(-4)}`; +} + +/** + * One-line title for a metric event shown in the observe:session candidate + * picker, e.g. `Jan 15, 10:00:00.000 AM · Startup TTI 1.23s · 1.0.0 · iOS 17.0 · session abc…1234`. + */ +export function formatMetricCandidateTitle(event: AppObserveEvent): string { + const displayName = getMetricDisplayName(event.metricName); + const value = `${event.metricValue.toFixed(2)}s`; + const timestamp = formatLogTimestamp(event.timestamp); + const shortSession = shortSessionId(event.sessionId ?? ''); + const device = `${event.deviceOs} ${event.deviceOsVersion}`; + return `${timestamp} · ${displayName} ${value} · ${event.appVersion} · ${device} · session ${shortSession}`; +} + +/** + * One-line title for a custom log event shown in the observe:session + * candidate picker. + */ +export function formatLogCandidateTitle(event: AppObserveCustomEvent): string { + const timestamp = formatLogTimestamp(event.timestamp); + const severity = + event.severityText ?? (event.severityNumber != null ? String(event.severityNumber) : '-'); + const shortSession = shortSessionId(event.sessionId ?? ''); + const device = `${event.deviceOs} ${event.deviceOsVersion}`; + return `${timestamp} · ${event.eventName} · ${severity} · ${event.appVersion} · ${device} · session ${shortSession}`; +} diff --git a/packages/eas-cli/src/observe/metricNames.ts b/packages/eas-cli/src/observe/metricNames.ts index 0acaa73ddf..7f9491f5cd 100644 --- a/packages/eas-cli/src/observe/metricNames.ts +++ b/packages/eas-cli/src/observe/metricNames.ts @@ -60,3 +60,18 @@ export function resolveNavigationMetricName(input: string): string { export function getMetricDisplayName(metricName: string): string { return METRIC_SHORT_NAMES[metricName] ?? metricName; } + +/** + * Non-throwing predicate: true when `input` is a known metric alias (either + * app-startup or navigation) or a known full metric name. Used to decide + * whether an event name should be treated as a metric or as a custom log + * event without forcing the caller into a try/catch around `resolveMetricName`. + */ +export function isKnownMetricName(input: string): boolean { + return ( + input in METRIC_ALIASES || + input in NAVIGATION_METRIC_ALIASES || + KNOWN_FULL_NAMES.has(input) || + KNOWN_FULL_NAVIGATION_NAMES.has(input) + ); +} From 432128c20f2846ce9f83af68d128db997cedcf6d Mon Sep 17 00:00:00 2001 From: Douglas Lowder Date: Wed, 8 Jul 2026 12:59:00 -0700 Subject: [PATCH 2/2] CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e9705f0b..187c3976dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder)) ### 🐛 Bug fixes