diff --git a/cmd/lk/analytics.go b/cmd/lk/analytics.go
index e840917b..94f58f76 100644
--- a/cmd/lk/analytics.go
+++ b/cmd/lk/analytics.go
@@ -22,6 +22,8 @@ import (
"io"
"net/http"
"net/url"
+ "os"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -85,6 +87,14 @@ var (
},
},
},
+ {
+ Name: "recording",
+ Usage: "Download a session's audio or chat history (requires --experimental-auth)",
+ UsageText: "lk analytics session recording SESSION_ID --type audio|chat-history [-o FILE] [--url-only]",
+ ArgsUsage: "SESSION_ID",
+ Action: sessionRead(sessionRecordingOptionsFrom, fetchSessionRecording),
+ Flags: append([]cli.Flag{jsonFlag}, analyticsRecordingFlags()...),
+ },
},
},
},
@@ -221,6 +231,28 @@ func analyticsParticipantListFlags() []cli.Flag {
}, pageFlags(defaultPageLimit, "participants")...)
}
+// analyticsRecordingFlags returns fresh instances of the recording download's
+// own flags (the shared jsonFlag is added by the command), for the same reason
+// as analyticsSessionListFlags. --type isn't Required at the flag level so the
+// --experimental-auth gate speaks first.
+func analyticsRecordingFlags() []cli.Flag {
+ return []cli.Flag{
+ &cli.StringFlag{
+ Name: "type",
+ Usage: "Recording `TYPE` to fetch: audio or chat-history (required)",
+ },
+ &cli.StringFlag{
+ Name: "output",
+ Aliases: []string{"o"},
+ Usage: "Save to `FILE`, replacing it if it exists (default SESSION_ID-audio.ogg or SESSION_ID-chat-history.json, never replaced)",
+ },
+ &cli.BoolFlag{
+ Name: "url-only",
+ Usage: "Print the signed download URL (valid for 15 minutes) instead of downloading",
+ },
+ }
+}
+
// analyticsListModeFlags: --page (offset) exists only on the API-key analytics
// endpoint, and the Public API session list is cursor-paginated. --start/--end
// work in both modes. The filter flags are sent only to the Public API for now.
@@ -628,11 +660,11 @@ func getUserAnalyticsSession(ctx context.Context, cmd *cli.Command) error {
}
// sessionRead builds the action of a command that reads one thing about a
-// session — its participants — which only the Public API serves. The action
-// refuses to run without --experimental-auth before checking anything else,
-// then reads the SESSION_ID argument and the command's options, so a bad flag
-// fails before the project lookup, and hands fetch a client signed in as the
-// user and the selected project.
+// session — its participants or recordings — which only the Public API
+// serves. The action refuses to run without --experimental-auth before
+// checking anything else, then reads the SESSION_ID argument and the
+// command's options, so a bad flag fails before the project lookup, and hands
+// fetch a client signed in as the user and the selected project.
func sessionRead[O any](
readOptions func(*cli.Command) (O, error),
fetch func(ctx context.Context, client *public.Client, projectID, sessionID string, opts O, asJSON bool) error,
@@ -667,7 +699,7 @@ func sessionRead[O any](
func fetchSessionParticipants(ctx context.Context, client *public.Client, projectID, sessionID string, opts public.ParticipantListOptions, asJSON bool) error {
participants, nextCursor, err := client.ListSessionParticipants(ctx, projectID, sessionID, opts)
if err != nil {
- return sessionReadError(err, projectID, sessionID)
+ return sessionReadError(err, projectID, sessionID, "participants", projectReadAccess)
}
return render.SessionParticipantsPage(out, asJSON, participants, nextCursor)
}
@@ -690,6 +722,133 @@ func participantListOptions(cmd *cli.Command) (public.ParticipantListOptions, er
return opts, nil
}
+// sessionRecordingOptions is what `session recording` fetches and where it
+// puts it.
+type sessionRecordingOptions struct {
+ public.RecordingURLOptions
+ // Output is the file to save to; empty picks defaultRecordingFile.
+ Output string
+ // URLOnly prints the signed URL instead of downloading.
+ URLOnly bool
+}
+
+// sessionRecordingOptionsFrom reads the recording flags. A missing or unknown
+// type fails here, before the project lookup.
+func sessionRecordingOptionsFrom(cmd *cli.Command) (sessionRecordingOptions, error) {
+ opts := sessionRecordingOptions{
+ RecordingURLOptions: public.RecordingURLOptions{Recording: strings.ToLower(strings.TrimSpace(cmd.String("type")))},
+ Output: cmd.String("output"),
+ URLOnly: cmd.Bool("url-only"),
+ }
+ if err := opts.Validate(); err != nil {
+ return sessionRecordingOptions{}, err
+ }
+ if opts.URLOnly && opts.Output != "" {
+ return sessionRecordingOptions{}, errors.New("--output can't be used with --url-only")
+ }
+ return opts, nil
+}
+
+// fetchSessionRecording asks the Public API to sign a URL for the recording,
+// then prints the URL or downloads it to a file. The download goes straight to
+// the object store; nothing passes through the API.
+func fetchSessionRecording(ctx context.Context, client *public.Client, projectID, sessionID string, opts sessionRecordingOptions, asJSON bool) error {
+ // A file -o names is replaced; the default name never replaces a file, so
+ // running the command twice can't silently overwrite the first download.
+ file := opts.Output
+ if file == "" && !opts.URLOnly {
+ file = defaultRecordingFile(sessionID, opts.Recording)
+ if _, err := os.Lstat(file); err == nil {
+ return fmt.Errorf("%s already exists; pass -o %s to replace it, or -o FILE to save elsewhere", file, file)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+
+ res, err := client.GetSessionRecordingURL(ctx, projectID, sessionID, opts.RecordingURLOptions)
+ if err != nil {
+ return sessionRecordingError(err, projectID, sessionID, opts.Recording)
+ }
+ if opts.URLOnly {
+ return render.RecordingURL(out, asJSON, *res)
+ }
+
+ n, err := saveRecording(ctx, *res.Url, file)
+ if err != nil {
+ return err
+ }
+ return render.RecordingSaved(out, asJSON, render.SavedRecording{
+ SessionID: sessionID,
+ Recording: opts.Recording,
+ File: file,
+ Bytes: n,
+ RecordingStartedAt: res.RecordingStartedAt,
+ })
+}
+
+// defaultRecordingFile names a downloaded recording in the working directory:
+// Ogg for audio, JSON for chat history. A path separator in the session id
+// can't send the file elsewhere.
+func defaultRecordingFile(sessionID, recording string) string {
+ ext := ".ogg"
+ if recording == public.RecordingChatHistory {
+ ext = ".json"
+ }
+ name := strings.NewReplacer("/", "_", `\`, "_").Replace(sessionID)
+ return name + "-" + recording + ext
+}
+
+// saveRecording downloads a signed recording URL to path. It writes a hidden
+// temporary file beside path (readable only by the user, like the recording
+// it holds) and renames it into place once the download completes, so a
+// failed download leaves no partial file and an existing file stays intact.
+func saveRecording(ctx context.Context, signedURL, path string) (int64, error) {
+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.part")
+ if err != nil {
+ return 0, err
+ }
+ n, err := public.DownloadRecording(ctx, signedURL, tmp)
+ if cerr := tmp.Close(); err == nil {
+ err = cerr
+ }
+ if err == nil {
+ err = os.Rename(tmp.Name(), path)
+ }
+ if err != nil {
+ _ = os.Remove(tmp.Name())
+ return 0, err
+ }
+ return n, nil
+}
+
+// sessionRecordingError explains why a session has nothing to download, and
+// otherwise annotates the error like the other Public API commands.
+func sessionRecordingError(err error, projectID, sessionID, recording string) error {
+ label := render.RecordingLabel(recording)
+ if dashboardURL, ok := public.ObservabilityDisabled(err); ok {
+ return observabilityDisabledError(sessionID, label, dashboardURL)
+ }
+ if public.IsNotFound(err) {
+ // The API answers an unknown session, or a mistyped --project, the same
+ // way, so say so too and name the project.
+ return fmt.Errorf("session %s has no %s (%w): it wasn't recorded, has expired, "+
+ "or the session ended less than a minute ago and the recording is still being indexed; "+
+ "or there is no such session in project %s", sessionID, label, err, projectID)
+ }
+ return sessionAPIError(err, label, projectAdminAccess)
+}
+
+// observabilityDisabledError says a session has nothing to read because the
+// project's user data recording is off, and where an admin turns it on.
+func observabilityDisabledError(sessionID, what, dashboardURL string) error {
+ where := "in the project's settings on the LiveKit Cloud dashboard"
+ if dashboardURL != "" {
+ where = "at " + dashboardURL
+ }
+ return fmt.Errorf("session %s has no %s: user data recording is off for this project, so nothing was recorded. "+
+ "A project admin can turn it on %s; it records new sessions only", sessionID, what, where)
+}
+
// pageOptions reads a session read's --limit and --cursor flags. A limit
// that isn't positive fails here, before the project lookup.
func pageOptions(cmd *cli.Command) (public.PageOptions, error) {
@@ -703,20 +862,35 @@ func pageOptions(cmd *cli.Command) (public.PageOptions, error) {
// sessionReadError annotates a session read's error like sessionAPIError,
// except NotFound: the API answers an unknown session and a mistyped
// --project the same way, so it names the session and the project it asked.
-func sessionReadError(err error, projectID, sessionID string) error {
+// what names what the read returns, and access is what the read requires.
+func sessionReadError(err error, projectID, sessionID, what string, access sessionReadAccess) error {
if public.IsNotFound(err) {
return fmt.Errorf("no session %s in project %s (%w)", sessionID, projectID, err)
}
- return sessionAPIError(err)
+ return sessionAPIError(err, what, access)
}
+// sessionReadAccess is the project access a session read requires.
+type sessionReadAccess int
+
+const (
+ // projectReadAccess reads: a session's participants.
+ projectReadAccess sessionReadAccess = iota
+ // projectAdminAccess reads, which can hold user data: a session's
+ // recordings.
+ projectAdminAccess
+)
+
// sessionAPIError annotates a Public API error from a session read like
// cloudAPIError, except a permission denial: cloudAPIError suggests API-key
// credentials, which these Public-API-only reads can't use, so it says what
-// the read requires instead: access to the project.
-func sessionAPIError(err error) error {
+// the read requires instead. what names what the read returns.
+func sessionAPIError(err error, what string, access sessionReadAccess) error {
if !public.IsPermissionDenied(err) {
return cloudAPIError(err)
}
+ if access == projectAdminAccess {
+ return fmt.Errorf("%w — reading a session's %s requires being a project admin", err, what)
+ }
return fmt.Errorf("%w — you don't have access to this project", err)
}
diff --git a/cmd/lk/analytics_test.go b/cmd/lk/analytics_test.go
index babbb3bc..c6aec989 100644
--- a/cmd/lk/analytics_test.go
+++ b/cmd/lk/analytics_test.go
@@ -16,11 +16,15 @@ package main
import (
"bytes"
+ "compress/gzip"
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"net/url"
+ "os"
+ "path/filepath"
"testing"
"time"
@@ -479,6 +483,141 @@ func TestFetchSessionParticipantsErrors(t *testing.T) {
}
}
+func TestSessionRecordingCommand(t *testing.T) {
+ analyticsCmd := findCommandByName(AnalyticsCommands, "analytics")
+ require.NotNil(t, analyticsCmd)
+ sessionCmd := findCommandByName(analyticsCmd.Commands, "session")
+ require.NotNil(t, sessionCmd)
+ recordingCmd := findCommandByName(sessionCmd.Commands, "recording")
+ require.NotNil(t, recordingCmd, "'analytics session recording' command must exist")
+ require.NotNil(t, recordingCmd.Action)
+ for _, name := range []string{"type", "output", "url-only", "json"} {
+ assert.NotNil(t, findFlagByName(recordingCmd.Flags, name), "--%s", name)
+ }
+}
+
+// TestSessionRecordingRequiresExperimentalAuth checks the recording download,
+// which has no API-key endpoint, refuses to run without --experimental-auth
+// before reading its arguments or any config.
+func TestSessionRecordingRequiresExperimentalAuth(t *testing.T) {
+ for _, args := range [][]string{
+ {"--experimental", "session", "recording", "RM_1", "--type", "audio"},
+ {"--experimental", "session", "recording", "RM_1", "--type", "bogus"},
+ {"--experimental", "session", "recording"},
+ } {
+ err := runAnalytics(args...)
+ require.ErrorContains(t, err, "only available under --experimental-auth")
+ }
+}
+
+// sessionRecordingCmdOptions runs sessionRecordingOptionsFrom with the given
+// arguments on a command built from fresh analyticsRecordingFlags.
+func sessionRecordingCmdOptions(t *testing.T, args ...string) (sessionRecordingOptions, error) {
+ t.Helper()
+ var opts sessionRecordingOptions
+ var optsErr error
+ cmd := &cli.Command{
+ Name: "recording",
+ Flags: analyticsRecordingFlags(),
+ Action: func(_ context.Context, cmd *cli.Command) error {
+ opts, optsErr = sessionRecordingOptionsFrom(cmd)
+ return nil
+ },
+ }
+ require.NoError(t, cmd.Run(context.Background(), append([]string{"recording"}, args...)))
+ return opts, optsErr
+}
+
+func TestSessionRecordingOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ want sessionRecordingOptions
+ wantErr string
+ }{
+ {
+ name: "audio",
+ args: []string{"--type", "audio"},
+ want: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}},
+ },
+ {
+ name: "chat history to a file",
+ args: []string{"--type", "chat-history", "-o", "chat.json"},
+ want: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "chat-history"}, Output: "chat.json"},
+ },
+ {
+ name: "url only",
+ args: []string{"--type", "audio", "--url-only"},
+ want: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}, URLOnly: true},
+ },
+ {name: "type is required", wantErr: `recording type is required ("audio" or "chat-history")`},
+ {name: "unknown type", args: []string{"--type", "transcript"}, wantErr: `invalid recording type "transcript"`},
+ {
+ name: "url only saves nothing",
+ args: []string{"--type", "audio", "--url-only", "-o", "a.ogg"},
+ wantErr: "--output can't be used with --url-only",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts, err := sessionRecordingCmdOptions(t, tt.args...)
+ if tt.wantErr != "" {
+ require.ErrorContains(t, err, tt.wantErr)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, opts)
+ })
+ }
+}
+
+// recordingServers starts a stand-in object store serving a session's audio
+// and gzip-encoded chat history at signed URLs, and a stand-in Public API that
+// signs those URLs. Requests to the object store are recorded so a test can
+// check no credentials reach it.
+func recordingServers(t *testing.T, audio []byte, chatHistory string) (api *public.Client, storeAuth *[]string) {
+ t.Helper()
+ var auth []string
+ store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth = append(auth, r.Header.Get("Authorization"))
+ switch r.URL.Path {
+ case "/recording.ogg":
+ w.Header().Set("Content-Type", "audio/ogg")
+ _, _ = w.Write(audio)
+ case "/chat_history.json":
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ _, _ = zw.Write([]byte(chatHistory))
+ _ = zw.Close()
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Encoding", "gzip")
+ _, _ = w.Write(buf.Bytes())
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(store.Close)
+
+ apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/projects/p1/sessions/RM_1/recording-url" || r.Header.Get("Authorization") != "Bearer sekret" {
+ http.NotFound(w, r)
+ return
+ }
+ object := "/recording.ogg"
+ if r.URL.Query().Get("fileType") == "RECORDING_FILE_TYPE_CHAT_HISTORY" {
+ object = "/chat_history.json"
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"url":%q,"expiresAt":"2026-10-07T12:15:00Z","recordingStartedAt":"2026-10-07T11:00:00Z"}`,
+ store.URL+object+"?X-Amz-Signature=abc")
+ }))
+ t.Cleanup(apiSrv.Close)
+
+ client, err := public.New(apiSrv.URL, "sekret")
+ require.NoError(t, err)
+ return client, &auth
+}
+
// captureOut points the command printer at buffers for one test.
func captureOut(t *testing.T) (stdout, stderr *bytes.Buffer) {
t.Helper()
@@ -489,16 +628,227 @@ func captureOut(t *testing.T) (stdout, stderr *bytes.Buffer) {
return stdout, stderr
}
+// TestFetchSessionRecording downloads each recording from a signed URL the
+// stand-in API hands out, to the default name or -o, and checks the chat
+// history lands decompressed.
+func TestFetchSessionRecording(t *testing.T) {
+ audio := []byte("OggS\x00\x02audio-bytes")
+ const chat = `{"items":[{"type":"message","role":"user","content":["hi"]}]}`
+
+ tests := []struct {
+ name string
+ opts sessionRecordingOptions
+ wantFile string
+ want string
+ }{
+ {
+ name: "audio to the default name",
+ opts: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}},
+ wantFile: "RM_1-audio.ogg",
+ want: string(audio),
+ },
+ {
+ name: "chat history to the default name, decompressed",
+ opts: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "chat-history"}},
+ wantFile: "RM_1-chat-history.json",
+ want: chat,
+ },
+ {
+ name: "chat history to -o",
+ opts: sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "chat-history"}, Output: "out/chat.json"},
+ wantFile: "out/chat.json",
+ want: chat,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Chdir(t.TempDir())
+ require.NoError(t, os.Mkdir("out", 0o755))
+ client, storeAuth := recordingServers(t, audio, chat)
+ _, stderr := captureOut(t)
+
+ require.NoError(t, fetchSessionRecording(context.Background(), client, "p1", "RM_1", tt.opts, false))
+
+ got, err := os.ReadFile(tt.wantFile)
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, string(got))
+ assert.Contains(t, stderr.String(), tt.wantFile)
+ assert.Equal(t, []string{""}, *storeAuth, "the object store gets the signed URL and nothing else")
+
+ leftovers, err := filepath.Glob(filepath.Join(filepath.Dir(tt.wantFile), ".*"))
+ require.NoError(t, err)
+ assert.Empty(t, leftovers, "no partial file is left behind")
+ })
+ }
+}
+
+// TestFetchSessionRecordingURLOnly checks --url-only prints the signed URL and
+// downloads nothing.
+func TestFetchSessionRecordingURLOnly(t *testing.T) {
+ t.Chdir(t.TempDir())
+ client, storeAuth := recordingServers(t, []byte("OggS"), "{}")
+ stdout, _ := captureOut(t)
+
+ opts := sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}, URLOnly: true}
+ require.NoError(t, fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, false))
+
+ assert.Regexp(t, `^http://127\.0\.0\.1:\d+/recording\.ogg\?X-Amz-Signature=abc\n$`, stdout.String())
+ assert.Empty(t, *storeAuth, "nothing is downloaded")
+ entries, err := os.ReadDir(".")
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+}
+
+// TestFetchSessionRecordingJSON checks --json reports the saved file.
+func TestFetchSessionRecordingJSON(t *testing.T) {
+ t.Chdir(t.TempDir())
+ client, _ := recordingServers(t, []byte("OggS"), "{}")
+ stdout, _ := captureOut(t)
+
+ opts := sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}}
+ require.NoError(t, fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, true))
+ assert.JSONEq(t, `{"sessionId":"RM_1","recording":"audio","file":"RM_1-audio.ogg","bytes":4,"recordingStartedAt":"2026-10-07T11:00:00Z"}`, stdout.String())
+}
+
+// TestFetchSessionRecordingNothingToDownload checks the two answers that mean
+// there's no recording each say so plainly, and leave no file behind.
+func TestFetchSessionRecordingNothingToDownload(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ body string
+ wantErr []string
+ }{
+ {
+ name: "not recorded",
+ status: http.StatusNotFound,
+ body: `{"code":5,"message":"recording not found"}`,
+ wantErr: []string{"session RM_1 has no chat history", "recording not found", "wasn't recorded", "less than a minute ago", "or there is no such session in project p1"},
+ },
+ {
+ name: "recording off",
+ status: http.StatusBadRequest,
+ body: `{"code":9,"message":"user data recording is off for this project, so nothing was captured to read","details":[` +
+ `{"@type":"type.googleapis.com/livekit.publicapi.observability.v1.ObservabilityDisabled","dashboardUrl":"https://cloud.example/projects/p1/settings/observability"}]}`,
+ wantErr: []string{
+ "session RM_1 has no chat history",
+ "user data recording is off for this project",
+ "https://cloud.example/projects/p1/settings/observability",
+ },
+ },
+ {
+ name: "signed out",
+ status: http.StatusUnauthorized,
+ body: `{"code":16,"message":"authentication required"}`,
+ wantErr: []string{"authentication required", "lk cloud auth"},
+ },
+ {
+ name: "permission denied",
+ status: http.StatusForbidden,
+ body: `{"code":7,"message":"permission denied"}`,
+ wantErr: []string{"permission denied", "reading a session's chat history requires being a project admin"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Chdir(t.TempDir())
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(tt.status)
+ _, _ = w.Write([]byte(tt.body))
+ }))
+ t.Cleanup(srv.Close)
+ client, err := public.New(srv.URL, "sekret")
+ require.NoError(t, err)
+ captureOut(t)
+
+ opts := sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "chat-history"}}
+ err = fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, false)
+ require.Error(t, err)
+ for _, want := range tt.wantErr {
+ assert.Contains(t, err.Error(), want)
+ }
+ entries, err := os.ReadDir(".")
+ require.NoError(t, err)
+ assert.Empty(t, entries)
+ })
+ }
+}
+
+// TestFetchSessionRecordingDownloadFails checks a refused signed URL leaves no
+// file, not even a partial one, and keeps an existing file intact.
+func TestFetchSessionRecordingDownloadFails(t *testing.T) {
+ t.Chdir(t.TempDir())
+ require.NoError(t, os.WriteFile("RM_1-audio.ogg", []byte("earlier"), 0o600))
+ store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte("Request has expired"))
+ }))
+ t.Cleanup(store.Close)
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"url":%q}`, store.URL+"/recording.ogg")
+ }))
+ t.Cleanup(api.Close)
+ client, err := public.New(api.URL, "sekret")
+ require.NoError(t, err)
+ captureOut(t)
+
+ opts := sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}, Output: "RM_1-audio.ogg"}
+ err = fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, false)
+ require.ErrorContains(t, err, "Request has expired")
+
+ got, err := os.ReadFile("RM_1-audio.ogg")
+ require.NoError(t, err)
+ assert.Equal(t, "earlier", string(got))
+ entries, err := os.ReadDir(".")
+ require.NoError(t, err)
+ assert.Len(t, entries, 1)
+}
+
+// TestFetchSessionRecordingExistingFile checks a download never replaces a
+// file at the default name, failing before it asks for a URL, and replaces
+// one -o names.
+func TestFetchSessionRecordingExistingFile(t *testing.T) {
+ t.Chdir(t.TempDir())
+ require.NoError(t, os.WriteFile("RM_1-audio.ogg", []byte("earlier"), 0o600))
+ client, storeAuth := recordingServers(t, []byte("OggS"), "{}")
+ captureOut(t)
+
+ opts := sessionRecordingOptions{RecordingURLOptions: public.RecordingURLOptions{Recording: "audio"}}
+ err := fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, false)
+ require.EqualError(t, err, "RM_1-audio.ogg already exists; pass -o RM_1-audio.ogg to replace it, or -o FILE to save elsewhere")
+ got, err := os.ReadFile("RM_1-audio.ogg")
+ require.NoError(t, err)
+ assert.Equal(t, "earlier", string(got))
+ assert.Empty(t, *storeAuth, "nothing is downloaded")
+
+ opts.Output = "RM_1-audio.ogg"
+ require.NoError(t, fetchSessionRecording(context.Background(), client, "p1", "RM_1", opts, false))
+ got, err = os.ReadFile("RM_1-audio.ogg")
+ require.NoError(t, err)
+ assert.Equal(t, "OggS", string(got))
+}
+
+func TestDefaultRecordingFile(t *testing.T) {
+ assert.Equal(t, "RM_1-audio.ogg", defaultRecordingFile("RM_1", "audio"))
+ assert.Equal(t, "RM_1-chat-history.json", defaultRecordingFile("RM_1", "chat-history"))
+ assert.Equal(t, "a_b-audio.ogg", defaultRecordingFile("a/b", "audio"), "a session id never names a directory")
+}
+
// TestSessionAPIError checks a permission denial on a Public-API-only session
// read says what the read requires, never to use API-key credentials, which
// these reads can't use, while other errors keep cloudAPIError's hints.
func TestSessionAPIError(t *testing.T) {
denied := &public.APIError{Status: http.StatusForbidden, Message: "permission denied"}
- err := sessionAPIError(denied)
- assert.EqualError(t, err, "permission denied — you don't have access to this project")
+ err := sessionAPIError(denied, "transcript", projectAdminAccess)
+ assert.EqualError(t, err, "permission denied — reading a session's transcript requires being a project admin")
assert.True(t, public.IsPermissionDenied(err))
+ err = sessionAPIError(denied, "participants", projectReadAccess)
+ assert.EqualError(t, err, "permission denied — you don't have access to this project")
+
signedOut := &public.APIError{Status: http.StatusUnauthorized, Message: "authentication required"}
- assert.ErrorContains(t, sessionAPIError(signedOut), "lk cloud auth")
+ assert.ErrorContains(t, sessionAPIError(signedOut, "transcript", projectAdminAccess), "lk cloud auth")
}
diff --git a/pkg/public/client.go b/pkg/public/client.go
index a4af81c3..7634b182 100644
--- a/pkg/public/client.go
+++ b/pkg/public/client.go
@@ -140,6 +140,10 @@ func (c *Client) DeleteProject(ctx context.Context, projectID string) error {
type APIError struct {
Status int
Message string
+ // details are the error envelope's typed details: JSON objects naming their
+ // type in "@type" beside the fields. Read them through helpers such as
+ // ObservabilityDisabled.
+ details []json.RawMessage
}
func (e *APIError) Error() string {
@@ -176,10 +180,11 @@ func IsNotFound(err error) bool {
// to the raw body.
func responseError(status int, body []byte) error {
var env struct {
- Message string `json:"message"`
+ Message string `json:"message"`
+ Details []json.RawMessage `json:"details"`
}
if err := json.Unmarshal(body, &env); err == nil && env.Message != "" {
- return &APIError{Status: status, Message: env.Message}
+ return &APIError{Status: status, Message: env.Message, details: env.Details}
}
msg := strings.TrimSpace(string(body))
if msg == "" {
@@ -188,6 +193,29 @@ func responseError(status int, body []byte) error {
return &APIError{Status: status, Message: fmt.Sprintf("unexpected response (HTTP %d): %s", status, msg)}
}
+// errorDetail finds the detail of the protobuf message named fullName (e.g.
+// "livekit.publicapi.observability.v1.ObservabilityDisabled") on an APIError
+// and decodes its fields into v. The API's REST errors carry each detail as
+// JSON whose "@type" is "type.googleapis.com/" plus that name.
+func errorDetail(err error, fullName string, v any) bool {
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ return false
+ }
+ for _, raw := range apiErr.details {
+ var typed struct {
+ Type string `json:"@type"`
+ }
+ if json.Unmarshal(raw, &typed) != nil {
+ continue
+ }
+ if typed.Type[strings.LastIndex(typed.Type, "/")+1:] == fullName {
+ return json.Unmarshal(raw, v) == nil
+ }
+ }
+ return false
+}
+
// okOrError returns nil for a 200 response and a classified APIError otherwise.
// Used by delete/remove/cancel operations whose success payload is empty.
func okOrError(status int, body []byte) error {
diff --git a/pkg/public/oapi/oapi.gen.go b/pkg/public/oapi/oapi.gen.go
index e49c37ae..4182c5e2 100644
--- a/pkg/public/oapi/oapi.gen.go
+++ b/pkg/public/oapi/oapi.gen.go
@@ -4305,99 +4305,23 @@ type LivekitPublicapiObservabilityV1MetricsGetResponse struct {
// LivekitPublicapiObservabilityV1RecordingGetURLResponse defines model for livekit.publicapi.observability.v1.Recording.GetURL.Response.
type LivekitPublicapiObservabilityV1RecordingGetURLResponse struct {
- // CreatedAt A Timestamp represents a point in time independent of any time zone or local
- // calendar, encoded as a count of seconds and fractions of seconds at
- // nanosecond resolution. The count is relative to an epoch at UTC midnight on
- // January 1, 1970, in the proleptic Gregorian calendar which extends the
- // Gregorian calendar backwards to year one.
- //
- // All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
- // second table is needed for interpretation, using a [24-hour linear
- // smear](https://developers.google.com/time/smear).
- //
- // The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
- // restricting to that range, we ensure that we can convert to and from [RFC
- // 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
- //
- // # Examples
- //
- // Example 1: Compute Timestamp from POSIX `time()`.
- //
- // Timestamp timestamp;
- // timestamp.set_seconds(time(NULL));
- // timestamp.set_nanos(0);
+ // ExpiresAt When the URL stops working: the request's time plus the granted
+ // expiry, after the 15-minute clamp.
//
- // Example 2: Compute Timestamp from POSIX `gettimeofday()`.
- //
- // struct timeval tv;
- // gettimeofday(&tv, NULL);
- //
- // Timestamp timestamp;
- // timestamp.set_seconds(tv.tv_sec);
- // timestamp.set_nanos(tv.tv_usec * 1000);
- //
- // Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
- //
- // FILETIME ft;
- // GetSystemTimeAsFileTime(&ft);
- // UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
- //
- // // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
- // // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
- // Timestamp timestamp;
- // timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
- // timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
- //
- // Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
- //
- // long millis = System.currentTimeMillis();
- //
- // Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
- // .setNanos((int) ((millis % 1000) * 1000000)).build();
- //
- // Example 5: Compute Timestamp from Java `Instant.now()`.
- //
- // Instant now = Instant.now();
- //
- // Timestamp timestamp =
- // Timestamp.newBuilder().setSeconds(now.getEpochSecond())
- // .setNanos(now.getNano()).build();
- //
- // Example 6: Compute Timestamp from current time in Python.
- //
- // timestamp = Timestamp()
- // timestamp.GetCurrentTime()
- //
- // # JSON Mapping
- //
- // In JSON format, the Timestamp type is encoded as a string in the
- // [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
- // format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
- // where {year} is always expressed using four digits while {month}, {day},
- // {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
- // seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
- // are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
- // is required. A ProtoJSON serializer should always use UTC (as indicated by
- // "Z") when printing the Timestamp type and a ProtoJSON parser should be
- // able to accept both UTC and other timezones (as indicated by an offset).
- //
- // For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
- // 01:30 UTC on January 15, 2017.
- //
- // In JavaScript, one can convert a Date object to this format using the
- // standard
- // [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
- // method. In Python, a standard `datetime.datetime` object can be converted
- // to this format using
- // [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
- // the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
- // the Joda Time's [`ISODateTimeFormat.dateTime()`](
- // http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime()
- // ) to obtain a formatter capable of generating timestamps in this format.
+ // Examples: 2023-01-15T01:30:15.01Z, 2024-12-25T12:00:00Z
+ ExpiresAt *GoogleProtobufTimestamp `json:"expiresAt,omitempty"`
+
+ // RecordingStartedAt When the recording started, as the agent reported it on upload. Line
+ // the audio up with the transcript by it.
//
// Examples: 2023-01-15T01:30:15.01Z, 2024-12-25T12:00:00Z
- CreatedAt *GoogleProtobufTimestamp `json:"createdAt,omitempty"`
- Url *string `json:"url,omitempty"`
+ RecordingStartedAt *GoogleProtobufTimestamp `json:"recordingStartedAt,omitempty"`
+
+ // Url A signed URL that downloads the recording straight from the object
+ // store in the project's data region, without credentials, until
+ // expires_at. Audio is Ogg; chat history is JSON served with
+ // Content-Encoding: gzip.
+ Url *string `json:"url,omitempty"`
}
// LivekitPublicapiObservabilityV1RecordingFileType RecordingFileType is the recording requested from GetSessionRecordingURL.
@@ -7227,9 +7151,12 @@ type AnalyticsServiceListSessionParticipantsParams struct {
// ObservabilityServiceGetSessionRecordingURLParams defines parameters for ObservabilityServiceGetSessionRecordingURL.
type ObservabilityServiceGetSessionRecordingURLParams struct {
+ // FileType Which recording to sign a URL for. UNSPECIFIED is InvalidArgument.
FileType *LivekitPublicapiObservabilityV1RecordingFileType `form:"fileType,omitempty" json:"fileType,omitempty"`
- // ExpirySeconds Requested URL lifetime; the server clamps it.
+ // ExpirySeconds How long the URL stays valid, in seconds. Unset or 0 is 900 (15
+ // minutes), and anything longer is clamped to 900 without an error;
+ // expires_at says what was granted.
ExpirySeconds *int `form:"expirySeconds,omitempty" json:"expirySeconds,omitempty"`
}
@@ -10168,7 +10095,9 @@ type ClientInterface interface {
// ObservabilityServiceGetSessionRecordingURL GetSessionRecordingURL
//
// GetSessionRecordingURL returns a short-lived signed URL for one of the
- // session's recordings: its audio or its chat history.
+ // session's recordings: its audio or its chat history. A session with no
+ // recording of that type is NotFound. A new recording can be NotFound for
+ // up to a minute after the session ends.
//
// Corresponds with GET /v1/projects/{projectId}/sessions/{sessionId}/recording-url (the `ObservabilityServiceGetSessionRecordingURL` operationId).
ObservabilityServiceGetSessionRecordingURL(ctx context.Context, projectId string, sessionId string, params *ObservabilityServiceGetSessionRecordingURLParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -11189,7 +11118,9 @@ func (c *Client) AnalyticsServiceGetParticipant(ctx context.Context, projectId s
//
// GetSessionRecordingURL returns a short-lived signed URL for one of the
//
-// session's recordings: its audio or its chat history.
+// session's recordings: its audio or its chat history. A session with no
+// recording of that type is NotFound. A new recording can be NotFound for
+// up to a minute after the session ends.
//
// Corresponds with GET /v1/projects/{projectId}/sessions/{sessionId}/recording-url (the `ObservabilityServiceGetSessionRecordingURL` operationId).
func (c *Client) ObservabilityServiceGetSessionRecordingURL(ctx context.Context, projectId string, sessionId string, params *ObservabilityServiceGetSessionRecordingURLParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -15907,7 +15838,9 @@ type ClientWithResponsesInterface interface {
// ObservabilityServiceGetSessionRecordingURLWithResponse GetSessionRecordingURL
//
// GetSessionRecordingURL returns a short-lived signed URL for one of the
- // session's recordings: its audio or its chat history.
+ // session's recordings: its audio or its chat history. A session with no
+ // recording of that type is NotFound. A new recording can be NotFound for
+ // up to a minute after the session ends.
//
// Returns a wrapper object for the known response body format(s).
//
@@ -19482,7 +19415,9 @@ func (c *ClientWithResponses) AnalyticsServiceGetParticipantWithResponse(ctx con
//
// GetSessionRecordingURL returns a short-lived signed URL for one of the
//
-// session's recordings: its audio or its chat history.
+// session's recordings: its audio or its chat history. A session with no
+// recording of that type is NotFound. A new recording can be NotFound for
+// up to a minute after the session ends.
//
// Returns a wrapper object for the known response body format(s).
//
diff --git a/pkg/public/observability.go b/pkg/public/observability.go
new file mode 100644
index 00000000..ff23bdb3
--- /dev/null
+++ b/pkg/public/observability.go
@@ -0,0 +1,210 @@
+// Copyright 2026 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package public
+
+import (
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/livekit/livekit-cli/v2/pkg/public/oapi"
+)
+
+// Recording names accepted by RecordingURLOptions.Recording.
+const (
+ RecordingAudio = "audio"
+ RecordingChatHistory = "chat-history"
+)
+
+// RecordingURLOptions chooses which of a session's recordings
+// GetSessionRecordingURL signs a URL for.
+type RecordingURLOptions struct {
+ // Recording is "audio" (Ogg) or "chat-history" (JSON). Required.
+ Recording string
+}
+
+// parseRecordingFileType maps a friendly recording name to the wire enum.
+func parseRecordingFileType(s string) (oapi.LivekitPublicapiObservabilityV1RecordingFileType, error) {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case RecordingAudio:
+ return oapi.RECORDINGFILETYPEAUDIO, nil
+ case RecordingChatHistory:
+ return oapi.RECORDINGFILETYPECHATHISTORY, nil
+ case "":
+ return "", errors.New(`recording type is required ("audio" or "chat-history")`)
+ default:
+ return "", fmt.Errorf("invalid recording type %q (expected \"audio\" or \"chat-history\")", s)
+ }
+}
+
+// Validate reports a missing or unknown recording name. GetSessionRecordingURL
+// makes the same check before sending a request; callers can run it earlier.
+func (o RecordingURLOptions) Validate() error {
+ _, err := parseRecordingFileType(o.Recording)
+ return err
+}
+
+// GetSessionRecordingURL returns a short-lived signed URL for one of a session's
+// recordings, with when it expires and when the recording started. The URL
+// downloads straight from the project's data region (see DownloadRecording).
+// A session with no such recording is NotFound (see IsNotFound), or, while the
+// project's user data recording is off, a FailedPrecondition that
+// ObservabilityDisabled recognizes.
+func (c *Client) GetSessionRecordingURL(ctx context.Context, projectID, sessionID string, opts RecordingURLOptions) (*oapi.LivekitPublicapiObservabilityV1RecordingGetURLResponse, error) {
+ fileType, err := parseRecordingFileType(opts.Recording)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.gen.ObservabilityServiceGetSessionRecordingURLWithResponse(ctx, projectID, sessionID,
+ &oapi.ObservabilityServiceGetSessionRecordingURLParams{FileType: &fileType})
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, responseError(resp.StatusCode(), resp.Body)
+ }
+ if resp.JSON200.Url == nil || *resp.JSON200.Url == "" {
+ return nil, errors.New("unexpected response from server: missing url")
+ }
+ return resp.JSON200, nil
+}
+
+// ObservabilityDisabled reports whether err is the Public API's answer to an
+// observability read that found nothing while the project's user data
+// recording is off. dashboardURL is the project's settings page where an admin
+// turns recording on; it can be empty.
+func ObservabilityDisabled(err error) (dashboardURL string, ok bool) {
+ var detail struct {
+ DashboardURL string `json:"dashboardUrl"`
+ }
+ if !errorDetail(err, "livekit.publicapi.observability.v1.ObservabilityDisabled", &detail) {
+ return "", false
+ }
+ return detail.DashboardURL, true
+}
+
+// gzipMagic opens every gzip stream (RFC 1952).
+var gzipMagic = []byte{0x1f, 0x8b}
+
+// recordingClient downloads recordings. It is its own client, not
+// http.DefaultClient, so nothing else in the process can give it a cookie jar
+// or a credential-adding transport, and so a stalled object store can't hang
+// the download forever: the store must start answering within
+// ResponseHeaderTimeout, and the whole download, an hour-long recording
+// included, must finish within Timeout.
+var recordingClient = &http.Client{
+ Timeout: 10 * time.Minute,
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ ForceAttemptHTTP2: true,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ResponseHeaderTimeout: 30 * time.Second,
+ IdleConnTimeout: 90 * time.Second,
+ },
+}
+
+// DownloadRecording copies the recording at a signed URL from
+// GetSessionRecordingURL into w and returns the bytes written. The URL carries
+// its own authorization, so the request sends no credentials: never the user's
+// session token, and no cookie, even across a redirect. Its errors never hold
+// the URL's query string, where the signature is. Chat history is stored
+// gzip-encoded; a gzip body is decompressed whether or not the HTTP transport
+// already did it, so w always gets the plain file.
+func DownloadRecording(ctx context.Context, signedURL string, w io.Writer) (int64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, signedURL, nil)
+ if err != nil {
+ return 0, fmt.Errorf("download recording: %w", redactURLError(err))
+ }
+ resp, err := recordingClient.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("download recording: %w", redactURLError(err))
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ msg := redactSignature(strings.TrimSpace(string(snippet)), signedURL)
+ if msg == "" {
+ msg = http.StatusText(resp.StatusCode)
+ }
+ return 0, fmt.Errorf("download recording: unexpected response (HTTP %d): %s", resp.StatusCode, msg)
+ }
+
+ br := bufio.NewReader(resp.Body)
+ var body io.Reader = br
+ if head, _ := br.Peek(len(gzipMagic)); bytes.Equal(head, gzipMagic) {
+ zr, err := gzip.NewReader(br)
+ if err != nil {
+ return 0, fmt.Errorf("download recording: %w", err)
+ }
+ defer zr.Close()
+ body = zr
+ }
+ n, err := io.Copy(w, body)
+ if err != nil {
+ return n, fmt.Errorf("download recording: %w", err)
+ }
+ return n, nil
+}
+
+// redactURLError drops the query string, which holds a signed URL's
+// signature, from the URL a *url.Error names, so the signature can't reach a
+// terminal or a log through an error message. Other errors pass through.
+func redactURLError(err error) error {
+ var uerr *url.Error
+ if errors.As(err, &uerr) {
+ uerr.URL = withoutQuery(uerr.URL)
+ }
+ return err
+}
+
+// withoutQuery returns rawURL without its query string or fragment.
+func withoutQuery(rawURL string) string {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ if i := strings.IndexAny(rawURL, "?#"); i >= 0 {
+ return rawURL[:i]
+ }
+ return rawURL
+ }
+ u.RawQuery, u.Fragment, u.RawFragment = "", "", ""
+ return u.String()
+}
+
+// redactSignature hides the long query values of signedURL, such as its
+// signature and credential, wherever they appear in msg: an object store's
+// error page can echo the signature it was given.
+func redactSignature(msg, signedURL string) string {
+ u, err := url.Parse(signedURL)
+ if err != nil {
+ return msg
+ }
+ for _, values := range u.Query() {
+ for _, v := range values {
+ if len(v) >= 16 {
+ msg = strings.ReplaceAll(msg, v, "[redacted]")
+ }
+ }
+ }
+ return msg
+}
diff --git a/pkg/public/observability_test.go b/pkg/public/observability_test.go
new file mode 100644
index 00000000..95984e25
--- /dev/null
+++ b/pkg/public/observability_test.go
@@ -0,0 +1,330 @@
+// Copyright 2026 LiveKit, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package public
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGetSessionRecordingURL checks the path and file type GetSessionRecordingURL
+// sends for each recording type, and that it hands back the signed URL and its
+// times.
+func TestGetSessionRecordingURL(t *testing.T) {
+ tests := []struct {
+ recording string
+ want string
+ }{
+ {recording: "audio", want: "RECORDING_FILE_TYPE_AUDIO"},
+ {recording: "chat-history", want: "RECORDING_FILE_TYPE_CHAT_HISTORY"},
+ {recording: " Chat-History ", want: "RECORDING_FILE_TYPE_CHAT_HISTORY"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.recording, func(t *testing.T) {
+ var gotPath, gotAuth string
+ var gotQuery url.Values
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotQuery, gotAuth = r.URL.Path, r.URL.Query(), r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"url":"https://bucket.example/rec?sig=x","expiresAt":"2026-10-07T12:15:00Z","recordingStartedAt":"2026-10-07T11:00:00Z"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ c, err := New(srv.URL, "sekret")
+ require.NoError(t, err)
+
+ res, err := c.GetSessionRecordingURL(context.Background(), "p1", "RM_1", RecordingURLOptions{Recording: tt.recording})
+ require.NoError(t, err)
+
+ assert.Equal(t, "/v1/projects/p1/sessions/RM_1/recording-url", gotPath)
+ assert.Equal(t, url.Values{"fileType": {tt.want}}, gotQuery)
+ assert.Equal(t, "Bearer sekret", gotAuth)
+ assert.Equal(t, "https://bucket.example/rec?sig=x", *res.Url)
+ assert.True(t, time.Date(2026, 10, 7, 12, 15, 0, 0, time.UTC).Equal(*res.ExpiresAt))
+ assert.True(t, time.Date(2026, 10, 7, 11, 0, 0, 0, time.UTC).Equal(*res.RecordingStartedAt))
+ })
+ }
+}
+
+// TestGetSessionRecordingURLRejectsUnknownRecording confirms a missing or
+// unknown recording name fails Validate, and fails GetSessionRecordingURL
+// before any request is sent.
+func TestGetSessionRecordingURLRejectsUnknownRecording(t *testing.T) {
+ tests := []struct {
+ recording string
+ wantErr string
+ }{
+ {recording: "", wantErr: `recording type is required ("audio" or "chat-history")`},
+ {recording: "transcript", wantErr: `invalid recording type "transcript" (expected "audio" or "chat-history")`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.recording, func(t *testing.T) {
+ opts := RecordingURLOptions{Recording: tt.recording}
+ require.EqualError(t, opts.Validate(), tt.wantErr)
+
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
+ t.Cleanup(srv.Close)
+ c, err := New(srv.URL, "sekret")
+ require.NoError(t, err)
+
+ _, err = c.GetSessionRecordingURL(context.Background(), "p1", "RM_1", opts)
+ require.EqualError(t, err, tt.wantErr)
+ assert.False(t, called, "no request is sent for a bad recording type")
+ })
+ }
+}
+
+// TestGetSessionRecordingURLMissingURL guards a 200 without a URL, which would
+// otherwise download from "".
+func TestGetSessionRecordingURLMissingURL(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ t.Cleanup(srv.Close)
+ c, err := New(srv.URL, "sekret")
+ require.NoError(t, err)
+
+ _, err = c.GetSessionRecordingURL(context.Background(), "p1", "RM_1", RecordingURLOptions{Recording: "audio"})
+ require.EqualError(t, err, "unexpected response from server: missing url")
+}
+
+// TestGetSessionRecordingURLErrors checks the two "nothing to download"
+// answers keep what the caller needs: a missing recording is NotFound, and a
+// missing recording with user data recording off carries the dashboard link.
+// The bodies are what the server's REST transcoder writes (a google.rpc.Status).
+func TestGetSessionRecordingURLErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ body string
+ wantNotFound bool
+ wantDisabled bool
+ wantURL string
+ }{
+ {
+ name: "no recording",
+ status: http.StatusNotFound,
+ body: `{"code":5,"message":"recording not found","details":[]}`,
+ wantNotFound: true,
+ },
+ {
+ name: "recording off",
+ status: http.StatusBadRequest,
+ body: `{"code":9,"message":"user data recording is off for this project, so nothing was captured to read","details":[` +
+ `{"@type":"type.googleapis.com/livekit.publicapi.observability.v1.ObservabilityDisabled","dashboardUrl":"https://cloud.example/projects/p1/settings/observability"}]}`,
+ wantDisabled: true,
+ wantURL: "https://cloud.example/projects/p1/settings/observability",
+ },
+ {
+ name: "another failed precondition",
+ status: http.StatusBadRequest,
+ body: `{"code":9,"message":"something else","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"X"}]}`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(tt.status)
+ _, _ = w.Write([]byte(tt.body))
+ }))
+ t.Cleanup(srv.Close)
+ c, err := New(srv.URL, "sekret")
+ require.NoError(t, err)
+
+ _, err = c.GetSessionRecordingURL(context.Background(), "p1", "RM_1", RecordingURLOptions{Recording: "audio"})
+ require.Error(t, err)
+ assert.Equal(t, tt.wantNotFound, IsNotFound(err))
+ dashboardURL, disabled := ObservabilityDisabled(err)
+ assert.Equal(t, tt.wantDisabled, disabled)
+ assert.Equal(t, tt.wantURL, dashboardURL)
+ })
+ }
+}
+
+func gzipped(t *testing.T, s string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ _, err := zw.Write([]byte(s))
+ require.NoError(t, err)
+ require.NoError(t, zw.Close())
+ return buf.Bytes()
+}
+
+// TestDownloadRecording checks a signed URL is fetched without credentials and
+// its body copied as served, except that a gzip body is decompressed whether or
+// not the transport already did it.
+func TestDownloadRecording(t *testing.T) {
+ const chat = `{"items":[{"type":"message","role":"user","content":["hi"]}]}`
+ tests := []struct {
+ name string
+ header http.Header
+ body []byte
+ wantBody string
+ }{
+ {
+ name: "audio as served",
+ header: http.Header{"Content-Type": {"audio/ogg"}},
+ body: []byte("OggS\x00audio"),
+ wantBody: "OggS\x00audio",
+ },
+ {
+ name: "chat history with Content-Encoding gzip",
+ header: http.Header{"Content-Type": {"application/json"}, "Content-Encoding": {"gzip"}},
+ body: gzipped(t, chat),
+ wantBody: chat,
+ },
+ {
+ name: "chat history gzip without the header",
+ header: http.Header{"Content-Type": {"application/octet-stream"}},
+ body: gzipped(t, chat),
+ wantBody: chat,
+ },
+ {
+ name: "empty body",
+ wantBody: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var gotAuth []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Values("Authorization")
+ for k, v := range tt.header {
+ w.Header()[k] = v
+ }
+ _, _ = w.Write(tt.body)
+ }))
+ t.Cleanup(srv.Close)
+
+ var buf bytes.Buffer
+ n, err := DownloadRecording(context.Background(), srv.URL+"/rec?X-Amz-Signature=abc", &buf)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantBody, buf.String())
+ assert.Equal(t, int64(len(tt.wantBody)), n)
+ assert.Empty(t, gotAuth, "a signed URL carries its own authorization")
+ })
+ }
+}
+
+// TestDownloadRecordingRefused checks a refused signed URL (expired, or the
+// object gone) is an error naming the status, not a saved error page.
+func TestDownloadRecordingRefused(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`AccessDeniedRequest has expired`))
+ }))
+ t.Cleanup(srv.Close)
+
+ var buf bytes.Buffer
+ _, err := DownloadRecording(context.Background(), srv.URL+"/rec", &buf)
+ require.ErrorContains(t, err, "HTTP 403")
+ require.ErrorContains(t, err, "Request has expired")
+ assert.Empty(t, buf.String())
+}
+
+// TestDownloadRecordingSendsNoCredentials checks neither a cookie nor an
+// Authorization header reaches the object store, before or after a redirect,
+// even when the store sets a cookie on the way and the process's default
+// client holds cookies for it.
+func TestDownloadRecordingSendsNoCredentials(t *testing.T) {
+ type seen struct{ cookie, auth []string }
+ var final seen
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ final = seen{r.Header.Values("Cookie"), r.Header.Values("Authorization")}
+ _, _ = w.Write([]byte("OggS"))
+ }))
+ t.Cleanup(target.Close)
+ var first seen
+ store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ first = seen{r.Header.Values("Cookie"), r.Header.Values("Authorization")}
+ http.SetCookie(w, &http.Cookie{Name: "session", Value: "from-store"})
+ http.Redirect(w, r, target.URL+"/rec?X-Amz-Signature=def", http.StatusFound)
+ }))
+ t.Cleanup(store.Close)
+
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err)
+ for _, srv := range []string{store.URL, target.URL} {
+ u, err := url.Parse(srv)
+ require.NoError(t, err)
+ jar.SetCookies(u, []*http.Cookie{{Name: "elsewhere", Value: "secret"}})
+ }
+ prevJar := http.DefaultClient.Jar
+ http.DefaultClient.Jar = jar
+ t.Cleanup(func() { http.DefaultClient.Jar = prevJar })
+
+ var buf bytes.Buffer
+ _, err = DownloadRecording(context.Background(), store.URL+"/rec?X-Amz-Signature=abc", &buf)
+ require.NoError(t, err)
+ assert.Equal(t, "OggS", buf.String())
+ assert.Equal(t, seen{}, first, "the first request carries no credentials")
+ assert.Equal(t, seen{}, final, "the redirected request carries no credentials")
+}
+
+// TestDownloadRecordingErrorsHideSignature checks a failed download's error
+// names where it went wrong without the signed URL's query string, whether the
+// store couldn't be reached, before or after a redirect, or echoed the
+// signature in its error page.
+func TestDownloadRecordingErrorsHideSignature(t *testing.T) {
+ const sig = "0123456789abcdef0123456789abcdef"
+ unreachable := httptest.NewServer(http.NotFoundHandler())
+ unreachableURL := unreachable.URL
+ unreachable.Close()
+
+ redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, unreachableURL+"/rec?X-Amz-Signature="+sig, http.StatusFound)
+ }))
+ t.Cleanup(redirect.Close)
+ echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`SignatureDoesNotMatch` + sig + ``))
+ }))
+ t.Cleanup(echo.Close)
+
+ tests := []struct {
+ name string
+ url string
+ wantErr string
+ }{
+ {name: "unreachable", url: unreachableURL + "/rec?X-Amz-Signature=" + sig, wantErr: unreachableURL + "/rec"},
+ {name: "unreachable after a redirect", url: redirect.URL + "/rec?X-Amz-Signature=" + sig, wantErr: unreachableURL + "/rec"},
+ {name: "signature echoed", url: echo.URL + "/rec?X-Amz-Signature=" + sig, wantErr: "SignatureDoesNotMatch"},
+ {name: "malformed", url: "http://[::1/rec?X-Amz-Signature=" + sig, wantErr: "download recording"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := DownloadRecording(context.Background(), tt.url, &bytes.Buffer{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ assert.NotContains(t, err.Error(), sig)
+ assert.NotContains(t, err.Error(), "X-Amz-Signature")
+ })
+ }
+}
diff --git a/pkg/public/render/analytics.go b/pkg/public/render/analytics.go
index 872c10dc..c9e76d54 100644
--- a/pkg/public/render/analytics.go
+++ b/pkg/public/render/analytics.go
@@ -22,6 +22,7 @@ import (
"time"
"unicode"
+ "github.com/livekit/livekit-cli/v2/pkg/public"
"github.com/livekit/livekit-cli/v2/pkg/public/oapi"
"github.com/livekit/livekit-cli/v2/pkg/util"
)
@@ -238,3 +239,46 @@ func formatBitrate(bps float64) string {
}
return fmt.Sprintf("%.1f %s", bps, units[i])
}
+
+// RecordingLabel names a recording ("audio" or "chat-history") for a sentence.
+func RecordingLabel(recording string) string {
+ if recording == public.RecordingChatHistory {
+ return "chat history"
+ }
+ return recording + " recording"
+}
+
+// RecordingURL prints a signed recording URL. As text it is the bare URL on
+// stdout, so it pipes into curl, with its expiry as a status line; as JSON it
+// is the API's response, recording start included.
+func RecordingURL(p *util.Printer, asJSON bool, r oapi.LivekitPublicapiObservabilityV1RecordingGetURLResponse) error {
+ if asJSON {
+ return util.PrintJSONTo(p.ResultWriter(), r)
+ }
+ p.Result(util.Deref(r.Url))
+ p.Statusf("The URL expires %s", util.FormatTime(r.ExpiresAt))
+ return nil
+}
+
+// SavedRecording is a recording downloaded to a file, and its --json shape.
+type SavedRecording struct {
+ SessionID string `json:"sessionId"`
+ Recording string `json:"recording"`
+ File string `json:"file"`
+ Bytes int64 `json:"bytes"`
+ RecordingStartedAt *time.Time `json:"recordingStartedAt,omitempty"`
+}
+
+// RecordingSaved says where a downloaded recording was saved, and when the
+// recording started (to line the audio up with the transcript).
+func RecordingSaved(p *util.Printer, asJSON bool, s SavedRecording) error {
+ if asJSON {
+ return util.PrintJSONTo(p.ResultWriter(), s)
+ }
+ p.Statusf("Saved %s of session %s to %s (%s)", RecordingLabel(s.Recording), s.SessionID,
+ util.Accented(s.File), util.FormatBytes(json.RawMessage(strconv.FormatInt(s.Bytes, 10))))
+ if s.RecordingStartedAt != nil {
+ p.Statusf("The recording started %s", util.FormatTime(s.RecordingStartedAt))
+ }
+ return nil
+}
diff --git a/pkg/public/render/analytics_test.go b/pkg/public/render/analytics_test.go
index 1a48fc3a..02aebc6e 100644
--- a/pkg/public/render/analytics_test.go
+++ b/pkg/public/render/analytics_test.go
@@ -19,6 +19,7 @@ import (
"encoding/json"
"regexp"
"testing"
+ "time"
"unicode/utf8"
"github.com/stretchr/testify/assert"
@@ -271,3 +272,38 @@ func TestSessionDetailJSON(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(sessionWithDetail), &want))
assert.Equal(t, want, got)
}
+
+const recordingURLResponse = `{"url":"https://bucket.example/rec?sig=x","expiresAt":"2026-10-07T12:15:00Z","recordingStartedAt":"2026-10-07T11:00:00Z"}`
+
+// TestRecordingURL checks the text form is the bare URL on stdout, so it pipes
+// into curl, and --json is the API's response.
+func TestRecordingURL(t *testing.T) {
+ var resp oapi.LivekitPublicapiObservabilityV1RecordingGetURLResponse
+ require.NoError(t, json.Unmarshal([]byte(recordingURLResponse), &resp))
+
+ var stdout, stderr bytes.Buffer
+ require.NoError(t, RecordingURL(util.NewPrinter(&stdout, &stderr, false), false, resp))
+ assert.Equal(t, "https://bucket.example/rec?sig=x\n", stdout.String())
+ assert.Contains(t, stderr.String(), "expires")
+
+ stdout.Reset()
+ require.NoError(t, RecordingURL(util.NewPrinter(&stdout, nil, true), true, resp))
+ assert.JSONEq(t, recordingURLResponse, stdout.String())
+}
+
+// TestRecordingSaved checks a saved recording says where it went, and --json
+// gives a script the same facts.
+func TestRecordingSaved(t *testing.T) {
+ started := time.Date(2026, 10, 7, 11, 0, 0, 0, time.UTC)
+ saved := SavedRecording{SessionID: "RM_1", Recording: "chat-history", File: "RM_1-chat-history.json", Bytes: 2048, RecordingStartedAt: &started}
+
+ var stdout, stderr bytes.Buffer
+ require.NoError(t, RecordingSaved(util.NewPrinter(&stdout, &stderr, false), false, saved))
+ assert.Empty(t, stdout.String())
+ assert.Contains(t, stderr.String(), "Saved chat history of session RM_1 to RM_1-chat-history.json (2.0 KB)")
+ assert.Contains(t, stderr.String(), "recording started")
+
+ stdout.Reset()
+ require.NoError(t, RecordingSaved(util.NewPrinter(&stdout, nil, true), true, saved))
+ assert.JSONEq(t, `{"sessionId":"RM_1","recording":"chat-history","file":"RM_1-chat-history.json","bytes":2048,"recordingStartedAt":"2026-10-07T11:00:00Z"}`, stdout.String())
+}