[build-tools] Collect shared simulator logs - #4463
sjkim-expo wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4463 +/- ##
==========================================
+ Coverage 72.52% 72.63% +0.11%
==========================================
Files 974 978 +4
Lines 46505 46728 +223
Branches 10015 10099 +84
==========================================
+ Hits 33725 33936 +211
- Misses 11786 11798 +12
Partials 994 994 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
✅ Thank you for adding the changelog entry! |
gwdp
left a comment
There was a problem hiding this comment.
Very nice 💪 ! few comments above
| deviceRunSessionId, | ||
| artifactId: `simulator-log-${udid}`, | ||
| name: `App logs (${udid.slice(0, 8)})`, | ||
| filename: 'simulator.ndjson', |
There was a problem hiding this comment.
suggestion/nit: would app-logs.ndjson make sense?
| await uploadDeviceRunSessionArtifactAsync(ctx, { | ||
| deviceRunSessionId, | ||
| artifactId: `simulator-log-${udid}`, | ||
| name: `App logs (${udid.slice(0, 8)})`, |
There was a problem hiding this comment.
What do you think about a follow-up to expose and use the device name as the name? Could a device ID have little meaning to users?
| return; | ||
| } | ||
| const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); | ||
| const signal = AbortSignal.timeout(30_000); |
There was a problem hiding this comment.
Would it make sense to move the timeout to be per device/upload? eg.: one upload can get stuck but we don't fail all?
| }); | ||
| } | ||
| } catch (err) { | ||
| logger.warn({ err }, 'Could not finalize simulator logs; the session result is unchanged.'); |
There was a problem hiding this comment.
Could we add Sentry.capture here so we gain visibility of this failure?
| try { | ||
| await ServeSimLogsRecorder.startAsync({ logger }); | ||
| } catch (err) { | ||
| logger.warn( |
There was a problem hiding this comment.
Same comment from above, adding Sentry here would give us visibility on this failure
| let file: Awaited<ReturnType<typeof open>> | undefined; | ||
| let body: NodeJS.ReadableStream | undefined; | ||
| const closeBody = (): void => { | ||
| (body as import('node:stream').Readable | undefined)?.destroy?.(); |
There was a problem hiding this comment.
Might be against repo convention, import type would be recommended
| payload = JSON.stringify(JSON.parse(parsed.raw)); | ||
| } | ||
| } catch { | ||
| continue; |
There was a problem hiding this comment.
Might be a silent failure we want to avoid; If serv-sim is somehow producing bad/incompatible output we want a loud failure that even bubbles up on sentry?
| let sequence: number | undefined; | ||
| try { | ||
| const parsed: unknown = JSON.parse(payload); | ||
| if ( |
There was a problem hiding this comment.
What do you think about using a Zod schema to validate here?
| }> { | ||
| let bytesWritten = 0; | ||
| let limitReached = maxBytes <= 0; | ||
| let lastSequence = since; |
There was a problem hiding this comment.
🟡 Legacy reconnect duplicates simulator logs
When a server switches to legacy records after a cursor-bearing stream, lastSequence retains the old cursor. The poller reconnects instead of capping that stream, repeatedly appending replayed records.
Learn more
A replay cursor is a sequence number returned by the envelope log protocol. The parser initializes lastSequence from since, so it returns a cursor even when the new response contains only legacy records. startAsync caps a stream without a cursor, but never caps this one. A legacy server can replay buffered logs on every reconnect, producing duplicates until collection ends.
Example: The first server emits envelope sequence 7 and disconnects. Its replacement returns data: {"pid":42} without envelopes on each connection; the client appends that record on every reconnect because it still reports sequence 7.
Recommended fix: Track whether the current response actually yielded envelope sequences separately from the incoming since. Cap reconnects after receiving legacy data even if since was supplied, and only update the replay cursor from sequences successfully written.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const collected = await ServeSimLogsRecorder.finishAsync({ logger }); | ||
| if (collected.length === 0) { | ||
| logger.info('No simulator logs collected; skipping upload.'); | ||
| return; | ||
| } | ||
| const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env); |
There was a problem hiding this comment.
🟡 Failed collection discards recorded logs
If getDeviceRunSessionIdOrThrow fails after finishAsync, the recorder clears its session and subsequent collection returns no files. Logs remain on disk but cannot be uploaded by a retry.
Learn more
Finalization transfers the recorded files to its first caller and clears the global session in finishAsync. Here the session ID is read only after finalization. If that lookup fails, the catch logs the error and the caller loses the file list; rerunning collection gets an empty list.
Example: Collection is invoked before DEVICE_RUN_SESSION_ID is available. Finalization returns one app log, then the ID lookup throws. A later call with a valid ID finds no session and uploads nothing.
Recommended fix: Validate the destination session ID before consuming the recorder result, or retain the file list for a later upload attempt.
Was this helpful? React with 👍 or 👎 to provide feedback.
| activeSession = session; | ||
| // Reserve the session before asynchronous setup so concurrent starts are harmless. | ||
| session.polling = (async () => { | ||
| const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-logs-')); |
|
@gwdp Addressed everything:
Device name makes sense as a follow-up. |
Why
User-app logs should be available across iOS session types, not just Agent Device. This follows the workflow approach Gabe and I discussed for ENG-26642 and replaces #4429.
How
I added background collection and final upload steps using the same pattern as serve-sim metrics. The collector requests
/logs?scope=user-appsand uploads the result as an App logs artifact. Replay cursors prevent duplicate records on reconnect.The collector checks that serve-sim acknowledged the filter. Older servers are skipped with a warning rather than falling back to all simulator logs. Collection remains best effort, capped at 20 MiB per device and 30 minutes, with a 30-second upload limit per device. A hard-killed worker can still lose its logs.
Rollout order is serve-sim filter support (expo/serve-sim#196), then the worker functions, then the WWW companion: https://github.com/expo/universe/pull/31439. The serve-sim filter must be released, not just merged, before the collector can use it.
Test Plan
Collector, artifact, function, and local HTTP tests pass, along with build-tools typechecking, focused lint and formatting. They cover filter acknowledgement, replay deduplication, aborts, byte limits, and uploading the exact collected bytes.
I also ran the real local WWW/worker workflow with a patched serve-sim build. The native fixture produced logs before and after a process relaunch. Stopping the session uploaded an artifact with 499 valid records and 68 fixture markers, all attributed to the app. Downloading it through the API and staging storage returned exactly the local file's bytes.
I also downloaded the artifact from the dashboard and verified that its bytes matched. The local supervisor needed a startup workaround, and verification with released package versions remains a separate check. This does not guarantee logs from before serve-sim starts.