Skip to content

feat(analytics): download a session's recording under --experimental-auth - #1019

Merged
Topherhindman merged 4 commits into
mainfrom
devx-799-cli-session-recording
Oct 10, 2026
Merged

Topherhindman merged 4 commits into
mainfrom
devx-799-cli-session-recording

Conversation

@Topherhindman

@Topherhindman Topherhindman commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes DEVX-799
Depends on livekit/public-api-server#49.

lk analytics session recording SESSION_ID --type audio|chat-history asks the Public API to sign a URL for a session's recording, then downloads it straight from the project's data region. Nothing passes through the API, and the user's session token never reaches the object store.

What changed

  • Regenerated client (chore(public)): oapi.gen.go is regenerated from livekit/public-api-server@7f379db, livekit/public-api-server#49's merge commit on main. It picks up GetSessionRecordingURL's response as the server now returns it: the URL's expires_at and the recording's recording_started_at replace created_at. The operation's docs now say a missing recording is NotFound and the expiry is clamped to 15 minutes. Nothing hand-written reads the changed types, so only oapi.gen.go changes.
  • Client (feat(public)):
    • GetSessionRecordingURL takes the recording by name, audio or chat-history, and rejects any other before sending a request. It returns the signed URL, its expiry and when the recording started.
    • DownloadRecording fetches the signed URL with no credentials, since the signature is the authorization. It uses its own http.Client, so no cookie jar or transport set elsewhere in the process can add any. A stalled object store times out (30s to answer, 10 minutes for the whole download) instead of hanging lk.
    • No signature in errors: a *url.Error loses the URL's query string, and long query values are hidden where an object store's error page echoes them.
    • Chat history: it's stored gzip-encoded, so a gzip body is decompressed whether or not the transport already did. A refused URL is an error naming the status, never a saved error page.
    • APIError keeps the error envelope's typed details. ObservabilityDisabled reads the dashboard link from the FailedPrecondition the server returns when user data recording is off. IsNotFound recognizes a missing recording.
  • Command (feat(analytics)):
    • Files: audio saves as SESSION_ID-audio.ogg, and chat history, decompressed, as SESSION_ID-chat-history.json. A file already at the default name is never replaced: the command refuses before asking for a URL and says to pass -o. A file -o FILE names is replaced, since the user chose it.
    • Atomic save: the download lands in a hidden temporary file that is renamed into place when complete. A failed download leaves no partial file and keeps an existing one.
    • --url-only prints the signed URL on stdout, so it pipes into curl, and its expiry on stderr. --json prints the API's response, or after a download, the saved file, its size and when the recording started.
    • Errors:
      • A missing recording says the session wasn't recorded, has expired, or ended less than a minute ago, or that there's no such session in the project, which it names. The API answers both the same way.
      • With user data recording off, it says so and links the project's observability settings.
      • A permission denial says reading the recording requires being a project admin. sessionAPIError now takes the access a read requires, and the participant list keeps saying the user doesn't have access to the project.
    • Only the Public API signs recording URLs, so the command runs only under --experimental-auth, through sessionRead.

Usage

lk --experimental-auth analytics --experimental session recording SESSION_ID \
    --type audio|chat-history [-o FILE] [--url-only] [--json]
$ lk --experimental-auth analytics --experimental session recording RM_1 --type chat-history
Saved chat history of session RM_1 to RM_1-chat-history.json (2.0 KB)
The recording started 2026-10-07 11:00

With --json:

{"sessionId":"RM_1","recording":"chat-history","file":"RM_1-chat-history.json","bytes":2048,"recordingStartedAt":"2026-10-07T11:00:00Z"}

Since review

  • 80ff1a3 fix(analytics): name the project when a recording isn't found

Testing

  • go build ./..., go vet ./pkg/... ./cmd/lk/ and go test ./pkg/... ./cmd/lk/ pass on every commit.
  • New tests:
    • pkg/public: TestGetSessionRecordingURL, TestGetSessionRecordingURLRejectsUnknownRecording, TestGetSessionRecordingURLMissingURL, TestGetSessionRecordingURLErrors, TestDownloadRecording (gzip handling), TestDownloadRecordingRefused, TestDownloadRecordingSendsNoCredentials and TestDownloadRecordingErrorsHideSignature.
    • pkg/public/render: TestRecordingURL and TestRecordingSaved.
    • cmd/lk: TestSessionRecordingCommand, TestSessionRecordingRequiresExperimentalAuth, TestSessionRecordingOptions, TestFetchSessionRecording, TestFetchSessionRecordingURLOnly, TestFetchSessionRecordingJSON, TestFetchSessionRecordingNothingToDownload, TestFetchSessionRecordingDownloadFails, TestFetchSessionRecordingExistingFile and TestDefaultRecordingFile.
  • The command above was run on staging on 2026-10-08, against public-api-server's stack run locally, with a real sign-in and staging data.
  • Known issue on staging: an expired recording's signed URL returns 404, so the download fails naming that status. That's on the server side, not in this command.

