We’re starting a LiveKit Room Composite Egress usi...
# ask-ai
v
We’re starting a LiveKit Room Composite Egress using a custom layout page (the
recordingUrl
/ 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
Copy 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();
    }
t
Thanks for reaching out, Faustin. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I hear you: egress starts (you get an egress ID) but no HLS (.m3u8/.ts) or MP4 files end up in S3. I’ll focus on the most likely root causes and give targeted checks you can run quickly, plus how to get headless Chrome logs or equivalent debugging info on LiveKit Cloud. What I’m answering: • Why LiveKit composite egress can start but produce no files. • The most common root causes and the exact checks to run first. • How to access or capture headless Chrome logs (or equivalent) when using a custom layout on LiveKit Cloud. Quick checklist to run first (high-impact checks) 1. Confirm custom layout signals readiness ◦ Ensure your custom page executes
window.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:
Copy code
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 Egress
v
On LiveKit egress console we always have STARTED and ABORTED never COMPLETE
t
Thanks — that helps. STARTED → ABORTED (never COMPLETE) means the egress process began but was terminated before finishing. Below are the most likely causes, exact checks to run now, and the one-piece-of-data that will tell us why it was aborted. What I’m answering: why egress jobs go from STARTED to ABORTED and what to check to fix it. Immediate single most important check • Capture the full
egress_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 | LiveKit
m
we are on livekit cloud
t
Thanks — helpful to know you’re on LiveKit Cloud. That narrows the likely causes and the debugging options. Below I’ll: • Confirm what’s most likely causing STARTED → ABORTED on LiveKit Cloud • Give the exact checks to run now (fast, high-value) • Explain what you can and cannot access about headless Chrome logs on LiveKit Cloud and practical alternatives • Ask for one specific piece of data that will immediately point to the root cause Why STARTED → ABORTED happens on LiveKit Cloud (summary) • The egress process began but was terminated before finishing. On LiveKit Cloud the most common reasons are: ◦ The source room ended or lost all published tracks while egress was running (egress lost media). ◦ The custom layout failed to render or never signaled readiness (
window.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:
Copy code
console.log("custom layout ready - setting window.livekitReady = true");
     window.livekitReady = true;
• Also add a global error handler to log uncaught errors:
Copy code
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.
m
i'm sure that the bot never join the room
t
Thanks — that’s a very useful detail. If the egress “bot” (the hidden egress participant / headless browser) never joins the room, that explains why you get STARTED → ABORTED and no files: there’s nothing to render or record. Let’s focus on why the egress participant isn’t joining and how to confirm it. What I’m addressing: why the egress participant never joins the room and the exact checks and fixes to run now. Quick explanation • Room Composite Egress works by launching a headless browser that joins the room as a hidden participant and renders your custom URL. If that participant never connects, there is no media to capture, so egress aborts. Immediate checks (do these now — high value, quick) 1. Capture webhook events ◦ Pull the
egress_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:
Copy code
console.log("connected, rendering tracks, setting window.livekitReady = true");
     window.livekitReady = true;
• Add global error capture to surface runtime failures:
Copy code
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.
m
here my logs : 2026-02-13 211524.699 CET GET200361 B9 msGoogleStackdriverMonitoring-UptimeChecks(https://cloud.google.com/monitoring) https://api-welya-staging.welya.io/v1/actuator/health 2026-02-13 211535.129 CET Received live kit webhook event: event: "track_unpublished" room { sid: "RM_kt2bvCFpSRch" name: "webinar-29-WB29-202604301000-9enu" } participant { sid: "PA_QeR9VLCvTLwH" identity: "presenter-T3eatnQsjUc1aWm8tBOqE07exPA3" } id: "EV_wwCbnjL5FqSF" created_at: 1771013735 track { sid: "TR_VCMBXbqpQbAvuQ" type: VIDEO width: 1280 height: 720 simulcast: true source: CAMERA layers { width: 320 height: 180 bitrate: 160000 ssrc: 4143173732 } layers { quality: MEDIUM width: 640 height: 360 bitrate: 450000 ssrc: 3291998892 } layers { quality: HIGH width: 1280 height: 720 bitrate: 1700000 ssrc: 2567685085 } mime_type: "video/VP8" mid: "1" codecs { mime_type: "video/VP8" mid: "1" cid: "6b414297-4be9-415d-a2bf-dca422d23c22" layers { width: 320 height: 180 bitrate: 160000 ssrc: 4143173732 } layers { quality: MEDIUM width: 640 height: 360 bitrate: 450000 ssrc: 3291998892 } layers { quality: HIGH width: 1280 height: 720 bitrate: 1700000 ssrc: 2567685085 } } stream: "camera" version { unix_micro: 1771013445179965 } } 2026-02-13 211535.153 CET Received live kit webhook event: event: "track_unpublished" room { sid: "RM_kt2bvCFpSRch" name: "webinar-29-WB29-202604301000-9enu" } participant { sid: "PA_QeR9VLCvTLwH" identity: "presenter-T3eatnQsjUc1aWm8tBOqE07exPA3" } id: "EV_iMQXJGaM7bAz" created_at: 1771013735 track { sid: "TR_AMFz7UbLSJwjV7" source: MICROPHONE mime_type: "audio/red" mid: "2" codecs { mime_type: "audio/red" mid: "2" cid: "a8968d69-f042-4441-a724-c1bd34de6bda" } stream: "camera" version { unix_micro: 1771013443982581 } audio_features: TF_AUTO_GAIN_CONTROL audio_features: TF_ECHO_CANCELLATION audio_features: TF_NOISE_SUPPRESSION backup_codec_policy: SIMULCAST } 2026-02-13 211535.176 CET Received live kit webhook event: event: "participant_left" room { sid: "RM_kt2bvCFpSRch" name: "webinar-29-WB29-202604301000-9enu" empty_timeout: 300 creation_time: 1771013424 enabled_codecs { mime: "audio/red" } enabled_codecs { mime: "video/H264" } enabled_codecs { mime: "video/VP8" } enabled_codecs { mime: "video/VP9" } enabled_codecs { mime: "video/AV1" } enabled_codecs { mime: "audio/opus" } enabled_codecs { mime: "audio/PCMU" } enabled_codecs { mime: "audio/PCMA" } version { unix_micro: 1771013424215929 } departure_timeout: 20 creation_time_ms: 1771013424188 } participant { sid: "PA_QeR9VLCvTLwH" identity: "presenter-T3eatnQsjUc1aWm8tBOqE07exPA3" state: DISCONNECTED metadata: "{\"role\":\"PRESENTER\",\"displayName\":\"Guillaume Martial\",\"userId\":\"T3eatnQsjUc1aWm8tBOqE07exPA3\"}" joined_at: 1771013443 name: "Guillaume Martial" version: 8 permission { can_subscribe: true can_publish: true can_publish_data: true } region: "ofrankfurt1b" is_publisher: true disconnect_reason: CLIENT_INITIATED joined_at_ms: 1771013443232 } id: "EV_P6vSJjUGn3fy" created_at: 1771013735 2026-02-13 211535.368 CET POST200319 B4 msGo-http-client/2.0 https://api-welya-staging.welya.io/v1/webhooks/livekit 2026-02-13 211535.374 CET Received live kit webhook event: event: "egress_ended" id: "EV_b2PzTMjsCG2S" created_at: 1771013735 egress_info { egress_id: "EG_zeYFcxuC4iHg" room_id: "RM_kt2bvCFpSRch" status: EGRESS_ABORTED room_composite { room_name: "webinar-29-WB29-202604301000-9enu" layout: "custom" custom_base_url: " https://staging.welya.io/recording-view?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJBUElLNGpmTjkzakx2dU4iLCJzdWIiOiJSRUNPUkRFUl9CT1QiLCJpYXQiOjE3NzEwMTM0ODYsIm5iZiI6MTc3MTAxMzQ4MSwianRpIjoiNzFiOGRiMTQtZTA0Yi00MjY1LTgwM2EtMGUyZmZjM2FjOGU0IiwiZXhwIjoxNzcxMDI3ODg2LCJ2aWRlbyI6eyJjYW5TdWJzY3JpYmUiOnRydWUsInJvb21Kb2luIjp0cnVlLCJyb29tQWRtaW4iOnRydWUsImNhblB1Ymxpc2hEYXRhIjpmYWxzZSwiY2FuUHVibGlzaCI6ZmFsc2UsInJvb20iOiJ3ZWJpbmFyLTI5LVdCMjktMjAyNjA0MzAxMDAwLTllbnUifSwibWV0YWRhdGEiOiJ7XCJyb2xlXCI6XCJSRUNPUkRFUlwiLFwiZGlzcGxheU5hbWVcIjpcIldlbHlhIFJlY29yZGVyXCIsXCJpc19yZWNvcmRlclwiOlwidHJ1ZVwifSIsIm5hbWUiOiJXZWx5YSBSZWNvcmRlciJ9.pflbR2FzzBM1Upf0Ixz6ALNZhStf2B-OgYcE2FjG4H0 " preset: H264_720P_30 file_outputs { file_type: MP4 filepath: "webinar-29-WB29-202604301000-9enu/live.mp4" s3 { access_key: "{access_key}" secret: "{secret}" region: "us-east-1" endpoint: "https://571f40f9ce4da69ef216b2aad1a0acdb.r2.cloudflarestorage.com" bucket: "welya-staging-live-mp4" force_path_style: true } } segment_outputs { protocol: HLS_PROTOCOL filename_prefix: "webinar-29-WB29-202604301000-9enu/hls/" playlist_name: "playlist.m3u8" segment_duration: 2 s3 { access_key: "{access_key}" secret: "{secret}" region: "us-east-1" endpoint: "https://571f40f9ce4da69ef216b2aad1a0acdb.r2.cloudflarestorage.com" bucket: "welya-staging-hls" force_path_style: true } live_playlist_name: "playlist-live.m3u8" } } error: "Start signal not received" started_at: 1771013488930827850 ended_at: 1771013734654076387 room_name: "webinar-29-WB29-202604301000-9enu" file_results { filename: "webinar-29-WB29-202604301000-9enu/live.mp4" started_at: 1771013734574740363 ended_at: 1771013734574740363 } segment_results { playlist_name: "webinar-29-WB29-202604301000-9enu/hls/playlist.m3u8" started_at: 1771013734574740363 ended_at: 1771013734574740363 live_playlist_name: "webinar-29-WB29-202604301000-9enu/hls/playlist-live.m3u8" } updated_at: 1771013734654076387 details: "End reason: StopEgress API" error_code: 412 } 2026-02-13 211535.401 CET GET2004.69 KB178 msChrome 144 https://api-welya-staging.welya.io/v1/workspaces/6/webinars?page=0&amp;size=10 2026-02-13 211555.964 CET POST200319 B4 msGo-http-client/2.0 https://api-welya-staging.welya.io/v1/webhooks/livekit 2026-02-13 211555.970 CET Received live kit webhook event: event: "participant_left" room { sid: "RM_kt2bvCFpSRch" name: "webinar-29-WB29-202604301000-9enu" empty_timeout: 300 creation_time: 1771013424 enabled_codecs { mime: "audio/red" } enabled_codecs { mime: "video/H264" } enabled_codecs { mime: "video/VP8" } enabled_codecs { mime: "video/VP9" } enabled_codecs { mime: "video/AV1" } enabled_codecs { mime: "audio/opus" } enabled_codecs { mime: "audio/PCMU" } enabled_codecs { mime: "audio/PCMA" } version { unix_micro: 1771013424215929 } departure_timeout: 20 creation_time_ms: 1771013424188 } participant { sid: "PA_nTVsoFX4Af56" identity: "EG_zeYFcxuC4iHg" state: DISCONNECTED joined_at: 1771013490 version: 3 permission { can_subscribe: true hidden: true recorder: true } region: "omarseille1b" kind: EGRESS disconnect_reason: CONNECTION_TIMEOUT joined_at_ms: 1771013490714 } id: "EV_Xfe82fqdLnCm" created_at: 1771013755 2026-02-13 211601.281 CET POST204266 B16 msGoogle-Cloud-Scheduler https://welya-api-c7c5vrg4mq-od.a.run.app/v1/internal/tasks/webhooks/process-pending what's wrong ?
t
Thanks — these logs are great. The webhook payload shows the exact cause: the egress aborted with error "Start signal not received" and the
egress_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:
Copy code
console.log("custom layout: setting window.livekitReady = true");
     window.livekitReady = true;
• Add a global error handler to capture runtime failures:
Copy code
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.
Thanks — this is very helpful. You’ve shown the core pieces: the egress webhook said "Start signal not received" and the egress participant disconnected with CONNECTION_TIMEOUT, but your Puppeteer run shows the page does set
window.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.
m
this code seeems good :
Copy code
import 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>
  );
}
t
Thanks — this is excellent. Your code looks correct and follows the recommended patterns for a LiveKit custom layout readiness signal. It already addresses the biggest causes of STARTED → ABORTED egress jobs. A few small checks and tweaks will make it robust on LiveKit Cloud. Summary of what’s good • You set
window.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:
Copy code
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 Recordings
m
here is the backend code :
Copy code
package 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();
    }
}
t
I don't have the answer you're looking for. You could also try asking your question: • in one of the other Slack channels or • to https://deepwiki.com/livekit/livekit_composite which is trained on all LiveKit source code If you find the answer, please post it here to help others!