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()) +}