diff --git a/CHANGELOG.md b/CHANGELOG.md index 69795a519b..52cb960d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa)) - [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys)) +- [eas-cli] Add `eas observe:event` to display a single Observe event (metric or log) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index ff848c133a..16dae6128f 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -205,6 +205,7 @@ If you want to enforce the `eas-cli` version for your project, use the `"cli.ver * [`eas metadata:pull`](#eas-metadatapull) * [`eas metadata:push`](#eas-metadatapush) * [`eas new [PATH]`](#eas-new-path) +* [`eas observe:event ID`](#eas-observeevent-id) * [`eas observe:events [EVENTNAME]`](#eas-observeevents-eventname) * [`eas observe:metrics [METRIC]`](#eas-observemetrics-metric) * [`eas observe:metrics-summary`](#eas-observemetrics-summary) @@ -2232,6 +2233,28 @@ ALIASES $ eas new ``` +## `eas observe:event ID` + +display a single Observe event (metric or log) by its ID + +``` +USAGE + $ eas observe:event ID [--project-id ] [--json] [--non-interactive] + +ARGUMENTS + ID ID of the event to display (from `eas observe:events` or `eas observe:session`) + +FLAGS + --json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive. + --non-interactive Run the command in non-interactive mode. + --project-id= EAS project ID (defaults to the project ID of the current directory) + +DESCRIPTION + display a single Observe event (metric or log) by its ID +``` + +_See code: [packages/eas-cli/src/commands/observe/event.ts](https://github.com/expo/eas-cli/blob/v22.2.0/packages/eas-cli/src/commands/observe/event.ts)_ + ## `eas observe:events [EVENTNAME]` display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned. diff --git a/packages/eas-cli/src/commands/observe/__tests__/event.test.ts b/packages/eas-cli/src/commands/observe/__tests__/event.test.ts new file mode 100644 index 0000000000..ac1e293d4a --- /dev/null +++ b/packages/eas-cli/src/commands/observe/__tests__/event.test.ts @@ -0,0 +1,162 @@ +import { CombinedError } from '@urql/core'; +import { GraphQLError } from 'graphql'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { ObserveQuery } from '../../../graphql/queries/ObserveQuery'; +import { + buildObserveCustomEventDetail, + buildObserveCustomEventJson, +} from '../../../observe/formatCustomEvents'; +import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents'; +import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ObserveEvent from '../event'; + +jest.mock('../../../observe/formatEvents', () => ({ + buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'), + buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }), +})); +jest.mock('../../../observe/formatCustomEvents', () => ({ + buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'), + buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }), +})); +jest.mock('../../../graphql/queries/ObserveQuery', () => ({ + ObserveQuery: { + customEventByIdAsync: jest.fn(), + metricEventByIdAsync: jest.fn(), + }, +})); +jest.mock('../../../log'); +jest.mock('../../../utils/json'); + +const mockCustomEventByIdAsync = jest.mocked(ObserveQuery.customEventByIdAsync); +const mockMetricEventByIdAsync = jest.mocked(ObserveQuery.metricEventByIdAsync); +const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail); +const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson); +const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail); +const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +// A custom (log) event ID is a UUID; a metric event ID is base64url-encoded JSON. +const UUID_ID = '123e4567-e89b-12d3-a456-426614174000'; +const BASE64_ID = 'eyJhIjoxfQ'; +const INVALID_ID = 'not a valid id'; + +describe(ObserveEvent, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + const projectId = 'test-project-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockCustomEventByIdAsync.mockResolvedValue(null); + mockMetricEventByIdAsync.mockResolvedValue(null); + }); + + function createCommand(argv: string[]): ObserveEvent { + const command = new ObserveEvent(argv, mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue({ + projectId, + loggedIn: { graphqlClient }, + }); + return command; + } + + function planGateError(): CombinedError { + const serverMessage = + 'Subscription to EAS is required for this feature. ' + + 'Subscribe: https://expo.dev/accounts/acme/settings/billing'; + return new CombinedError({ + graphQLErrors: [ + new GraphQLError(serverMessage, null, null, null, null, null, { + errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE, + }), + ], + }); + } + + it('looks up a UUID id with the custom-event query only', async () => { + mockCustomEventByIdAsync.mockResolvedValue({ id: UUID_ID } as any); + await createCommand([UUID_ID]).runAsync(); + expect(mockCustomEventByIdAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + id: UUID_ID, + }); + expect(mockMetricEventByIdAsync).not.toHaveBeenCalled(); + expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1); + }); + + it('looks up a base64 id with the metric-event query only', async () => { + mockMetricEventByIdAsync.mockResolvedValue({ id: BASE64_ID } as any); + await createCommand([BASE64_ID]).runAsync(); + expect(mockMetricEventByIdAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + id: BASE64_ID, + }); + expect(mockCustomEventByIdAsync).not.toHaveBeenCalled(); + expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1); + }); + + it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => { + await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow( + /is not a valid Observe event ID/ + ); + expect(mockCustomEventByIdAsync).not.toHaveBeenCalled(); + expect(mockMetricEventByIdAsync).not.toHaveBeenCalled(); + }); + + it('emits typed JSON for a metric event with --json', async () => { + mockMetricEventByIdAsync.mockResolvedValue({ id: BASE64_ID } as any); + await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync(); + expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1); + expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ + type: 'metric', + event: { id: 'metric-json' }, + }); + }); + + it('emits typed JSON for a log event with --json', async () => { + mockCustomEventByIdAsync.mockResolvedValue({ id: UUID_ID } as any); + await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync(); + expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ + type: 'log', + event: { id: 'log-json' }, + }); + }); + + it('throws not-found when a well-formed ID resolves to nothing', async () => { + mockCustomEventByIdAsync.mockResolvedValue(null); + await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow( + new RegExp(`No Observe event found with ID "${UUID_ID}"`) + ); + }); + + it('surfaces the plan-gate message when the lookup is not available on the plan', async () => { + mockCustomEventByIdAsync.mockRejectedValueOnce(planGateError()); + await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow( + /Subscription to EAS is required/ + ); + }); + + it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => { + const serverError = new CombinedError({ + graphQLErrors: [ + new GraphQLError('unexpected server error', null, null, null, null, null, { + requestId: 'req-123', + }), + ], + }); + mockCustomEventByIdAsync.mockRejectedValue(serverError); + + await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow( + new RegExp( + `Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)` + ) + ); + }); +}); diff --git a/packages/eas-cli/src/commands/observe/event.ts b/packages/eas-cli/src/commands/observe/event.ts new file mode 100644 index 0000000000..5dffe21404 --- /dev/null +++ b/packages/eas-cli/src/commands/observe/event.ts @@ -0,0 +1,165 @@ +import { Args } from '@oclif/core'; +import { validate as isUuid } from 'uuid'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { EasCommandError } from '../../commandUtils/errors'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../commandUtils/flags'; +import { GraphqlError } from '../../graphql/client'; +import { ObserveQuery } from '../../graphql/queries/ObserveQuery'; +import Log from '../../log'; +import { + buildObserveCustomEventDetail, + buildObserveCustomEventJson, +} from '../../observe/formatCustomEvents'; +import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents'; +import { ObserveProjectIdFlag } from '../../observe/flags'; +import { withObservePlanGateHandlingAsync } from '../../observe/planGating'; +import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class ObserveEvent extends EasCommand { + static override description = + 'display a single Observe event (metric or log) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.'; + + static override args = { + id: Args.string({ + description: 'ID of the event to display', + required: true, + }), + }; + + static override flags = { + ...ObserveProjectIdFlag, + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + private static loggedInOnlyContextDefinition = { + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags, args } = await this.parse(ObserveEvent); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + + const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({ + command: this, + commandClass: ObserveEvent, + loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition, + projectIdOverride: flags['project-id'], + nonInteractive, + }); + + if (json) { + enableJsonOutput(); + } + + const id = args.id; + + // A custom (log) event ID is a UUID; a metric event ID is base64url-encoded + // JSON. Route to the matching query so we never issue the query that is + // guaranteed to fail for this ID, and reject anything that is neither up front. + if (isUuid(id)) { + const customEvent = await fetchObserveEventAsync(id, () => + ObserveQuery.customEventByIdAsync(graphqlClient, { appId: projectId, id }) + ); + if (!customEvent) { + throw eventNotFoundError(id); + } + if (json) { + printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(customEvent) }); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveCustomEventDetail(customEvent)); + } + return; + } + + if (parsesAsBase64(id)) { + const event = await fetchObserveEventAsync(id, () => + ObserveQuery.metricEventByIdAsync(graphqlClient, { appId: projectId, id }) + ); + if (!event) { + throw eventNotFoundError(id); + } + if (json) { + printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) }); + } else { + Log.addNewLineIfNone(); + Log.log(buildObserveEventDetail(event)); + } + return; + } + + throw new EasCommandError( + `"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.` + ); + } +} + +/** + * A metric event ID is canonical base64url (of JSON). Round-tripping rejects + * strings that only coincidentally contain base64 characters, so a value that + * is neither a UUID nor this is treated as an invalid ID. + */ +function parsesAsBase64(id: string): boolean { + if (id.length === 0) { + return false; + } + return Buffer.from(id, 'base64url').toString('base64url') === id; +} + +function eventNotFoundError(id: string): EasCommandError { + return new EasCommandError( + `No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.` + ); +} + +/** + * Runs an Observe event lookup, translating plan-gate rejections to their + * upgrade message and any other server error into an actionable message that + * preserves the underlying error and request ID for support. + */ +async function fetchObserveEventAsync(id: string, fn: () => Promise): Promise { + try { + return await withObservePlanGateHandlingAsync(fn); + } catch (error) { + if (error instanceof EasCommandError) { + throw error; + } + throw new EasCommandError( + `Could not retrieve Observe event with ID "${id}". ` + + 'The ID may be invalid or the event may not exist (events also age out of retention). ' + + 'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' + + `\n\n${describeObserveServerError(error)}` + ); + } +} + +/** + * Extract a human-readable description from a GraphQL/server error, including + * the request ID(s) so a support request can reference them. Falls back to the + * error's own message for non-GraphQL errors. + */ +function describeObserveServerError(error: unknown): string { + if (error instanceof GraphqlError && error.graphQLErrors.length > 0) { + return error.graphQLErrors + .map(graphQLError => { + const message = graphQLError.message.replace('[GraphQL] ', ''); + const requestId = graphQLError.extensions?.requestId; + return requestId ? `${message} (Request ID: ${String(requestId)})` : message; + }) + .join('\n'); + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index bb936114f3..d8882c6acb 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -15272,6 +15272,22 @@ export type AppObserveCustomEventListQueryVariables = Exact<{ export type AppObserveCustomEventListQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', customEventList: { __typename?: 'AppObserveCustomEventListConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveCustomEventEdge', cursor: string, node: { __typename?: 'AppObserveCustomEvent', id: string, eventName: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> } }> } } } } }; +export type AppObserveMetricEventByIdQueryVariables = Exact<{ + appId: Scalars['String']['input']; + id: Scalars['ID']['input']; +}>; + + +export type AppObserveMetricEventByIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', event?: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null, routeName?: string | null } | null } } } }; + +export type AppObserveCustomEventByIdQueryVariables = Exact<{ + appId: Scalars['String']['input']; + id: Scalars['ID']['input']; +}>; + + +export type AppObserveCustomEventByIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', customEvent?: { __typename?: 'AppObserveCustomEvent', id: string, eventName: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> } | null } } } }; + export type AppObserveCustomEventNamesQueryVariables = Exact<{ appId: Scalars['String']['input']; startTime: Scalars['DateTime']['input']; diff --git a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts index 6f684a39f5..cbd0001dd2 100644 --- a/packages/eas-cli/src/graphql/queries/ObserveQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ObserveQuery.ts @@ -116,6 +116,33 @@ type AppObserveCustomEventListQueryVariables = { orderBy?: AppObserveCustomEventListOrderBy; }; +type AppObserveMetricEventByIdQuery = { + app: { + byId: { + id: string; + observe: { + event: AppObserveEvent | null; + }; + }; + }; +}; + +type AppObserveCustomEventByIdQuery = { + app: { + byId: { + id: string; + observe: { + customEvent: AppObserveCustomEvent | null; + }; + }; + }; +}; + +type AppObserveEventByIdQueryVariables = { + appId: string; + id: string; +}; + type AppObserveCustomEventNamesQuery = { app: { byId: { @@ -373,6 +400,68 @@ export const ObserveQuery = { }; }, + async metricEventByIdAsync( + graphqlClient: ExpoGraphqlClient, + { appId, id }: AppObserveEventByIdQueryVariables + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveMetricEventById($appId: String!, $id: ID!) { + app { + byId(appId: $appId) { + id + observe { + event(id: $id) { + id + ...AppObserveEventFragment + } + } + } + } + } + ${print(AppObserveEventFragmentNode)} + `, + { appId, id } + ) + .toPromise() + ); + + return data.app.byId.observe.event ?? null; + }, + + async customEventByIdAsync( + graphqlClient: ExpoGraphqlClient, + { appId, id }: AppObserveEventByIdQueryVariables + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query AppObserveCustomEventById($appId: String!, $id: ID!) { + app { + byId(appId: $appId) { + id + observe { + customEvent(id: $id) { + id + ...AppObserveCustomEventFragment + } + } + } + } + } + ${print(AppObserveCustomEventFragmentNode)} + `, + { appId, id } + ) + .toPromise() + ); + + return data.app.byId.observe.customEvent ?? null; + }, + async customEventNamesAsync( graphqlClient: ExpoGraphqlClient, { diff --git a/packages/eas-cli/src/observe/formatCustomEvents.ts b/packages/eas-cli/src/observe/formatCustomEvents.ts index e98558b960..9917c11317 100644 --- a/packages/eas-cli/src/observe/formatCustomEvents.ts +++ b/packages/eas-cli/src/observe/formatCustomEvents.ts @@ -101,6 +101,32 @@ export function buildObserveCustomEventsTable( return lines.join('\n'); } +export function buildObserveCustomEventJson(event: AppObserveCustomEvent): ObserveCustomEventJson { + return { + id: event.id, + eventName: event.eventName, + timestamp: event.timestamp, + sessionId: event.sessionId ?? null, + severityNumber: event.severityNumber ?? null, + severityText: event.severityText ?? null, + properties: event.properties.map(p => ({ + key: p.key, + value: p.value, + type: p.type, + })), + appVersion: event.appVersion, + appBuildNumber: event.appBuildNumber, + appUpdateId: event.appUpdateId ?? null, + appEasBuildId: event.appEasBuildId ?? null, + deviceModel: event.deviceModel, + deviceOs: event.deviceOs, + deviceOsVersion: event.deviceOsVersion, + countryCode: event.countryCode ?? null, + environment: event.environment ?? null, + easClientId: event.easClientId, + }; +} + export function buildObserveCustomEventsJson( events: AppObserveCustomEvent[], pageInfo: PageInfo @@ -109,29 +135,7 @@ export function buildObserveCustomEventsJson( pageInfo: { hasNextPage: boolean; endCursor: string | null }; } { return { - events: events.map(event => ({ - id: event.id, - eventName: event.eventName, - timestamp: event.timestamp, - sessionId: event.sessionId ?? null, - severityNumber: event.severityNumber ?? null, - severityText: event.severityText ?? null, - properties: event.properties.map(p => ({ - key: p.key, - value: p.value, - type: p.type, - })), - appVersion: event.appVersion, - appBuildNumber: event.appBuildNumber, - appUpdateId: event.appUpdateId ?? null, - appEasBuildId: event.appEasBuildId ?? null, - deviceModel: event.deviceModel, - deviceOs: event.deviceOs, - deviceOsVersion: event.deviceOsVersion, - countryCode: event.countryCode ?? null, - environment: event.environment ?? null, - easClientId: event.easClientId, - })), + events: events.map(buildObserveCustomEventJson), pageInfo: { hasNextPage: pageInfo.hasNextPage, endCursor: pageInfo.endCursor ?? null, @@ -139,6 +143,43 @@ export function buildObserveCustomEventsJson( }; } +/** + * Render a single custom (log) event as a vertical Field/Value detail table, + * plus its properties, for `eas observe:event`. + */ +export function buildObserveCustomEventDetail(event: AppObserveCustomEvent): string { + const rows: string[][] = [ + ['ID', event.id], + ['Type', 'Log'], + ['Event Name', event.eventName], + ['Timestamp', formatLogTimestamp(event.timestamp)], + ['Severity', formatSeverity(event)], + ['Session ID', event.sessionId ?? '-'], + ['App Version', `${event.appVersion} (${event.appBuildNumber})`], + ['Update ID', event.appUpdateId ?? '-'], + ['EAS Build ID', event.appEasBuildId ?? '-'], + ['Platform', `${event.deviceOs} ${event.deviceOsVersion}`], + ['Device', event.deviceModel], + ['Country', event.countryCode ?? '-'], + ['Environment', event.environment ?? '-'], + ['EAS Client ID', event.easClientId], + ]; + + const lines = [chalk.bold('Log event'), '', renderTextTable(['Field', 'Value'], rows)]; + + if (event.properties.length > 0) { + const propertyRows = event.properties.map(p => [p.key, p.type, p.value]); + lines.push( + '', + chalk.bold('Properties'), + '', + renderTextTable(['Key', 'Type', 'Value'], propertyRows) + ); + } + + return lines.join('\n'); +} + export interface BuildEmptyCustomEventsWithSuggestionsOptions { daysBack?: number; startTime?: string; diff --git a/packages/eas-cli/src/observe/formatEvents.ts b/packages/eas-cli/src/observe/formatEvents.ts index 83114f59da..a2c707fe8c 100644 --- a/packages/eas-cli/src/observe/formatEvents.ts +++ b/packages/eas-cli/src/observe/formatEvents.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { AppObserveEvent, PageInfo } from '../graphql/generated'; import renderTextTable from '../utils/renderTextTable'; -import { buildTimeRangeDescription, formatTimestamp } from './formatUtils'; +import { buildTimeRangeDescription, formatLogTimestamp, formatTimestamp } from './formatUtils'; import { getMetricDisplayName } from './metricNames'; export interface ObserveEventJson { @@ -87,31 +87,59 @@ export function buildObserveEventsTable( return lines.join('\n'); } +export function buildObserveEventJson(event: AppObserveEvent): ObserveEventJson { + return { + id: event.id, + metricName: event.metricName, + metricValue: event.metricValue, + appVersion: event.appVersion, + appBuildNumber: event.appBuildNumber, + appUpdateId: event.appUpdateId ?? null, + deviceModel: event.deviceModel, + deviceOs: event.deviceOs, + deviceOsVersion: event.deviceOsVersion, + countryCode: event.countryCode ?? null, + sessionId: event.sessionId ?? null, + easClientId: event.easClientId, + timestamp: event.timestamp, + customParams: resolveCustomParams(event), + routeName: event.routeName ?? null, + }; +} + 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, - appUpdateId: event.appUpdateId ?? null, - deviceModel: event.deviceModel, - deviceOs: event.deviceOs, - deviceOsVersion: event.deviceOsVersion, - countryCode: event.countryCode ?? null, - sessionId: event.sessionId ?? null, - easClientId: event.easClientId, - timestamp: event.timestamp, - customParams: resolveCustomParams(event), - routeName: event.routeName ?? null, - })), + events: events.map(buildObserveEventJson), pageInfo: { hasNextPage: pageInfo.hasNextPage, endCursor: pageInfo.endCursor ?? null, }, }; } + +/** + * Render a single metric event as a vertical Field/Value detail table, for + * `eas observe:event`. + */ +export function buildObserveEventDetail(event: AppObserveEvent): string { + const rows: string[][] = [ + ['ID', event.id], + ['Type', 'Metric'], + ['Metric', getMetricDisplayName(event.metricName)], + ['Value', `${event.metricValue.toFixed(2)}s`], + ['Timestamp', formatLogTimestamp(event.timestamp)], + ['Session ID', event.sessionId ?? '-'], + ['Route', event.routeName ?? '-'], + ['App Version', `${event.appVersion} (${event.appBuildNumber})`], + ['Update ID', event.appUpdateId ?? '-'], + ['Platform', `${event.deviceOs} ${event.deviceOsVersion}`], + ['Device', event.deviceModel], + ['Country', event.countryCode ?? '-'], + ['EAS Client ID', event.easClientId], + ['Custom Params', event.customParams ? JSON.stringify(event.customParams) : '-'], + ]; + return [chalk.bold('Metric event'), '', renderTextTable(['Field', 'Value'], rows)].join('\n'); +}