Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[eas-cli] Use wrapper instead of telemetry subscriber for metadata #1169

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🧹 Chores

- Refactor metadata telemetry to handle errors better. ([#1169](https://github.com/expo/eas-cli/pull/1169) by [@byCedric](https://github.com/byCedric))

## [0.54.1](https://github.com/expo/eas-cli/releases/tag/v0.54.1) - 2022-06-15

### 🎉 New features
Expand Down
50 changes: 22 additions & 28 deletions packages/eas-cli/src/metadata/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createAppleTasks } from './apple/tasks';
import { createAppleWriter } from './config';
import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context';
import { MetadataDownloadError, MetadataValidationError } from './errors';
import { subscribeTelemetry } from './utils/telemetry';
import { withTelemetryAsync } from './utils/telemetry';

/**
* Generate a local store configuration from the stores.
Expand All @@ -29,44 +29,38 @@ export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promi
}

const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx);
const { unsubscribeTelemetry, executionId } = subscribeTelemetry(
MetadataEvent.APPLE_METADATA_DOWNLOAD,
{ app, auth }
);

Log.addNewLineIfNone();
Log.log('Downloading App Store configuration...');

const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks(metadataCtx);
const taskCtx = { app };
await withTelemetryAsync(MetadataEvent.APPLE_METADATA_DOWNLOAD, { app, auth }, async () => {
const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks(metadataCtx);
const taskCtx = { app };

for (const task of tasks) {
try {
await task.prepareAsync({ context: taskCtx });
} catch (error: any) {
errors.push(error);
for (const task of tasks) {
try {
await task.prepareAsync({ context: taskCtx });
} catch (error: any) {
errors.push(error);
}
}
}

for (const task of tasks) {
try {
await task.downloadAsync({ config, context: taskCtx as AppleData });
} catch (error: any) {
errors.push(error);
for (const task of tasks) {
try {
await task.downloadAsync({ config, context: taskCtx as AppleData });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get rid of the as AppleData cast?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same as: #1169 (comment) We could relax the types, but we would need to add more checks in the tasks.

} catch (error: any) {
errors.push(error);
}
}
}

try {
await fs.writeJson(filePath, config.toSchema(), { spaces: 2 });
} finally {
unsubscribeTelemetry();
}

if (errors.length > 0) {
throw new MetadataDownloadError(errors, executionId);
}
if (errors.length > 0) {
throw new MetadataDownloadError(errors);
}
});

return filePath;
}
24 changes: 19 additions & 5 deletions packages/eas-cli/src/metadata/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,25 @@ export class MetadataValidationError extends Error {
}
}

/**
* During telemetry actions, a random exection ID is generated.
* This ID should be communicated to users and reported to us.
* With that ID, we can look up the failed requests and help solve the issue.
*/
export class MetadataTelemetryError extends Error {
public constructor(public readonly originalError: Error, public readonly executionId: string) {
Copy link
Member Author

@byCedric byCedric Jun 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of this, since it kind of obfuscates the original error. But I don't see a "safe" way of exposing the executionId to the user outside withTelemetryAsync.

We can always do something like the snippet below, but that's not super safe. And adding an intermediate Error class also feels a bit weird (e.g. MetadataDownloadError < TelemetryError < Error). But I'm open to other suggestions.

try {
  ...
} catch (error: any) {
  error.executionId = ...
  throw error;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could make withTelemetryAsync return whatever passed callback returns

const errors = await withTelemetryAsync(MetadataEvent.APPLE_METADATA_DOWNLOAD, { app, auth }, async () => {
  ...
  return errors;
})
if (errors.length > 0) {
   throw new MetadataDownloadError(errors);
}

or handle that error inside withTelemetryAsync (or inside callback that you are passing to withTelemetryAsync)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm against returning errors as if they were regular data. I like the other suggestion better.

super(originalError.message);
}
}

/**
* If a single entity failed to update, we don't block the other entities from uploading.
* We still attempt to update the data in the stores as much as possible.
* Because of that, we keep track of any errors encountered and throw this generic error.
* It contains that list of encountered errors to present to the user.
*/
export class MetadataUploadError extends Error {
public constructor(public readonly errors: Error[], public readonly executionId: string) {
public constructor(public readonly errors: Error[]) {
super(
`Store configuration upload encountered ${
errors.length === 1 ? 'an error' : `${errors.length} errors`
Expand All @@ -32,12 +43,12 @@ export class MetadataUploadError extends Error {

/**
* If a single entity failed to download, we don't block the other entities from downloading.
* We sill attempt to pull in the data from the stores as much as possible.
* Because of that, we keep track of any errors envountered and throw this generic error.
* We still attempt to pull in the data from the stores as much as possible.
* Because of that, we keep track of any errors encountered and throw this generic error.
* It contains that list of encountered errors to present to the user.
*/
export class MetadataDownloadError extends Error {
public constructor(public readonly errors: Error[], public readonly executionId: string) {
public constructor(public readonly errors: Error[]) {
super(
`Store configuration download encountered ${
errors.length === 1 ? 'an error' : `${errors.length} errors`
Expand All @@ -50,7 +61,10 @@ export class MetadataDownloadError extends Error {
* Handle a thrown metadata error by informing the user what went wrong.
* If a normal error is thrown, this method will re-throw that error to avoid consuming it.
*/
export function handleMetadataError(error: Error): void {
export function handleMetadataError(thrownError: Error): void {
const error =
thrownError instanceof MetadataTelemetryError ? thrownError.originalError : thrownError;

if (error instanceof MetadataValidationError) {
Log.newLine();
Log.error(chalk.bold(error.message));
Expand Down
51 changes: 23 additions & 28 deletions packages/eas-cli/src/metadata/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createAppleTasks } from './apple/tasks';
import { createAppleReader, validateConfig } from './config';
import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context';
import { MetadataUploadError, MetadataValidationError } from './errors';
import { subscribeTelemetry } from './utils/telemetry';
import { withTelemetryAsync } from './utils/telemetry';

/**
* Sync a local store configuration with the stores.
Expand All @@ -23,11 +23,6 @@ export async function uploadMetadataAsync(
}

const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx);
const { unsubscribeTelemetry, executionId } = subscribeTelemetry(
MetadataEvent.APPLE_METADATA_UPLOAD,
{ app, auth }
);

const fileData = await fs.readJson(filePath);
const { valid, errors: validationErrors } = validateConfig(fileData);
if (!valid) {
Expand All @@ -37,32 +32,32 @@ export async function uploadMetadataAsync(
Log.addNewLineIfNone();
Log.log('Uploading App Store configuration...');

const errors: Error[] = [];
const config = createAppleReader(fileData);
const tasks = createAppleTasks(metadataCtx);
const taskCtx = { app };

for (const task of tasks) {
try {
await task.prepareAsync({ context: taskCtx });
} catch (error: any) {
errors.push(error);
await withTelemetryAsync(MetadataEvent.APPLE_METADATA_UPLOAD, { app, auth }, async () => {
const errors: Error[] = [];
const config = createAppleReader(fileData);
const tasks = createAppleTasks(metadataCtx);
const taskCtx = { app };

for (const task of tasks) {
try {
await task.prepareAsync({ context: taskCtx });
} catch (error: any) {
errors.push(error);
}
}
}

for (const task of tasks) {
try {
await task.uploadAsync({ config, context: taskCtx as AppleData });
} catch (error: any) {
errors.push(error);
for (const task of tasks) {
try {
await task.uploadAsync({ config, context: taskCtx as AppleData });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the cast?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's going from a Partial<AppleData> to a finalized AppleData. What we can do is relax the typing on uploadAsync and downloadAsync. But that would increase the checks we need to do within the tasks themselves.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow. If we're passing a partial object and the function expects a non-partial one it can lead to strange errors in the future.

} catch (error: any) {
errors.push(error);
}
}
}

unsubscribeTelemetry();

if (errors.length > 0) {
throw new MetadataUploadError(errors, executionId);
}
if (errors.length > 0) {
throw new MetadataUploadError(errors);
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks almost like the other function in this PR (downloadMetadataAsync). Maybe it's worth writing some util to extract the common code?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, they are both the "task runners". The main difference is the task it's executing and the error it could be throwing. I can create a task runner, but I didn't want to abstract too much in the first version.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create a follow-up task to refactor this.


return { appleLink: `https://appstoreconnect.apple.com/apps/${app.id}/appstore` };
}
84 changes: 67 additions & 17 deletions packages/eas-cli/src/metadata/utils/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,73 @@
import { App } from '@expo/apple-utils';
import { App, getRequestClient } from '@expo/apple-utils';

import { TelemetryContext, makeDataScrubber } from '../telemetry';
import { MetadataEvent } from '../../../analytics/events';
import { MetadataTelemetryError } from '../../errors';
import { TelemetryContext, makeDataScrubber, withTelemetryAsync } from '../telemetry';

describe(makeDataScrubber, () => {
const stub: TelemetryContext = {
// Only the App ID is considered to be sensitive
app: new App({} as any, 'SECRET_APP_ID', {} as any),
// Only the token properties and user credentials are considered to be sensitive
auth: {
username: 'SECRET_USERNAME',
password: 'SECRET_PASSWORD',
context: {
token: 'SECRET_TOKEN',
teamId: 'SECRET_TEAM_ID',
providerId: 1337,
},
} as any,
};
const stub: TelemetryContext = {
// Only the App ID is considered to be sensitive
app: new App({} as any, 'SECRET_APP_ID', {} as any),
// Only the token properties and user credentials are considered to be sensitive
auth: {
username: 'SECRET_USERNAME',
password: 'SECRET_PASSWORD',
context: {
token: 'SECRET_TOKEN',
teamId: 'SECRET_TEAM_ID',
providerId: 1337,
},
} as any,
};
Comment on lines +7 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use ts-mockito instead of using as any. We use it in www.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice! TIL, I used the any to only pass a partial version of the original type (only the types that we need in the tests). I'll look into ts-mockito


describe(withTelemetryAsync, () => {
afterEach(() => {
jest.clearAllMocks();
});

it('detaches interceptors after action resolved', async () => {
const { interceptors } = getRequestClient();
const ejectInterceptor = jest.spyOn(interceptors.response, 'eject');

await withTelemetryAsync(MetadataEvent.APPLE_METADATA_DOWNLOAD, stub, async () => {
return 'testing resolved telemetry action';
});

expect(ejectInterceptor).toBeCalledWith(expect.any(Number));
});

it('detaches interceptors after action rejected', async () => {
const { interceptors } = getRequestClient();
const ejectInterceptor = jest.spyOn(interceptors.response, 'eject');

try {
await withTelemetryAsync(MetadataEvent.APPLE_METADATA_DOWNLOAD, stub, async () => {
throw new Error('testing rejected telemetry action');
});
} catch {
// intentional failure
}

expect(ejectInterceptor).toBeCalledWith(expect.any(Number));
Comment on lines +42 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await expect(...).rejects.toThrow(...)

});

it('adds executionId to thrown errors', async () => {
let caughtError: null | Error = null;

try {
await withTelemetryAsync(MetadataEvent.APPLE_METADATA_DOWNLOAD, stub, async () => {
throw new Error('testing exectution ID');
});
} catch (error: any) {
caughtError = error;
}

expect(caughtError).toBeInstanceOf(MetadataTelemetryError);
expect(caughtError).toHaveProperty('executionId', expect.any(String));
expect(caughtError).toHaveProperty('originalError', expect.any(Error));
Comment on lines +56 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await expect(...).rejects.toThrow(...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this at first, but in we also need to test the "executionId". I don't think we can do such a thing with just that line, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point but you're just checking if executionId and originalError were set to anything. If we're using typescript for all files in this repo then it's not possible to create an instance of MetadataTelemetryError without specifying those two fields.

I'm not feeling too strong about this though.

});
});

describe(makeDataScrubber, () => {
it('scrubs the app.id', () => {
expect(makeDataScrubber(stub)('some text SECRET_APP_ID')).toBe('some text {APPLE_APP_ID}');
});
Expand Down
26 changes: 10 additions & 16 deletions packages/eas-cli/src/metadata/utils/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,18 @@ import type { AxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';

import { Analytics, MetadataEvent } from '../../analytics/events';
import { MetadataTelemetryError } from '../errors';

export type TelemetryContext = {
app: App;
auth: Partial<Session.AuthState>;
};

/**
* Subscribe the telemetry to the ongoing metadata requests and responses.
* When providing the app and auth info, we can scrub that data from the telemetry.
* Returns an execution ID to group all events of a single run together, and a unsubscribe function.
*/
export function subscribeTelemetry(
export async function withTelemetryAsync<T>(
event: MetadataEvent,
options: TelemetryContext
): {
/** Unsubscribe the telemetry from all apple-utils events */
unsubscribeTelemetry: () => void;
/** The unique id added to all telemetry events from a single execution */
executionId: string;
} {
options: TelemetryContext,
action: () => Promise<T>
): Promise<T> {
const executionId = uuidv4();
const scrubber = makeDataScrubber(options);
const { interceptors } = getRequestClient();
Expand Down Expand Up @@ -59,11 +51,13 @@ export function subscribeTelemetry(
}
);

function unsubscribeTelemetry(): void {
try {
return await action();
} catch (error: any) {
throw new MetadataTelemetryError(error, executionId);
} finally {
interceptors.response.eject(responseInterceptorId);
}

return { unsubscribeTelemetry, executionId };
}

/** Exposed for testing */
Expand Down