Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions packages/eas-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <value>] [--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=<value> 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.
Expand Down
162 changes: 162 additions & 0 deletions packages/eas-cli/src/commands/observe/__tests__/event.test.ts
Original file line number Diff line number Diff line change
@@ -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\\)`
)
);
});
});
165 changes: 165 additions & 0 deletions packages/eas-cli/src/commands/observe/event.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<T>(id: string, fn: () => Promise<T>): Promise<T> {
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);
}
16 changes: 16 additions & 0 deletions packages/eas-cli/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading