victorious-pencil-53525
02/13/2026, 12:14 PMrecordingUrl / customUrl). The goal is to stream the recording output to Cloudflare so our viewer can play the HLS output via a .m3u8 playlist.
In our Java backend, we start egress with:
• HLS SegmentedFileOutput (playlist playlist.m3u8, live playlist playlist-live.m3u8, 2s segments) uploaded to an S3 bucket
• MP4 EncodedFileOutput uploaded to another S3 bucket (we also generate an .mp4, but it’s not being stored either)
However, no objects are being created in the HLS bucket (no .m3u8, no .ts segments), so the viewer never receives the HLS stream.
We use a React custom layout page that connects to the room and sets window.livekitReady = true once connected (to signal readiness to egress), and renders camera + screen-share tracks.
The egress call succeeds and returns an egress ID, but no files are produced.
Questions:
Why would LiveKit composite egress start successfully but produce no files in the S3 bucket (HLS + MP4)?
What are the most common root causes and what should we check first (egress readiness signal, customUrl reachability, credentials/permissions, bucket config, region/endpoint, egress status/errors, etc.)?
1. How can we access or view the headless Chrome logs used by the custom layout when running on LiveKit Cloud?
2. What are the most common root causes and what should we check first (egress readiness signal, customUrl reachability, credentials/permissions, bucket config, region/endpoint, egress status/errors, etc.)?
3. How can we access or view the headless Chrome logs used by the custom layout when running on LiveKit Cloud?
Our code
@Override
public String startCustomCompositeEgress(String roomName, String recordingUrl, String hlsKeyPrefix, String mp4Filepath) throws IOException {
LivekitEgress.S3Upload hlsS3UploadConfiguration = buildS3Upload(hlsBucketName);
LivekitEgress.S3Upload mp4S3UploadConfiguration = buildS3Upload(mp4BucketName);
LOGGER.info("Starting Custom Composite Egress for room: {}. Capture URL: {}", roomName, recordingUrl);
LivekitEgress.SegmentedFileOutput hlsOutputConfiguration = LivekitEgress.SegmentedFileOutput.newBuilder()
.setFilenamePrefix(hlsKeyPrefix)
.setPlaylistName("playlist.m3u8")
.setLivePlaylistName("playlist-live.m3u8")
.setSegmentDuration(2)
.setProtocol(LivekitEgress.SegmentedFileProtocol.HLS_PROTOCOL)
.setS3(hlsS3UploadConfiguration)
.build();
LivekitEgress.EncodedFileOutput mp4OutputConfiguration = LivekitEgress.EncodedFileOutput.newBuilder()
.setFileType(LivekitEgress.EncodedFileType.MP4)
.setFilepath(mp4Filepath)
.setS3(mp4S3UploadConfiguration)
.build();
EncodedOutputs egressOutputsWrapper = new EncodedOutputs(
mp4OutputConfiguration,
null,
hlsOutputConfiguration,
null
);
Call<LivekitEgress.EgressInfo> egressCall = egressClient.startRoomCompositeEgress(
roomName,
egressOutputsWrapper,
"custom",
LivekitEgress.EncodingOptionsPreset.H264_720P_30,
null,
false,
false,
recordingUrl,
AudioMixing.DEFAULT_MIXING
);
LivekitEgress.EgressInfo egressInformation = executeCall(egressCall);
if (egressInformation == null) {
throw new IOException("LiveKit startCustomCompositeEgress returned empty body");
}
LOGGER.info("Custom Egress successfully initiated with ID: {} and status: {}",
egressInformation.getEgressId(), egressInformation.getStatus());
return egressInformation.getEgressId();
}tall-airline-16756
02/13/2026, 12:14 PMtall-airline-16756
02/13/2026, 12:14 PMwindow.livekitReady = true after the page has connected to the room and video elements are rendered.
◦ Add a visible console log just before setting it: console.log("setting window.livekitReady = true"); window.livekitReady = true;
◦ If the signal never fires, the headless browser won’t start rendering and no output will be produced.
1. Verify customUrl is reachable and healthy
◦ Must be publicly accessible over HTTPS (not localhost).
◦ Request the URL from an external machine/curl to confirm 200 OK and no redirects or auth pages.
◦ Check for runtime JS errors in your page (unhandled exceptions can prevent rendering).
1. Confirm media exists in the room
◦ Egress needs tracks to record. Make sure at least one participant is publishing the expected tracks (camera/screen) at or soon after egress start.
◦ Start egress while publishers are present or ensure publishers join quickly.
1. Validate S3 configuration & permissions
◦ Confirm access key/secret (or role) are correct, and the IAM policy allows s3:PutObject (and PutObjectAcl if used).
◦ Confirm bucket name, region, and any custom endpoint are correct and match the S3Upload config.
◦ If using an AssumeRole/STS setup, confirm role trust and session permissions are correct.
1. Check output configuration fields
◦ HLS: ensure protocol is HLS (your code shows HLS_PROTOCOL — confirm enum mapping is correct).
◦ HLS playlist names and prefix are valid (no illegal chars).
◦ MP4: ensure filepath ends with .mp4 and is a valid path within the bucket.
1. Inspect egress lifecycle events (webhook)
◦ Subscribe to webhooks and capture egress_started and egress_ended events.
◦ egress_ended will include status and error details when things fail; look for EGRESS_FAILED or upload errors and any `details`/`error` text.
◦ Webhook payload often has the clearest explanation for why outputs were not written.
Why egress can start but still produce no files (common root causes)
• Readiness signal never set (most common with custom layouts).
• customUrl unreachable or has JS errors that prevent rendering in headless Chrome.
• No publisher tracks in the room, so nothing to record.
• S3 credentials or permissions incorrect — uploads fail even though egress ran.
• Region/endpoint mismatch for S3 causing silent upload failures.
• Output configuration mistakes (wrong protocol enum, missing filepath).
• Egress timed out or aborted due to headless rendering errors — check egress_ended webhook.
How to get headless Chrome logs on LiveKit Cloud
• Direct access to the headless Chrome process logs on LiveKit Cloud is not provided.
• Alternatives that produce the same debugging signals:
a. Use the egress_ended webhook payload:
• The egress_ended event often includes failure details. Capture the full payload (include `error`/`details` fields) and share it for diagnosis.
a. Add robust logging to your custom page:
• Log runtime events and errors to the page console and expose them (for example, push into a DOM element or window variable).
• Example:
window.__egressLogs = [];
const _log = console.log;
console.log = (...args) => { window.__egressLogs.push(args); _log(...args); };
window.addEventListener('error', e => window.__egressLogs.push(['error', e.message, e.filename, e.lineno]));
• This doesn't give you LiveKit Cloud console logs, but allows you to reproduce and capture the page behavior locally or via a debugging endpoint.
1. Reproduce locally (recommended)
◦ Run an equivalent Web egress or run the page in a local headless Chrome (Puppeteer) to capture console and network logs. This is the fastest way to observe the same rendering behavior and console errors.
1. Backup egress / manifest (if enabled)
◦ If backup egress is enabled for your project, the egress_ended webhook can include manifestLocation or backupStorageUsed info where you can download queued recording artifacts. Backup egress must be enabled on the project side.
Targeted debugging steps (small sequence to run now)
1. Add console logs in your custom page:
◦ Log when the room connects, when tracks are added, and when you set window.livekitReady = true.
1. Start egress while watching webhooks:
◦ Capture egress_started and full egress_ended payloads (save raw JSON).
1. If egress_ended indicates failure, paste the payload (error/details) here and I’ll help interpret it.
2. If egress_ended shows successful but no S3 objects:
◦ Re-check S3Upload config region/endpoint and credentials.
◦ Enable or request backup egress so failed uploads are preserved for inspection (backup must be enabled for your project).
If you’d like, send me:
• The egress_ended webhook payload (if available) — especially status, error, and any details.
• A screenshot or console logs from the custom page showing whether window.livekitReady ran and any JS errors.
• Confirmation of whether publishers were present at egress start.
I’ll walk through the webhook payload and the page logs with you and point to the specific next fix.
Sources: Best practices for managing webhook event streams | LiveKit | server-sdk-js | livekit/livekit_composite | DeepWiki | Backup Egress Recordings | Universal Egressvictorious-pencil-53525
02/13/2026, 12:55 PMtall-airline-16756
02/13/2026, 12:56 PMegress_ended webhook payload (or the egress info returned when the job ends). The webhook’s status and details fields will usually say exactly why it aborted (e.g., “Room ended”, “StopEgress API”, upload error). Share that payload and I can interpret it.
Most common root causes (priority order) and what to check now
1. Room ended or lost media (most common)
◦ What happens: The composite egress connects as a hidden participant. If the room closes or no tracks are published, egress loses its media source and aborts.
◦ Check: Were participants still present and publishing when egress aborted? Inspect room lifecycle — did the room get deleted or did all participants leave right after egress started?
◦ Fixes: Keep at least one publisher in the room until egress completes, or explicitly stop egress before deleting the room. Consider using empty_timeout or deleting the room only after egress_ended.
1. window.livekitReady never set / custom layout failed to render
◦ What happens: The headless Chrome page never signals readiness, so rendering doesn’t happen.
◦ Check: Verify your custom page runs window.livekitReady = true after successfully connecting and rendering tracks. Add console logs right before you set it.
◦ Fixes: Ensure the page is public HTTPS, loads without JS errors, and sets window.livekitReady after tracks are attached.
1. No published tracks or subscribe permission blocked
◦ What happens: Egress subscribes to tracks as a hidden participant; without tracks (or with subscribe disabled) there’s nothing to capture.
◦ Check: Confirm at least one track is published and that the egress participant can subscribe (token/grants include canSubscribe).
◦ Fixes: Start egress after a publisher is present; ensure JWT grants allow subscribing.
1. S3 / output configuration failures
◦ What happens: Recording may start but uploads fail; egress can abort if outputs can’t be written.
◦ Check: Validate S3 credentials, IAM policy (PutObject, PutObjectAcl as required), bucket name, region, and endpoint match S3Upload config.
◦ Fixes: Correct credentials/region or use an IAM role that’s allowed to upload.
1. Custom layout unreachable or JS errors
◦ What happens: The headless browser can’t load or runs into runtime errors and aborts.
◦ Check: curl the customUrl from a public machine to ensure 200 over HTTPS. Test the page in a headless browser locally to catch exceptions.
◦ Fixes: Fix hosting, TLS, or client-side errors.
1. Manual stop or API/room delete triggered egress stop
◦ What happens: A StopEgress API call, deleting the room, or participant removal can produce ABORTED.
◦ Check: Look for calls in your backend to stop egress or delete the room near the start time. The webhook details often says “StopEgress API” or “Room deleted”.
1. Resource/connectivity issues on egress worker
◦ What happens: Browser fails to launch or connect to LiveKit due to resource/network problems.
◦ Check: For self-hosted: monitor CPU/memory. For LiveKit Cloud: check egress_ended error text and contact LiveKit if worker instability is suspected.
◦ Fixes: Ensure adequate worker resources or escalate with the egress_ended payload.
How to get the diagnostic data we need
• Capture and share the full egress_ended webhook JSON (especially status and details). That single payload normally identifies the abort reason.
• If you don’t have webhooks, poll the egress status via the API and save the final egress info returned.
• If the webhook indicates a rendering or JS error but doesn’t include Chrome logs, reproduce the custom layout locally in headless Chrome (Puppeteer) to capture console/network logs.
Quick pragmatic checks to run right now
1. Confirm a participant was publishing at the time of egress start.
2. Add console logs to your custom page around connection, track attach, and right before window.livekitReady = true.
3. Curl your customUrl from a public host to confirm HTTPS 200 and no redirects/auth pages.
4. Verify S3 credentials, bucket name, region and IAM policy allow PutObject.
5. Capture the egress_ended webhook and paste the payload here.
If you share the egress_ended webhook payload (or the final egress info), I’ll parse the details and tell you precisely which of the above is the abort cause and the exact next step to take.
Sources: Why is there extra audio at the end of my recordings of an agent session? |... | Server-side | livekit/livekit_composite | DeepWiki | server-sdk-js | livekit/livekit_composite | DeepWiki | Backup Egress Recordings | Best practices for managing webhook event streams | LiveKitminiature-postman-32444
02/13/2026, 1:41 PMtall-airline-16756
02/13/2026, 1:42 PMwindow.livekitReady).
◦ S3 uploads or output configuration failed (credentials/region/bucket mismatch).
◦ A StopEgress/room delete or other API action caused an explicit abort.
◦ Less commonly: transient worker/browser errors on the service side.
Immediate checks to run now (do these in order)
1. Capture the final egress event
◦ Check your webhooks for the egress_ended event and save the full JSON payload (especially status, details, error, manifestLocation, and backupStorageUsed fields).
◦ Why: This payload almost always states the abort reason (e.g., “Room ended”, “StopEgress API”, upload error).
1. Confirm room and media state at start time
◦ Was at least one participant publishing video/audio when you started egress and while it was running?
◦ If the room auto-deleted or the last publisher left, egress will usually abort.
1. Verify custom layout readiness signal
◦ Ensure your page sets window.livekitReady = true after the page is connected and tracks are rendered.
◦ Add a console log immediately before setting it:
console.log("custom layout ready - setting window.livekitReady = true");
window.livekitReady = true;
• Also add a global error handler to log uncaught errors:
window.addEventListener('error', e => console.error('Layout error', e));
1. Confirm customUrl is reachable and healthy
◦ From a public host run: curl -I <https://your-custom-url> — confirm HTTPS 200 and no auth redirects.
◦ Load the page in a headless browser locally to surface runtime JS errors (Puppeteer or Chromium headless).
1. Validate S3 output configuration
◦ Check the credentials/keys or role used by LiveKit egress to write to the buckets.
◦ Confirm bucket name, region, and endpoint match what you send in S3Upload.
◦ Ensure IAM policy allows s3:PutObject (and PutObjectAcl if used).
1. Look for API-triggered stops or room deletion
◦ Search your backend logs for StopEgress, deleteRoom, or any automated job that could have run at the same time.
What you can and cannot access about headless Chrome logs on LiveKit Cloud
• Cannot: You do not get direct access to the headless Chrome process logs from LiveKit Cloud (no remote console logs or direct Chrome stderr from the managed service).
• Can / Alternatives:
a. Egress webhooks: egress_ended often includes useful textual failure details. Capture and share that payload.
b. Instrument your custom layout to surface its console logs:
• Add console logs and expose them on the page (e.g., push logs to window.__egressLogs or render them into the DOM), then reproduce and fetch the page or have the layout POST logs to a diagnostics endpoint you control.
a. Reproduce locally in headless Chrome/Puppeteer:
• This is the fastest way to see the same console/network errors that the LiveKit headless browser would see.
a. Backup egress / manifest (LiveKit Cloud feature):
• If enabled for your project, failed outputs may be available via the manifestLocation in the egress_ended webhook. If you don’t see this enabled and need it, tell me and I’ll describe the next steps.
Exactly what I need from you to pinpoint the root cause
• The full egress_ended webhook payload for one of the aborted egress jobs (the raw JSON). That will almost always identify the reason (room ended, stop API, S3 upload error, etc.).
If you can’t fetch the webhook:
• Paste the egress ID and timestamps of when you started the job and when it aborted, and confirm whether participants were still publishing at abort time.
If you want immediate quick tests I can guide you through:
• How to instrument your layout to capture console logs and expose them.
• A small Puppeteer script to run locally against your customUrl to reproduce errors.
• What exact IAM policy statements to check for S3 uploads.
Send the egress_ended payload (or egress ID + approximate timestamps + whether publishers were present) and I’ll parse it and give the precise next step.miniature-postman-32444
02/13/2026, 1:42 PMtall-airline-16756
02/13/2026, 1:43 PMegress_started and egress_ended webhook payloads for an aborted job. The egress_ended.details or error fields often say why the egress participant failed to join (network error, page load error, auth, etc.). This is the single most diagnostic item.
1. Confirm your customUrl is reachable from the public internet (HTTPS)
◦ From an external host run: curl -I https://your-custom-url
◦ Must return 200 OK (no auth redirect, no captive page). LiveKit Cloud’s headless browser must be able to fetch the page.
1. Verify the custom page successfully connects to the LiveKit room when loaded
◦ Open your customUrl in an incognito browser and confirm:
▪︎ The page connects to the room (shows participants).
▪︎ No JS errors in devtools console.
▪︎ The page sets window.livekitReady = true after tracks are rendered.
◦ If your page requires any interactive login or third-party auth, the headless browser won’t be able to complete it.
1. Ensure the page sets window.livekitReady = true reliably
◦ Must set after the page has connected and rendered tracks. Add explicit logging:
console.log("connected, rendering tracks, setting window.livekitReady = true");
window.livekitReady = true;
• Add global error capture to surface runtime failures:
window.addEventListener('error', e => console.error('layout error', e));
1. Confirm there are published tracks present (at or soon after egress start)
◦ Egress joins but will have nothing to render if no tracks are published. Ensure at least one publisher is in the room when egress starts.
1. Check token/grants and any subscribe restrictions
◦ The egress hidden participant must be able to subscribe to the room tracks. If your room policy or token generation denies subscribe permission, the egress participant may connect but be unable to receive tracks (effectively useless).
◦ Confirm your egress URL / token grants allow subscribing.
1. Check for network/firewall/CSP issues on the page
◦ If the page blocks certain network calls (CSP), or references resources behind a firewall, the headless browser may fail to fully render or connect.
If the egress participant never appears, here’s how to test/reproduce locally (fast and very telling)
• Run the custom page in a local headless Chrome (Puppeteer) and attempt to load it with the same room connection parameters the egress uses. Capture console and network logs. If it fails locally, the same failure likely happens on LiveKit Cloud.
• Instrument the page to expose console logs at a public endpoint or into the DOM so you can retrieve them after a test run.
What LiveKit Cloud can/can’t give you
• You cannot directly access the headless Chrome stdout/stderr on LiveKit Cloud.
• The best available artifacts on Cloud are:
◦ egress_started / egress_ended webhook payloads (these contain status and details).
◦ Any backup manifest if backup egress is enabled (appears in webhook as manifestLocation / backupStorageUsed).
• Because you’re on LiveKit Cloud, capturing the egress_ended webhook is essential.
Next steps I recommend (in order)
1. Capture and paste the egress_ended webhook JSON for one aborted job (I’ll parse it and point to the exact reason).
2. While you fetch that, run:
◦ curl the custom URL from a publicly accessible machine and paste response headers if non-200.
◦ Open the page in an incognito browser and check console for errors and confirm window.livekitReady is set.
1. If you want, add the small logging snippet (above) to your page and re-run an egress so console traces appear in the webhook details or your diagnostics endpoint.
If you share the egress_ended webhook payload (or the egress ID + start/abort timestamps and whether publishers were present), I’ll read the details field and give the exact next step to get the egress participant joining and the job to COMPLETE.miniature-postman-32444
02/13/2026, 8:17 PMtall-airline-16756
02/13/2026, 8:18 PMegress_ended details say "End reason: StopEgress API". That confirms the headless browser never received the readiness signal from your custom layout, so the egress participant never fully joined/streamed and the job was stopped.
What’s wrong (short)
• The custom layout did not set the required start signal (window.livekitReady = true) before the egress timeout. Because the start signal was never received, LiveKit aborted the job. The participant_left event for the egress identity shows disconnect_reason: CONNECTION_TIMEOUT, which lines up with the headless browser timing out trying to join or render.
Why the log fields matter
• error: "Start signal not received" — the headless browser loaded the page but never saw the readiness signal.
• participant (identity = your egress id) state: DISCONNECTED with disconnect_reason: CONNECTION_TIMEOUT — the egress participant attempted to join but timed out.
• details: "End reason: StopEgress API" — the job was explicitly stopped (likely by internal timeout/abort logic after the start signal failed) and marked ABORTED.
Concrete next steps to fix (do these in order)
1) Confirm and instrument the custom page
• Add an explicit console log immediately before you set the ready flag:
console.log("custom layout: setting window.livekitReady = true");
window.livekitReady = true;
• Add a global error handler to capture runtime failures:
window.addEventListener('error', e => console.error('layout uncaught error', e.message, e.filename, e.lineno));
window.addEventListener('unhandledrejection', e => console.error('layout promise rejection', e.reason));
• Add a small status element in the DOM (e.g., <div id="egressStatus">) and update it when connected and when ready. This helps you visually confirm readiness when testing.
2) Verify the page connects and can subscribe to tracks
• Open the customUrl in an incognito window and confirm:
◦ The page successfully connects to the LiveKit room.
◦ At least one track from a publisher is visible/playable.
◦ Console shows the readiness log above and no JS errors.
• If your page needs authentication or shows an interstitial, the headless browser can’t complete it — the page must be reachable and load without manual interaction.
3) Ensure the readiness signal happens after media is attached
• Set window.livekitReady = true only after you confirm the video/audio elements are attached and playing (or at least visible).
• Example sequence:
a. Connect to room.
b. Subscribe to expected tracks.
c. Attach tracks to <video> elements and wait for loadedmetadata / playing.
d. Then set window.livekitReady = true.
4) Re-run egress and capture artifacts
• Re-run the egress after making the above changes.
• Capture the egress_started and egress_ended webhook payloads again. If you still see "Start signal not received", paste the full JSON and I’ll parse it.
5) Local reproduction (highly recommended)
• Run the custom page in headless Chrome locally (Puppeteer) connecting to the same room URL/token to reproduce and capture console/network logs. This typically reveals the exact JS error or network issue the LiveKit headless browser saw.
6) Verify room/publisher timing
• Make sure a publisher is present and publishing before or soon after you start egress. If the publisher leaves before the headless browser finishes setup, the egress will have nothing to render.
Additional checks (S3 / outputs)
• Your webhook shows S3 output config present, but these will only be created if the egress successfully starts writing. Fixing the start signal is the main priority. Once egress completes, the MP4/HLS files should be written.
If you want, I can:
• Review the small snippet of your custom layout where you set window.livekitReady and suggest exact placement.
• Help craft a Puppeteer script to reproduce the page load and capture console/network logs.
Send the updated egress_ended payload after you retry (or paste your layout snippet where you set the ready flag) and I’ll guide the exact next change.tall-airline-16756
02/13/2026, 8:43 PMwindow.livekitReady = true and reports BOT_SIGNAL: READY. That narrows the problem: the page signals readiness locally, but LiveKit Cloud’s headless browser either didn’t execute the same readiness sequence or the readiness signal didn’t reach the egress process before it timed out. Let’s walk through the likely causes and exact checks/fixes.
What the logs tell us (short)
• Egress aborted with error: "Start signal not received" and details: "End reason: StopEgress API".
• Egress participant disconnected with reason: CONNECTION_TIMEOUT.
• Your Puppeteer test confirms the page is capable of setting window.livekitReady = true and shows BOT_SIGNAL: READY and speaker DOM state connected_to_speaker.
• Therefore: the readiness signal works in your test environment, but LiveKit Cloud’s headless browser either:
◦ never ran the same code path (page loaded differently), or
◦ the signal happened but before the egress worker was ready to hear it, or
◦ network/auth differences prevented the egress bot from successfully joining or subscribing after the signal (leading to a timeout and StopEgress).
Immediate focused checks (run in this order)
1) Confirm the same URL, token, and query params used by LiveKit Cloud
• LiveKit uses the custom_base_url you provided in the egress start call. Ensure the exact URL (including query params and token) you tested locally is identical to the one recorded in the webhook payload.
• If LiveKit launched the page with a different token/URL (expired token, missing params), the page might not have been able to connect as the egress participant — the readiness flag in that environment would not be set or won’t be trusted.
Action: Compare the full test URL you used in Puppeteer to the custom_base_url string in the webhook; ensure tokens are not expired and include grants needed for recorder/subscription.
2) Ensure readiness is only set after media elements are actually playing/attached
• Your Puppeteer showed window.livekitReady = true quickly — but browser autoplay/policy may prevent audio/video from actually playing in LiveKit Cloud headless instance.
• LiveKit expects the page to signal readiness after the page has successfully connected AND track(s) are subscribed/attached (or at least video frames arriving).
Action: Change your page so it sets window.livekitReady = true only after you see one of:
• the subscribed video element fires loadedmetadata and playing (or requestVideoFrameCallback shows frames), OR
• the track subscription promise resolves and playbackReady is true.
This avoids signaling before the browser actually has media.
3) Add explicit verification logs that LiveKit Cloud can see
• The headless browser environment may run differently (no user gesture), and your page currently logs to console — good. But LiveKit Cloud only records whether it got the start signal; it doesn’t forward console logs to you.
Action: When the page reaches important states, POST a small status ping to a public endpoint you control (e.g., /egress-diagnostic) with:
• event: page_loaded, token_used, subscribe_success, ready_signal_time
This will let you confirm LiveKit Cloud actually loaded the same page and reached the readiness step.
4) Token grant and recorder subscription permissions
• The recorder/eject hidden participant needs the proper permissions in the token (recorder: true, canSubscribe: true, roomJoin true). If tokens differ or recorder grants are missing/invalid, the page may set window.livekitReady locally but the egress participant cannot subscribe/receive media — then connection times out.
Action: Verify the token embedded in the URL has recorder: true, canSubscribe: true and is not expired at the time LiveKit calls the URL. If using short-lived tokens, ensure LiveKit is given a fresh token.
5) Race: readiness signal before LiveKit begins listening
• If your page sets window.livekitReady before LiveKit’s egress worker injects the egress library that listens for that flag, it may be missed.
Action: Modify the page to support late or repeated signals:
• When the page sets window.livekitReady = true, also call a small polling function that periodically (every 500ms for, say, 10s) re-asserts readiness (e.g., set a flag or ping your diagnostics endpoint). Or expose a function window.notifyLivekitEgressReady = () => { window.livekitReady = true; } and call it a few times after initial set.
6) Check connection/timeouts in webhook timestamps
• Your webhook shows started_at and ended_at close together; the egress likely timed out waiting for a readiness or media subscription and then StopEgress API was invoked internally.
Action: Re-run after the fixes and capture webhook again. If it still aborts, paste the new egress_ended payload.
7) Verify headless autoplay / audio policy differences
• Your Puppeteer log warns: "The AudioContext was not allowed to start" and "could not playback audio". LiveKit’s headless environment may block autoplay; if your readiness logic waits for audio playback and it never occurs due to autoplay policy, that could block readiness.
Action: Prefer video frame detection or subscription confirmation rather than audio playback as the readiness signal. If audio is required, ensure you handle autoplay restrictions (use muted autoplay for playback detection).
Next concrete changes to deploy (minimal changes to try immediately)
1. Delay setting window.livekitReady = true until a subscribed video element reports playing or you receive subscription.on('subscribed') callback.
Example:
• videoEl.addEventListener('playing', () => { console.log('playing — ready'); window.livekitReady = true; });
1. Add a retry/polling mechanism to re-assert readiness for 10 seconds after first setting it:
◦ setInterval(() => { if (!window.__livekitAcknowledged) { window.livekitReady = true; } }, 500);
1. Add a diagnostic POST when the page sets readiness, containing the token timestamp and a small identifier so you can correlate LiveKit loads with your logs.
If you want, I can:
• Suggest the exact minimal code snippet to wait for video frames (or subscription confirmation) and re-assert readiness safely.
• Review the token contents (redact secrets) to confirm grants and expiry are correct.
• Parse the next egress_ended payload if it still aborts after these changes.
For the quickest win, update your custom page to:
• set readiness only after a video playing or subscription success,
• add a retry that re-asserts readiness for several seconds,
• and POST a readiness diagnostic to your server so you can correlate with the LiveKit webhook.
Send me either:
• the snippet where you currently set window.livekitReady (I’ll propose exact placement), or
• the new egress_ended JSON after you test the adjusted readiness flow, and I’ll interpret it.miniature-postman-32444
02/13/2026, 8:50 PMimport type { TrackReference } from '@livekit/components-react';
import {
AudioTrack,
LiveKitRoom,
RoomAudioRenderer,
useParticipants,
useRoomContext,
useTracks,
VideoTrack,
} from '@livekit/components-react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isTrackReference } from '@livekit/components-core';
import { RoomEvent, Track } from 'livekit-client';
/**
* Utilitaire : Envoi des logs au backend pour le monitoring à distance du bot.
*/
const sendRemoteLog = async (message: string, level: 'INFO' | 'ERROR' = 'INFO') => {
const apiUrl = import.meta.env.VITE_API_URL;
try {
await fetch(`${apiUrl}/public/debug/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level,
message,
url: window.location.href,
}),
});
} catch (e) {
console.warn('Failed to send remote log:', e);
}
};
/**
* Composant Stage : Gère le rendu des flux média et détecte le démarrage effectif de la vidéo.
*/
const RecordingStage = ({ onVideoStarted }: { onVideoStarted: () => void }) => {
const room = useRoomContext();
const participants = useParticipants();
const screenShareTracks = useTracks([Track.Source.ScreenShare]);
const screenShareAudioTracks = useTracks([Track.Source.ScreenShareAudio]);
const [layoutConfig, setLayoutConfig] = useState<{
layout: 'grid' | 'presentation';
activeAsset: any;
}>({ layout: 'grid', activeAsset: null });
useEffect(() => {
const handleMetadataUpdate = (metadata?: string) => {
if (!metadata) return;
try {
const data = JSON.parse(metadata);
setLayoutConfig({
layout: data.layout || 'grid',
activeAsset: data.activeAsset || null,
});
} catch (e) {
sendRemoteLog(`Error parsing metadata: ${e}`, 'ERROR');
}
};
handleMetadataUpdate(room.metadata);
room.on(RoomEvent.RoomMetadataChanged, handleMetadataUpdate);
return () => {
room.off(RoomEvent.RoomMetadataChanged, handleMetadataUpdate);
};
}, [room]);
/**
* Sélection du présentateur : on exclut le bot (isLocal) pour éviter l'écran noir.
* On utilise getTrackPublications().length pour le typage correct.
*/
const mainSpeaker = useMemo(() => {
return participants.find(
(p) => !p.isLocal && (p.permissions?.canPublish || p.getTrackPublications().length > 0),
);
}, [participants]);
const speakerTrack: TrackReference | undefined = useMemo(() => {
if (!mainSpeaker) return undefined;
const publication = mainSpeaker.getTrackPublication(Track.Source.Camera);
return publication
? { participant: mainSpeaker, source: Track.Source.Camera, publication }
: undefined;
}, [mainSpeaker]);
const presentationTrackRef = useMemo(() => {
const ref = screenShareTracks.find(
(t) => isTrackReference(t) && t.publication.trackName === 'presentation',
);
return isTrackReference(ref) ? ref : null;
}, [screenShareTracks]);
const presentationAudioTrackRef = useMemo(() => {
const ref = screenShareAudioTracks.find(
(t) => isTrackReference(t) && t.publication.trackName === 'presentation_audio',
);
return isTrackReference(ref) ? ref : null;
}, [screenShareAudioTracks]);
const isPresentation = layoutConfig.layout === 'presentation' && layoutConfig.activeAsset;
return (
<div className="w-full h-full bg-black overflow-hidden relative border-none select-none">
<div id="egress-status" style={{ display: 'none' }}>
{mainSpeaker ? 'connected_to_speaker' : 'waiting_for_speaker'}
</div>
{/* Calque Présentation */}
<div
className={`absolute inset-0 transition-opacity duration-700 ${
isPresentation ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
{isPresentation && presentationTrackRef?.publication.isSubscribed && (
<>
<VideoTrack
trackRef={presentationTrackRef}
className="w-full h-full object-contain"
onLoadedMetadata={onVideoStarted}
/>
{presentationAudioTrackRef?.publication.isSubscribed && (
<AudioTrack trackRef={presentationAudioTrackRef} />
)}
</>
)}
</div>
{/* Calque Speaker */}
<div
className={`transition-all duration-700 ease-in-out ${
isPresentation
? 'absolute top-4 right-4 w-80 aspect-video z-50 rounded-2xl shadow-2xl overflow-hidden border-2 border-white/20'
: 'relative w-full h-full z-20 flex items-center justify-center p-4'
}`}
>
{speakerTrack ? (
<VideoTrack
trackRef={speakerTrack}
className="w-full h-full object-cover rounded-xl"
onLoadedMetadata={onVideoStarted}
/>
) : (
<div className="text-gray-500 font-medium text-sm">Attente du flux caméra...</div>
)}
</div>
</div>
);
};
/**
* Page de vue pour l'enregistrement LiveKit.
*/
export default function LiveKitRecordingViewPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
// On priorise l'URL passée par LiveKit Cloud dans la query string
const serverUrl = searchParams.get('url') || import.meta.env.VITE_LIVEKIT_WS_URL;
const signaled = useRef(false);
const signalIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
/**
* Fonction "Nucleaire" : Déclenche et maintient le signal READY pour le moteur d'Egress.
*/
const startPersistentSignal = useCallback(() => {
if (signaled.current) return;
signaled.current = true;
// Log explicite pour les outils internes de LiveKit Cloud
console.log('custom layout: setting window.livekitReady = true');
sendRemoteLog('RECORDER_EVENT: Signal loop started.');
signalIntervalRef.current = setInterval(() => {
(window as any).livekitReady = true;
(globalThis as any).livekitReady = true;
// Signal via attribut DOM pour une détection maximale
document.body.setAttribute('data-livekit-ready', 'true');
}, 500);
console.log('BOT_SIGNAL: READY');
}, []);
useEffect(() => {
(window as any).livekitReady = false;
sendRemoteLog('RECORDER_EVENT: Component mounted.');
// Capture des erreurs recommandée par l'IA LiveKit Assistant
const handleGlobalError = (e: ErrorEvent) => {
console.error('layout uncaught error', e.message, e.filename, e.lineno);
sendRemoteLog(`JS_CRITICAL_ERROR: ${e.message}`, 'ERROR');
};
const handlePromiseRejection = (e: PromiseRejectionEvent) => {
console.error('layout promise rejection', e.reason);
sendRemoteLog(`PROMISE_REJECTION: ${e.reason}`, 'ERROR');
};
window.addEventListener('error', handleGlobalError);
window.addEventListener('unhandledrejection', handlePromiseRejection);
// Parachute de sécurité après 15s : on force le signal si aucun média n'est détecté
const safetyTimeout = setTimeout(() => {
if (!signaled.current) {
sendRemoteLog('RECORDER_WARN: 15s Safety timeout. Forcing readiness signal.', 'ERROR');
startPersistentSignal();
}
}, 15000);
return () => {
window.removeEventListener('error', handleGlobalError);
window.removeEventListener('unhandledrejection', handlePromiseRejection);
clearTimeout(safetyTimeout);
if (signalIntervalRef.current) clearInterval(signalIntervalRef.current);
};
}, [startPersistentSignal]);
const handleOnConnected = () => {
sendRemoteLog('RECORDER_EVENT: WebRTC Connected.');
// On peut aussi déclencher ici si on veut un signal dès la connexion réseau
};
if (!token) {
return (
<div className="bg-black text-white h-screen flex items-center justify-center">
Démarrage de l'enregistreur : Token manquant.
</div>
);
}
return (
<LiveKitRoom
token={token}
serverUrl={serverUrl}
connect={true}
audio={false}
video={false}
onConnected={handleOnConnected}
onDisconnected={() => {
sendRemoteLog('RECORDER_ERROR: WebRTC Disconnected.', 'ERROR');
if (signalIntervalRef.current) clearInterval(signalIntervalRef.current);
}}
>
<div
id="capture-area"
style={{
width: '1280px',
height: '720px',
backgroundColor: '#000',
position: 'relative',
overflow: 'hidden',
}}
>
<RoomAudioRenderer />
<RecordingStage onVideoStarted={startPersistentSignal} />
</div>
</LiveKitRoom>
);
}tall-airline-16756
02/13/2026, 8:51 PMwindow.livekitReady (and globalThis + DOM attribute) repeatedly via an interval—this is resilient to timing/race issues.
• You trigger readiness from onLoadedMetadata, which ties readiness to actual media playback.
• You send remote logs to your backend so you can correlate page events with LiveKit webhooks.
• You include a safety timeout to force the signal after 15s so egress won’t hang forever.
Why you still saw “Start signal not received”
• The webhook you showed earlier reported error: "Start signal not received" and disconnect_reason: CONNECTION_TIMEOUT. That means LiveKit’s headless browser either didn’t see the readiness signal in time, or the egress bot couldn’t complete the connection (token/subscribe issues or autoplay/race conditions). Your Puppeteer test shows the page can set the signal locally — so the remaining possibilities are:
◦ LiveKit loaded a different URL/token than you tested (expired or missing grants).
◦ The page signaled readiness before the recorder code in the headless context was ready to detect it.
◦ Autoplay/track playback didn’t actually start in the headless environment (audio autoplay can be blocked).
◦ The egress bot could not subscribe because of token grants or room state.
Small, targeted changes to prevent recurrence (apply in this order)
1. Ensure readiness only after a playback-confirming event
◦ You already use onLoadedMetadata. Prefer checking playing or requestVideoFrameCallback where possible:
▪︎ videoEl.addEventListener('playing', () => startPersistentSignal());
▪︎ or use requestVideoFrameCallback to confirm frames are arriving before signaling.
1. Keep your persistent re-assertion (you already do)
◦ Your interval every 500ms for a few seconds is good. To be extra safe, re-assert for ~10s after first set.
1. Add a “late listener” re-assertion to handle race timing
◦ If LiveKit injects a listener after the page runs, ensure your page can re-assert readiness (you already do with interval). Also expose a function:
window.notifyLivekitEgressReady = () => { window.livekitReady = true; };
• This allows the recorder to call it if needed (some setups re-check DOM attributes).
1. Prefer video-frame detection over audio playback
◦ Headless environments often block audio autoplay. Detect playing or video frames instead of relying on audio.
1. Confirm the token in the custom_base_url is valid for recorder joins
◦ Ensure recorder grants include recorder: true, canSubscribe: true, roomJoin: true, and that the token is not expired at page load.
1. Keep your remote logging and add a readiness POST
◦ When you set the readiness flag, POST a small payload to your debug endpoint with the token id/timestamp and BOT_SIGNAL: READY so you can correlate with LiveKit’s webhook timestamps.
1. Validate room state before or during egress start
◦ Make sure at least one publisher is present. If publishers disconnect immediately, egress will have nothing to record.
What to check next (quick checklist)
• Confirm custom_base_url token & query params in webhook match what you tested locally.
• Check remote logs after an egress run for: “RECORDER_EVENT: Signal loop started.” and “BOT_SIGNAL: READY”.
• Re-run egress and capture egress_ended webhook. If it still says "Start signal not received", paste the full JSON and I’ll parse it.
• If you want, add a short Puppeteer run to load the exact custom_base_url with the same token and capture console logs — I can provide a script.
If you’d like, paste the exact snippet where you attach onLoadedMetadata / trigger startPersistentSignal (you already included it — but if you want I’ll suggest the minimal edit to await playing frames). Also share one aborted egress_ended payload after you deploy these small changes and we’ll confirm the result.
Sources: Universal Egress | How to Send Logs from the JavaScript SDK to Providers like Datadog | How to Send Logs from the JavaScript SDK to External Providers | LiveKit | server-sdk-js | livekit/livekit_composite | DeepWiki | Backup Egress Recordingsminiature-postman-32444
02/13/2026, 9:17 PMpackage io.welya.api.service.livekit;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.livekit.server.AudioMixing;
import io.livekit.server.EgressServiceClient;
import io.livekit.server.EncodedOutputs;
import io.livekit.server.RoomServiceClient;
import io.welya.api.config.LiveKitProperties;
import io.welya.api.config.R2Properties;
import io.welya.api.exception.WelyaException;
import livekit.LivekitEgress;
import livekit.LivekitModels;
import livekit.LivekitRoom;
import okhttp3.ResponseBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import retrofit2.Call;
import retrofit2.Response;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
@Service
@Profile("!test")
public class LiveKitGatewayImpl implements LiveKitGateway {
private static final Logger LOGGER = LoggerFactory.getLogger(LiveKitGatewayImpl.class);
private static final String LAYOUT_SCREEN_SHARE = "screen-share";
private static final String CAN_SUBSCRIBE = "canSubscribe";
private static final String CAN_PUBLISH = "canPublish";
private static final String CAN_PUBLISH_DATA = "canPublishData";
private static final String ROOM_ADMIN = "roomAdmin";
private final RoomServiceClient roomServiceClient;
private final EgressServiceClient egressClient;
private final LiveKitProperties liveKitProperties;
private final R2Properties r2Properties;
private final String hlsBucketName;
private final String mp4BucketName;
private final ObjectMapper objectMapper;
public LiveKitGatewayImpl(RoomServiceClient roomServiceClient,
EgressServiceClient egressClient,
LiveKitProperties liveKitProperties,
R2Properties r2Properties,
@Value("${cloudflare.r2.buckets.hls-bucket-name}") String hlsBucketName,
@Value("${cloudflare.r2.buckets.mp4-bucket-name}") String mp4BucketName,
ObjectMapper objectMapper) {
this.roomServiceClient = roomServiceClient;
this.egressClient = egressClient;
this.liveKitProperties = liveKitProperties;
this.r2Properties = r2Properties;
this.hlsBucketName = hlsBucketName;
this.mp4BucketName = mp4BucketName;
this.objectMapper = objectMapper;
}
@Override
public String generateToken(String roomName, String identity, Role role, Map<String, String> metadata) {
Map<String, Object> videoGrantMap = getRolesMap(roomName, role);
Instant nowInstant = Instant.now();
Instant expirationInstant = nowInstant.plusSeconds(2L * 60L * 60L);
String metadataJson;
try {
metadataJson = objectMapper.writeValueAsString(metadata);
} catch (JsonProcessingException exception) {
throw new WelyaException("Failed to serialize token metadata", exception);
}
return Jwts.builder()
.header().add("typ", "JWT")
.and()
.issuer(liveKitProperties.getApiKey())
.subject(identity)
.issuedAt(Date.from(nowInstant))
.notBefore(Date.from(nowInstant.minusSeconds(5)))
.id(UUID.randomUUID().toString())
.expiration(Date.from(expirationInstant))
.claim("video", videoGrantMap)
.claim("metadata", metadataJson)
.claim("name", metadata.getOrDefault("displayName", identity))
.signWith(Keys.hmacShaKeyFor(liveKitProperties.getApiSecret().getBytes(StandardCharsets.UTF_8)))
.compact();
}
@Override
public void createRoom(String roomName) throws IOException {
Call<LivekitModels.Room> call = roomServiceClient.createRoom(roomName);
LivekitModels.Room body = executeCall(call);
if (body == null) {
throw new IOException("LiveKit createRoom returned empty body");
}
}
@Override
public List<LivekitModels.ParticipantInfo> listParticipants(String roomName) throws IOException {
Call<List<LivekitModels.ParticipantInfo>> call = roomServiceClient.listParticipants(roomName);
List<LivekitModels.ParticipantInfo> body = executeCall(call);
if (body == null) {
return List.of();
}
return body.stream()
.sorted(Comparator.comparing(LivekitModels.ParticipantInfo::getIdentity, Comparator.nullsLast(String::compareTo)))
.toList();
}
@Override
public void removeParticipant(String roomName, String participantIdentity) throws IOException {
Call<Void> call = roomServiceClient.removeParticipant(roomName, participantIdentity);
executeExpectNoBody(call);
}
@Override
public void muteTrack(String roomName, String participantIdentity, String trackSid, boolean muted) throws IOException {
Call<LivekitModels.TrackInfo> call = roomServiceClient.mutePublishedTrack(roomName, participantIdentity, trackSid, muted);
executeCall(call);
}
private static <T> T executeCall(Call<T> call) throws IOException {
Response<T> response = call.execute();
if (!response.isSuccessful()) {
String errorBodyContent = null;
try (ResponseBody errorBody = response.errorBody()) {
if (errorBody != null) {
errorBodyContent = errorBody.string();
}
} catch (IOException exception) {
LOGGER.warn("Failed to read error body during LiveKit API call");
}
StringBuilder messageBuilder = new StringBuilder("LiveKit API error: HTTP ")
.append(response.code())
.append(" - ");
messageBuilder.append(response.message());
if (errorBodyContent != null && !errorBodyContent.isBlank()) {
messageBuilder.append(" | body=").append(errorBodyContent);
}
throw new IOException(messageBuilder.toString());
}
return response.body();
}
private static void executeExpectNoBody(Call<Void> call) throws IOException {
Response<Void> response = call.execute();
if (!response.isSuccessful()) {
String errorBodyContent = null;
try (ResponseBody errorBody = response.errorBody()) {
if (errorBody != null) {
errorBodyContent = errorBody.string();
}
} catch (IOException exception) {
LOGGER.warn("Failed to read error body during LiveKit API call (expect no body)");
}
StringBuilder messageBuilder = new StringBuilder("LiveKit API error on removeParticipant: HTTP ")
.append(response.code())
.append(" - ");
messageBuilder.append(response.message());
if (errorBodyContent != null && !errorBodyContent.isBlank()) {
messageBuilder.append(" | body=").append(errorBodyContent);
}
throw new IOException(messageBuilder.toString());
}
}
private static Map<String, Object> getRolesMap(String roomName, Role role) {
Map<String, Object> videoGrantMap = new HashMap<>();
videoGrantMap.put("room", roomName);
videoGrantMap.put("roomJoin", true);
switch (role) {
case VIEWER -> {
videoGrantMap.put(CAN_SUBSCRIBE, true);
videoGrantMap.put(CAN_PUBLISH, false);
videoGrantMap.put(CAN_PUBLISH_DATA, false);
}
case SPEAKER -> {
videoGrantMap.put(CAN_SUBSCRIBE, true);
videoGrantMap.put(CAN_PUBLISH, true);
videoGrantMap.put(CAN_PUBLISH_DATA, true);
}
case MODERATOR -> {
videoGrantMap.put(CAN_SUBSCRIBE, true);
videoGrantMap.put(CAN_PUBLISH, false);
videoGrantMap.put(CAN_PUBLISH_DATA, false);
videoGrantMap.put(ROOM_ADMIN, true);
}
case ADMIN -> {
videoGrantMap.put(CAN_SUBSCRIBE, true);
videoGrantMap.put(CAN_PUBLISH, true);
videoGrantMap.put(CAN_PUBLISH_DATA, true);
videoGrantMap.put(ROOM_ADMIN, true);
}
}
return videoGrantMap;
}
@Override
public void updateRoomMetadata(String roomName, String metadata) throws IOException {
var request = LivekitRoom.UpdateRoomMetadataRequest.newBuilder()
.setRoom(roomName)
.setMetadata(metadata)
.build();
Call<LivekitModels.Room> call = roomServiceClient.updateRoomMetadata(request.getRoom(), request.getMetadata());
executeCall(call);
}
@Override
public String generateRecorderToken(String roomName) {
Map<String, Object> videoGrantMap = new HashMap<>();
videoGrantMap.put("room", roomName);
videoGrantMap.put("roomJoin", true);
videoGrantMap.put("recorder", true);
videoGrantMap.put(ROOM_ADMIN, true);
videoGrantMap.put(CAN_PUBLISH, false);
videoGrantMap.put(CAN_SUBSCRIBE, true);
videoGrantMap.put(CAN_PUBLISH_DATA, false);
Instant nowInstant = Instant.now();
Instant expirationInstant = nowInstant.plusSeconds(14400);
Map<String, String> metadataMap = new HashMap<>();
metadataMap.put("displayName", "Welya Recorder");
metadataMap.put("is_recorder", "true");
metadataMap.put("role", "RECORDER");
String metadataJson;
try {
metadataJson = objectMapper.writeValueAsString(metadataMap);
} catch (JsonProcessingException exception) {
throw new WelyaException("Failed to serialize recorder metadata", exception);
}
return Jwts.builder()
.header().add("typ", "JWT")
.and()
.issuer(liveKitProperties.getApiKey())
.subject("RECORDER_BOT")
.issuedAt(Date.from(nowInstant))
.notBefore(Date.from(nowInstant.minusSeconds(5)))
.id(UUID.randomUUID().toString())
.expiration(Date.from(expirationInstant))
.claim("video", videoGrantMap)
.claim("metadata", metadataJson)
.claim("name", "Welya Recorder")
.signWith(Keys.hmacShaKeyFor(liveKitProperties.getApiSecret().getBytes(StandardCharsets.UTF_8)))
.compact();
}
}tall-airline-16756
02/13/2026, 9:18 PM