@Topherhindman
Topherhindman force-pushed the devx-793-cli-session-participants branch from fed8275 to 4859532 Compare October 9, 2026 02:57
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from f3686f4 to 1a70d9d Compare October 9, 2026 02:58
@Topherhindman
Topherhindman force-pushed the devx-793-cli-session-participants branch from 4859532 to 0800de1 Compare October 9, 2026 05:10
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from 1a70d9d to 10b2cbf Compare October 9, 2026 05:10
@Topherhindman
Topherhindman marked this pull request as ready for review October 9, 2026 06:27
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from 10b2cbf to 837b051 Compare October 9, 2026 20:26
@Topherhindman
Topherhindman force-pushed the devx-793-cli-session-participants branch from 0800de1 to 1b3665b Compare October 9, 2026 20:26
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from 837b051 to 80ff1a3 Compare October 9, 2026 20:50
Base automatically changed from devx-793-cli-session-participants to main October 10, 2026 03:09
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from 80ff1a3 to 7c8b3b1 Compare October 10, 2026 03:11
Picks up GetSessionRecordingURL's response as the server now returns
it: the URL's expires_at and the recording's recording_started_at
replace created_at, and the operation and its file_type and
expiry_seconds params carry the server's descriptions (NotFound for a
missing recording, UNSPECIFIED is InvalidArgument, expiry clamped to 15
minutes). Nothing hand-written reads the changed types, so only
oapi.gen.go changes.

Generated from livekit/public-api-server@7f379db
GetSessionRecordingURL takes the recording by name, "audio" or
"chat-history", and rejects any other before sending a request. It
returns the signed URL with its expiry and the recording's start.

DownloadRecording fetches that URL with no credentials, since the
signature is the authorization and the user's session token must not
reach the object store. It uses its own http.Client, not
http.DefaultClient, so no cookie jar or transport set elsewhere in the
process can add any, and a stalled object store times out (30s to
answer, 10 minutes for the whole download) instead of hanging lk. Its
errors keep the signature out: a *url.Error loses the URL's query
string, and the URL's long query values are hidden where an object
store's error page echoes them. Chat history is stored gzip-encoded, so
a gzip body is decompressed whether or not the transport already did;
a refused URL is an error naming the status, never a saved error page.

APIError now keeps the error envelope's typed details, which the REST
transcoder writes as JSON beside an "@type". ObservabilityDisabled
reads the dashboard link off the FailedPrecondition the server returns
when nothing was recorded because user data recording is off, and
IsNotFound recognizes a missing recording.
…auth

`lk analytics session recording SESSION_ID --type audio|chat-history`
asks the Public API to sign a URL for the recording and downloads it
straight from the project's data region; nothing passes through the
API. Audio saves as SESSION_ID-audio.ogg and chat history, decompressed,
as SESSION_ID-chat-history.json. A file already at the default name is
never replaced: the command refuses before asking for a URL and says to
pass -o to replace it. A file -o FILE names is replaced, since the user
chose it. The download lands in a hidden temporary file that is renamed
into place when complete, so a failed download leaves no partial file
and keeps an existing one.

--url-only prints the signed URL on stdout (its expiry on stderr), and
--json prints the API's response or, after a download, the saved file,
its size and when the recording started.

A missing recording says the session wasn't recorded, has expired, or
ended less than a minute ago, or that there is no such session, since
the API answers both the same way. With user data recording off it says
so and links the project's observability settings from the error's
ObservabilityDisabled detail. A permission denial says reading the
recording requires being a project admin: sessionAPIError now takes the
access a read requires, and the participant list keeps saying the user
doesn't have access to the project.

Only the Public API signs recording URLs, so the command runs only
under --experimental-auth, through sessionRead, with the signed-in
user's session token, and refuses to run otherwise before reading its
arguments.
A NotFound from the recording read said there might be no such session
"in this project". A mistyped --project answers NotFound too, so the
error now names the project it asked: "in project p_...".
@Topherhindman
Topherhindman force-pushed the devx-799-cli-session-recording branch from 7c8b3b1 to c42dea5 Compare October 10, 2026 03:12
@Topherhindman
Topherhindman merged commit ac0c259 into main Oct 10, 2026
25 of 27 checks passed
@Topherhindman
Topherhindman deleted the devx-799-cli-session-recording branch October 10, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants