Skip to content
Draft
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 @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- [build-tools] Pass a metrics CORS origin to serve-sim so the dashboard can read a session's live CPU/memory stream cross-origin. ([#3990](https://github.com/expo/eas-cli/pull/3990) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the serve-sim session's CPU/memory NDJSON as a device run session artifact at teardown. ([#3995](https://github.com/expo/eas-cli/pull/3995) by [@gwdp](https://github.com/gwdp))

### 🐛 Bug fixes

Expand Down
2 changes: 2 additions & 0 deletions packages/build-tools/src/steps/easFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadServeSimMetricsBuildFunction } from './functions/uploadServeSimMetrics';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
Expand Down Expand Up @@ -99,6 +100,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createUploadServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),

createInstallPodsBuildFunction(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
import {
collectServeSimMetricsToFileAsync,
serveSimMetricsFilePath,
} from '../utils/serveSimArtifacts';

const STARTUP_TIMEOUT_MS = 60_000;

Expand Down Expand Up @@ -37,19 +41,38 @@ export function createStartServeSimRemoteSessionBuildFunction(
});
logger.info(`Preview URL: ${previewUrl}`);

await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl },
// Stream serve-sim's cpu/mem `/metrics` for the whole session, appending to a file on disk.
// Abort it in `finally` so a stopped, aborted, or errored session all flush and close the
// file; the `eas/upload_serve_sim_metrics` teardown step ships it afterwards.
const metricsController = new AbortController();
const metricsSignal = signal
? AbortSignal.any([signal, metricsController.signal])
: metricsController.signal;
const metricsCollection = collectServeSimMetricsToFileAsync({
serveSimUrl: previewUrl,
filePath: serveSimMetricsFilePath(deviceRunSessionId),
signal: metricsSignal,
logger,
});

await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
try {
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl },
logger,
});

await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
} finally {
metricsController.abort();
await metricsCollection; // flush + close the file; best-effort, never throws
}
},
});
}
26 changes: 26 additions & 0 deletions packages/build-tools/src/steps/functions/uploadServeSimMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';

import { type CustomBuildContext } from '../../customBuildContext';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
import {
serveSimMetricsFilePath,
uploadServeSimMetricsFileAsync,
} from '../utils/serveSimArtifacts';

export function createUploadServeSimMetricsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'upload_serve_sim_metrics',
name: 'Upload serve-sim metrics',
__metricsId: 'eas/upload_serve_sim_metrics',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId,
filePath: serveSimMetricsFilePath(deviceRunSessionId),
logger,
});
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';

import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import {
collectServeSimMetricsToFileAsync,
serveSimMetricsFilePath,
uploadServeSimMetricsFileAsync,
} from '../serveSimArtifacts';

jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.mock('node-fetch');

const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');

function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}

async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<string> {
let out = '';
for await (const chunk of stream as Readable) {
out += chunk.toString();
}
return out;
}

const ctx = {} as CustomBuildContext;
const serveSimUrl = 'https://sim.example.ngrok.app';

// One meta frame then two sample frames, with an `event:` label and a `:` heartbeat mixed in —
// the shape serve-sim's /metrics SSE emits.
const SSE_STREAM = [
'event: meta',
'data: {"schemaVersion":1,"udid":"U","hostCores":8,"sampleIntervalMs":1000}',
'',
': heartbeat',
'',
'data: {"t":1000,"bundleId":"dev.expo.MyApp","cpuPct":0,"memBytes":100}',
'',
'data: {"t":2000,"bundleId":"dev.expo.MyApp","cpuPct":40,"memBytes":120}',
'',
].join('\n');

const EXPECTED_NDJSON =
'{"schemaVersion":1,"udid":"U","hostCores":8,"sampleIntervalMs":1000}\n' +
'{"t":1000,"bundleId":"dev.expo.MyApp","cpuPct":0,"memBytes":100}\n' +
'{"t":2000,"bundleId":"dev.expo.MyApp","cpuPct":40,"memBytes":120}\n';

let tmpDir: string;
let filePath: string;

beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-metrics-test-'));
filePath = path.join(tmpDir, 'metrics.ndjson');
});

afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});

describe(serveSimMetricsFilePath, () => {
it('derives a deterministic path from the device run session id', () => {
expect(serveSimMetricsFilePath('abc')).toBe(
path.join(os.tmpdir(), 'eas-serve-sim-metrics', 'abc.ndjson')
);
});
});

describe(collectServeSimMetricsToFileAsync, () => {
beforeEach(() => jest.mocked(fetch).mockReset());

it('appends the data payloads to the file as NDJSON, ignoring event/heartbeat lines', async () => {
jest.mocked(fetch).mockResolvedValue(new Response(Readable.from([Buffer.from(SSE_STREAM)])));
await collectServeSimMetricsToFileAsync({
serveSimUrl,
filePath,
signal: new AbortController().signal,
logger: createLoggerMock(),
});
expect(await readFile(filePath, 'utf-8')).toBe(EXPECTED_NDJSON);
const [url] = jest.mocked(fetch).mock.calls[0];
expect(url).toBe(`${serveSimUrl}/metrics`);
});

it('writes nothing on a non-ok response', async () => {
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
await collectServeSimMetricsToFileAsync({
serveSimUrl,
filePath,
signal: new AbortController().signal,
logger: createLoggerMock(),
});
expect(await readFile(filePath, 'utf-8')).toBe('');
});
});

