From f82a234512b5640191a1fa69941998f2a3e2a55c Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 16 Jul 2026 12:28:16 -0700 Subject: [PATCH] [build-tools] collect serve-sim session metrics to a file and upload at teardown --- CHANGELOG.md | 1 + .../build-tools/src/steps/easFunctions.ts | 2 + .../functions/startServeSimRemoteSession.ts | 43 +++-- .../steps/functions/uploadServeSimMetrics.ts | 26 +++ .../utils/__tests__/serveSimArtifacts.test.ts | 169 ++++++++++++++++++ .../src/steps/utils/serveSimArtifacts.ts | 118 ++++++++++++ 6 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 packages/build-tools/src/steps/functions/uploadServeSimMetrics.ts create mode 100644 packages/build-tools/src/steps/utils/__tests__/serveSimArtifacts.test.ts create mode 100644 packages/build-tools/src/steps/utils/serveSimArtifacts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c319f52fe..0c000ad8fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index 97421432a6..589061da7c 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -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'; @@ -99,6 +100,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createFinishIosSimulatorRecordingsBuildFunction(), createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx), createStartServeSimRemoteSessionBuildFunction(ctx), + createUploadServeSimMetricsBuildFunction(ctx), createInstallMaestroBuildFunction(), createInstallPodsBuildFunction(), diff --git a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts index 69c24f21c6..db10bb1462 100644 --- a/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts @@ -9,6 +9,10 @@ import { uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; +import { + collectServeSimMetricsToFileAsync, + serveSimMetricsFilePath, +} from '../utils/serveSimArtifacts'; const STARTUP_TIMEOUT_MS = 60_000; @@ -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 + } }, }); } diff --git a/packages/build-tools/src/steps/functions/uploadServeSimMetrics.ts b/packages/build-tools/src/steps/functions/uploadServeSimMetrics.ts new file mode 100644 index 0000000000..134e81d307 --- /dev/null +++ b/packages/build-tools/src/steps/functions/uploadServeSimMetrics.ts @@ -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, + }); + }, + }); +} diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimArtifacts.test.ts new file mode 100644 index 0000000000..603df3209f --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimArtifacts.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/build-tools/src/steps/utils/serveSimArtifacts.ts b/packages/build-tools/src/steps/utils/serveSimArtifacts.ts new file mode 100644 index 0000000000..0fa02d4046 --- /dev/null +++ b/packages/build-tools/src/steps/utils/serveSimArtifacts.ts @@ -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 { + 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(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 { + 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.'); + } +}