describe(uploadServeSimMetricsFileAsync, () => {
beforeEach(() => jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset());

it('uploads the file as a metrics.ndjson artifact', async () => {
await writeFile(filePath, EXPECTED_NDJSON);
let uploaded = '';
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementation(async (_ctx, { stream }) => {
uploaded = await readStreamAsync(stream);
});

await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
filePath,
logger: createLoggerMock(),
});

expect(uploaded).toBe(EXPECTED_NDJSON);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
deviceRunSessionId: 'session-id',
filename: 'metrics.ndjson',
artifactId: 'metrics-session-id',
size: Buffer.byteLength(EXPECTED_NDJSON),
})
);
});

it('skips the upload when the file is missing', async () => {
await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
filePath,
logger: createLoggerMock(),
});
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});

it('skips the upload when the file is empty', async () => {
await writeFile(filePath, '');
await uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
filePath,
logger: createLoggerMock(),
});
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
});

it('swallows an upload failure (best-effort)', async () => {
await writeFile(filePath, EXPECTED_NDJSON);
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('R2 down'));
await expect(
uploadServeSimMetricsFileAsync(ctx, {
deviceRunSessionId: 'session-id',
filePath,
logger: createLoggerMock(),
})
).resolves.toBeUndefined();
});
});
118 changes: 118 additions & 0 deletions packages/build-tools/src/steps/utils/serveSimArtifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { type bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';

const METRICS_ARTIFACT_FILENAME = 'metrics.ndjson';

// Deterministic path on the worker disk so the collect (session) step and the upload (teardown)
// step agree on the file without passing it between them — both derive it from the device run
// session id. Writing to disk (rather than buffering in memory) also survives a crashed session
// step, so the upload step can still ship whatever was flushed.
export function serveSimMetricsFilePath(deviceRunSessionId: string): string {
return path.join(os.tmpdir(), 'eas-serve-sim-metrics', `${deviceRunSessionId}.ndjson`);
}

// Subscribe to serve-sim's `/metrics` SSE for the whole session, appending each `data:` payload to
// `filePath` as it arrives. serve-sim emits one meta line then one line per sample, so payloads are
// the NDJSON verbatim. Resolves once `signal` aborts at teardown (or the stream ends), after the
// file is flushed and closed.
export async function collectServeSimMetricsToFileAsync({
serveSimUrl,
filePath,
signal,
logger,
}: {
serveSimUrl: string;
filePath: string;
signal: AbortSignal;
logger: bunyan;
}): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const file = createWriteStream(filePath, { flags: 'a' });
try {
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
if (!response.ok || !response.body) {
logger.warn(`serve-sim /metrics responded ${response.status}; no metrics captured.`);
return;
}
let buffer = '';
for await (const chunk of response.body) {
buffer += chunk.toString();
let newline: number;
while ((newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.startsWith('data:')) {
const payload = line.slice('data:'.length).trim();
if (payload) {
file.write(payload + '\n');
}
}
}
}
} catch (err) {
// Aborting the stream at teardown is expected; anything else is best-effort telemetry.
if (!signal.aborted) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('serve-sim /metrics stream failed', error);
logger.warn(
{ err: error },
'serve-sim /metrics stream failed; using metrics collected so far.'
);
}
} finally {
await new Promise<void>(resolve => file.end(() => resolve()));
}
}

// Uploads the collected metrics NDJSON file as a device run session artifact. Best-effort: skips a
// missing or empty file and never throws. Runs as its own teardown step, so a stopped, aborted, or
// errored session still ships whatever the collector wrote.
export async function uploadServeSimMetricsFileAsync(
ctx: CustomBuildContext,
{
deviceRunSessionId,
filePath,
logger,
}: {
deviceRunSessionId: string;
filePath: string;
logger: bunyan;
}
): Promise<void> {
let size: number;
try {
({ size } = await stat(filePath));
} catch {
logger.info('No serve-sim metrics captured; skipping artifact upload.');
return;
}
if (size === 0) {
logger.info('No serve-sim metrics captured; skipping artifact upload.');
return;
}
try {
logger.info(`Uploading serve-sim metrics (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: `metrics-${deviceRunSessionId}`,
name: METRICS_ARTIFACT_FILENAME,
filename: METRICS_ARTIFACT_FILENAME,
kind: undefined,
size,
stream: createReadStream(filePath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload serve-sim metrics artifact', error);
logger.warn({ err: error }, 'Could not upload serve-sim metrics artifact.');
}
}
Loading