From c663ba5184f947b5bfbc95e2a7d271bcfa04019c Mon Sep 17 00:00:00 2001 From: Nick Thompson Date: Mon, 9 Mar 2026 15:46:59 -0400 Subject: [PATCH 1/2] feat: add analytics subcommand --- cmd/lk/analytics.go | 450 +++++++++++++++++++++++++++++++++++++++ cmd/lk/analytics_test.go | 100 +++++++++ cmd/lk/main.go | 1 + 3 files changed, 551 insertions(+) create mode 100644 cmd/lk/analytics.go create mode 100644 cmd/lk/analytics_test.go diff --git a/cmd/lk/analytics.go b/cmd/lk/analytics.go new file mode 100644 index 000000000..b251a9c76 --- /dev/null +++ b/cmd/lk/analytics.go @@ -0,0 +1,450 @@ +// 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 main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + authutil "github.com/livekit/livekit-cli/v2/pkg/auth" + "github.com/livekit/livekit-cli/v2/pkg/util" + "github.com/livekit/protocol/auth" + "github.com/urfave/cli/v3" +) + +const ( + analyticsProjectIDRequirement = "analytics API requires a LiveKit Cloud project with a known project_id" + analyticsProjectSelectHint = "Select a cloud project via --project or run `lk cloud auth`" +) + +var ( + AnalyticsCommands = []*cli.Command{ + { + Name: "analytics", + Usage: "List and inspect LiveKit Cloud analytics sessions", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "List analytics sessions", + Action: listAnalyticsSessions, + Flags: []cli.Flag{ + jsonFlag, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of sessions to return", + }, + &cli.IntFlag{ + Name: "page", + Usage: "Page number (starts at 0)", + }, + &cli.StringFlag{ + Name: "start", + Usage: "Start date in `YYYY-MM-DD` format", + }, + &cli.StringFlag{ + Name: "end", + Usage: "End date in `YYYY-MM-DD` format", + }, + }, + }, + { + Name: "get", + Usage: "Get analytics session details by session ID", + ArgsUsage: "SESSION_ID", + Action: getAnalyticsSession, + Flags: []cli.Flag{ + jsonFlag, + }, + }, + }, + }, + } +) + +type analyticsListResponse struct { + Sessions []*analyticsSession `json:"sessions"` +} + +type analyticsSession struct { + SessionID string `json:"sessionId"` + RoomName string `json:"roomName"` + CreatedAt string `json:"createdAt"` + EndedAt string `json:"endedAt"` + LastActive string `json:"lastActive"` + BandwidthIn json.RawMessage `json:"bandwidthIn"` + BandwidthOut json.RawMessage `json:"bandwidthOut"` + Egress json.RawMessage `json:"egress"` + NumParticipants int `json:"numParticipants"` + NumActiveParticipants int `json:"numActiveParticipants"` +} + +type analyticsSessionDetails struct { + RoomID string `json:"roomId"` + RoomName string `json:"roomName"` + Bandwidth json.RawMessage `json:"bandwidth"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + NumParticipants int `json:"numParticipants"` + ConnectionMinutes json.RawMessage `json:"connectionMinutes"` + Participants []*analyticsParticipant `json:"participants"` +} + +type analyticsParticipant struct { + ParticipantIdentity string `json:"participantIdentity"` + ParticipantName string `json:"participantName"` + JoinedAt string `json:"joinedAt"` + LeftAt string `json:"leftAt"` + Region string `json:"region"` + ConnectionType string `json:"connectionType"` + SDKVersion string `json:"sdkVersion"` +} + +func listAnalyticsSessions(ctx context.Context, cmd *cli.Command) error { + query, err := buildAnalyticsListQuery(cmd) + if err != nil { + return err + } + + body, err := callAnalyticsAPI(ctx, cmd, "", query) + if err != nil { + return err + } + + if cmd.Bool("json") { + var obj any + if err := json.Unmarshal(body, &obj); err != nil { + return err + } + util.PrintJSON(obj) + return nil + } + + var res analyticsListResponse + if err := json.Unmarshal(body, &res); err != nil { + return fmt.Errorf("failed to parse analytics list response: %w", err) + } + + if len(res.Sessions) == 0 { + fmt.Println("No sessions found") + return nil + } + + table := util.CreateTable(). + Headers("Session ID", "Room", "Created", "Ended", "Participants", "Active", "Bandwidth In", "Bandwidth Out") + + for _, session := range res.Sessions { + if session == nil { + continue + } + table.Row( + emptyDash(session.SessionID), + emptyDash(session.RoomName), + emptyDash(session.CreatedAt), + emptyDash(session.EndedAt), + strconv.Itoa(session.NumParticipants), + strconv.Itoa(session.NumActiveParticipants), + rawJSONToString(session.BandwidthIn), + rawJSONToString(session.BandwidthOut), + ) + } + + fmt.Println(table) + return nil +} + +func getAnalyticsSession(ctx context.Context, cmd *cli.Command) error { + sessionID, err := extractArg(cmd) + if err != nil { + _ = cli.ShowSubcommandHelp(cmd) + return errors.New("session ID is required") + } + + body, err := callAnalyticsAPI(ctx, cmd, sessionID, nil) + if err != nil { + return err + } + + if cmd.Bool("json") { + var obj any + if err := json.Unmarshal(body, &obj); err != nil { + return err + } + util.PrintJSON(obj) + return nil + } + + var details analyticsSessionDetails + if err := json.Unmarshal(body, &details); err != nil { + return fmt.Errorf("failed to parse analytics details response: %w", err) + } + + summary := util.CreateTable(). + Headers("Session ID", "Room", "Start", "End", "Participants", "Connection Minutes", "Bandwidth"). + Row( + emptyDash(details.RoomID), + emptyDash(details.RoomName), + emptyDash(details.StartTime), + emptyDash(details.EndTime), + strconv.Itoa(details.NumParticipants), + rawJSONToString(details.ConnectionMinutes), + rawJSONToString(details.Bandwidth), + ) + fmt.Println(summary) + + if len(details.Participants) == 0 { + return nil + } + + participantTable := util.CreateTable(). + Headers("Identity", "Name", "Joined", "Left", "Region", "Connection", "SDK") + + for _, participant := range details.Participants { + if participant == nil { + continue + } + participantTable.Row( + emptyDash(participant.ParticipantIdentity), + emptyDash(participant.ParticipantName), + emptyDash(participant.JoinedAt), + emptyDash(participant.LeftAt), + emptyDash(participant.Region), + emptyDash(participant.ConnectionType), + emptyDash(participant.SDKVersion), + ) + } + + fmt.Println(participantTable) + return nil +} + +func buildAnalyticsListQuery(cmd *cli.Command) (url.Values, error) { + query := url.Values{} + + if cmd.IsSet("limit") { + limit := cmd.Int("limit") + if limit <= 0 { + return nil, errors.New("limit must be greater than 0") + } + query.Set("limit", strconv.Itoa(limit)) + } + + if cmd.IsSet("page") { + page := cmd.Int("page") + if page < 0 { + return nil, errors.New("page must be greater than or equal to 0") + } + query.Set("page", strconv.Itoa(page)) + } + + startDate := cmd.String("start") + endDate := cmd.String("end") + start, end, err := validateAnalyticsDateRange(startDate, endDate) + if err != nil { + return nil, err + } + + if !start.IsZero() { + query.Set("start", startDate) + } + if !end.IsZero() { + query.Set("end", endDate) + } + + return query, nil +} + +func validateAnalyticsDateRange(startDate, endDate string) (time.Time, time.Time, error) { + var ( + start time.Time + end time.Time + err error + ) + + if startDate != "" { + start, err = time.Parse("2006-01-02", startDate) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid start date %q, expected YYYY-MM-DD", startDate) + } + } + + if endDate != "" { + end, err = time.Parse("2006-01-02", endDate) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid end date %q, expected YYYY-MM-DD", endDate) + } + } + + if !start.IsZero() && !end.IsZero() && start.After(end) { + return time.Time{}, time.Time{}, errors.New("start date must be less than or equal to end date") + } + + return start, end, nil +} + +func callAnalyticsAPI(ctx context.Context, cmd *cli.Command, sessionID string, query url.Values) ([]byte, error) { + _, err := requireProject(ctx, cmd) + if err != nil { + return nil, err + } + + projectID, err := resolveAnalyticsProjectID() + if err != nil { + return nil, err + } + + token, err := createAnalyticsAccessToken(project.APIKey, project.APISecret) + if err != nil { + return nil, err + } + + baseURL := strings.TrimSuffix(serverURL, "/") + endpoint := fmt.Sprintf("%s/api/project/%s/sessions", baseURL, url.PathEscape(projectID)) + if sessionID != "" { + endpoint += "/" + url.PathEscape(sessionID) + } + + reqURL, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + + if len(query) != 0 { + reqURL.RawQuery = query.Encode() + } + + if printCurl { + fmt.Printf("curl -H \"Authorization: Bearer %s\" \"%s\"\n", token, reqURL.String()) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, err + } + req.Header = authutil.NewHeaderWithToken(token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, mapAnalyticsHTTPError(resp.StatusCode, string(body)) + } + + return body, nil +} + +func resolveAnalyticsProjectID() (string, error) { + if project != nil && project.ProjectId != "" { + return project.ProjectId, nil + } + + if project == nil { + return "", fmt.Errorf("%s; %s", analyticsProjectIDRequirement, analyticsProjectSelectHint) + } + + projectName := project.Name + if strings.TrimSpace(projectName) == "" { + projectName = "" + } + + return "", fmt.Errorf( + "selected project [%s] is missing project_id; %s. %s", + projectName, + analyticsProjectIDRequirement, + analyticsProjectSelectHint, + ) +} + +func createAnalyticsAccessToken(apiKey, apiSecret string) (string, error) { + token, err := auth.NewAccessToken(apiKey, apiSecret). + SetVideoGrant(&auth.VideoGrant{RoomList: true}). + SetIdentity("lk-analytics"). + ToJWT() + if err != nil { + return "", err + } + return token, nil +} + +func mapAnalyticsHTTPError(statusCode int, body string) error { + trimmedBody := strings.TrimSpace(body) + if len(trimmedBody) > 200 { + trimmedBody = trimmedBody[:200] + "..." + } + + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden { + lowerBody := strings.ToLower(trimmedBody) + if strings.Contains(lowerBody, "scale plan") { + return errors.New("analytics API requires LiveKit Cloud Scale plan or higher") + } + if trimmedBody == "" { + return fmt.Errorf("analytics API is not authorized (HTTP %d)", statusCode) + } + return fmt.Errorf("analytics API is not authorized (HTTP %d): %s", statusCode, trimmedBody) + } + + if statusCode == http.StatusNotFound { + if trimmedBody == "" { + return errors.New("analytics resource not found") + } + return fmt.Errorf("analytics resource not found: %s", trimmedBody) + } + + if trimmedBody == "" { + return fmt.Errorf("analytics API request failed with HTTP %d", statusCode) + } + return fmt.Errorf("analytics API request failed with HTTP %d: %s", statusCode, trimmedBody) +} + +func rawJSONToString(value json.RawMessage) string { + if len(value) == 0 { + return "-" + } + + var numeric json.Number + if err := json.Unmarshal(value, &numeric); err == nil { + return numeric.String() + } + + var text string + if err := json.Unmarshal(value, &text); err == nil { + return emptyDash(text) + } + + return emptyDash(string(value)) +} + +func emptyDash(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} diff --git a/cmd/lk/analytics_test.go b/cmd/lk/analytics_test.go new file mode 100644 index 000000000..421268c24 --- /dev/null +++ b/cmd/lk/analytics_test.go @@ -0,0 +1,100 @@ +// 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 main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/livekit/livekit-cli/v2/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalyticsCommandTree(t *testing.T) { + analyticsCmd := findCommandByName(AnalyticsCommands, "analytics") + require.NotNil(t, analyticsCmd, "top-level 'analytics' command must exist") + + listCmd := findCommandByName(analyticsCmd.Commands, "list") + require.NotNil(t, listCmd, "'analytics list' command must exist") + require.NotNil(t, listCmd.Action, "'analytics list' must have an action") + + getCmd := findCommandByName(analyticsCmd.Commands, "get") + require.NotNil(t, getCmd, "'analytics get' command must exist") + require.NotNil(t, getCmd.Action, "'analytics get' must have an action") +} + +func TestValidateAnalyticsDateRange(t *testing.T) { + start, end, err := validateAnalyticsDateRange("2026-03-01", "2026-03-09") + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC), end) + + _, _, err = validateAnalyticsDateRange("2026-03-10", "2026-03-09") + require.Error(t, err) + assert.Contains(t, err.Error(), "start date must be less than or equal to end date") + + _, _, err = validateAnalyticsDateRange("03-01-2026", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid start date") + + _, _, err = validateAnalyticsDateRange("", "03-09-2026") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid end date") +} + +func TestMapAnalyticsHTTPError(t *testing.T) { + err := mapAnalyticsHTTPError(401, "scale plan or higher is required") + require.Error(t, err) + assert.Contains(t, err.Error(), "Scale plan or higher") + + err = mapAnalyticsHTTPError(403, "forbidden") + require.Error(t, err) + assert.Contains(t, err.Error(), "not authorized") + + err = mapAnalyticsHTTPError(500, "internal error") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 500") +} + +func TestRawJSONToString(t *testing.T) { + val := json.RawMessage(`"1234"`) + assert.Equal(t, "1234", rawJSONToString(val)) + + val = json.RawMessage(`1234`) + assert.Equal(t, "1234", rawJSONToString(val)) + + val = json.RawMessage(``) + assert.Equal(t, "-", rawJSONToString(val)) +} + +func TestResolveAnalyticsProjectID(t *testing.T) { + originalProject := project + defer func() { + project = originalProject + }() + + project = &config.ProjectConfig{Name: "staging", ProjectId: "p_123"} + projectID, err := resolveAnalyticsProjectID() + require.NoError(t, err) + assert.Equal(t, "p_123", projectID) + + project = &config.ProjectConfig{Name: "staging", ProjectId: ""} + _, err = resolveAnalyticsProjectID() + require.Error(t, err) + assert.Contains(t, err.Error(), "selected project [staging] is missing project_id") + assert.Contains(t, err.Error(), "Select a cloud project via --project or run `lk cloud auth`") +} diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 26576c29c..c74cb9dab 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -59,6 +59,7 @@ func main() { app.Commands = append(app.Commands, AppCommands...) app.Commands = append(app.Commands, AgentCommands...) + app.Commands = append(app.Commands, AnalyticsCommands...) app.Commands = append(app.Commands, CloudCommands...) app.Commands = append(app.Commands, DocsCommands...) app.Commands = append(app.Commands, ProjectCommands...) From 717f73ef093f69287787c90b16e830cf62ddb27f Mon Sep 17 00:00:00 2001 From: Nick Thompson Date: Tue, 14 Jul 2026 11:27:58 -0400 Subject: [PATCH 2/2] fix: address analytics review feedback --- autocomplete/fish_autocomplete | 17 +++++++++++- cmd/lk/analytics.go | 50 ++++++++++++++++++++++++---------- cmd/lk/analytics_test.go | 27 ++++++++++++++++++ 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 327bb34ce..6e7165555 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -2,7 +2,7 @@ function __fish_lk_no_subcommand --description 'Test if there has been any subcommand yet' for i in (commandline -opc) - if contains -- $i generate-fish-completion app agent a cloud docs project set-theme room create-room list-rooms list-room update-room-metadata list-participants get-participant remove-participant update-participant mute-track update-subscriptions send-data token create-token join-room dispatch egress start-room-composite-egress start-web-egress start-participant-egress start-track-composite-egress start-track-egress list-egress update-layout update-stream stop-egress test-egress-template ingress create-ingress update-ingress list-ingress delete-ingress sip list-sip-trunk delete-sip-trunk create-sip-dispatch-rule list-sip-dispatch-rule delete-sip-dispatch-rule create-sip-participant number replay perf load-test completion + if contains -- $i generate-fish-completion app agent a analytics cloud docs project set-theme room create-room list-rooms list-room update-room-metadata list-participants get-participant remove-participant update-participant mute-track update-subscriptions send-data token create-token join-room dispatch egress start-room-composite-egress start-web-egress start-participant-egress start-track-composite-egress start-track-egress list-egress update-layout update-stream stop-egress test-egress-template ingress create-ingress update-ingress list-ingress delete-ingress sip list-sip-trunk delete-sip-trunk create-sip-dispatch-rule list-sip-dispatch-rule delete-sip-dispatch-rule create-sip-participant number replay perf load-test completion return 1 end end @@ -225,6 +225,21 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d 'Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or "" to target the project\'s default agent (the one that auto-joins every room). Requires --scenarios.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_lk_no_subcommand' -a 'analytics' -d 'List and inspect LiveKit Cloud analytics sessions' +complete -c lk -n '__fish_seen_subcommand_from analytics' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'list' -d 'List analytics sessions' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l limit -r -d 'Maximum number of sessions to return' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l page -r -d 'Page number (starts at 0)' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l start -r -d 'Start date in `YYYY-MM-DD` format' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l end -r -d 'End date in `YYYY-MM-DD` format' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from list; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'get' -d 'Get analytics session details by session ID' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get' -f -l json -s j -d 'Output as JSON' +complete -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and __fish_seen_subcommand_from get; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -x -c lk -n '__fish_seen_subcommand_from analytics; and not __fish_seen_subcommand_from list get help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_lk_no_subcommand' -a 'cloud' -d 'Interact with LiveKit Cloud services' complete -c lk -n '__fish_seen_subcommand_from cloud' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from cloud; and not __fish_seen_subcommand_from auth help h' -a 'auth' -d 'Authenticate LiveKit Cloud account to link your projects' diff --git a/cmd/lk/analytics.go b/cmd/lk/analytics.go index b251a9c76..ab32d81fb 100644 --- a/cmd/lk/analytics.go +++ b/cmd/lk/analytics.go @@ -33,6 +33,7 @@ import ( ) const ( + defaultAnalyticsLimit = 10 analyticsProjectIDRequirement = "analytics API requires a LiveKit Cloud project with a known project_id" analyticsProjectSelectHint = "Select a cloud project via --project or run `lk cloud auth`" ) @@ -52,6 +53,7 @@ var ( &cli.IntFlag{ Name: "limit", Usage: "Maximum number of sessions to return", + Value: defaultAnalyticsLimit, }, &cli.IntFlag{ Name: "page", @@ -145,7 +147,7 @@ func listAnalyticsSessions(ctx context.Context, cmd *cli.Command) error { } if len(res.Sessions) == 0 { - fmt.Println("No sessions found") + out.Result("No sessions found") return nil } @@ -163,12 +165,12 @@ func listAnalyticsSessions(ctx context.Context, cmd *cli.Command) error { emptyDash(session.EndedAt), strconv.Itoa(session.NumParticipants), strconv.Itoa(session.NumActiveParticipants), - rawJSONToString(session.BandwidthIn), - rawJSONToString(session.BandwidthOut), + formatBytes(session.BandwidthIn), + formatBytes(session.BandwidthOut), ) } - fmt.Println(table) + out.Result(table) return nil } @@ -207,9 +209,9 @@ func getAnalyticsSession(ctx context.Context, cmd *cli.Command) error { emptyDash(details.EndTime), strconv.Itoa(details.NumParticipants), rawJSONToString(details.ConnectionMinutes), - rawJSONToString(details.Bandwidth), + formatBytes(details.Bandwidth), ) - fmt.Println(summary) + out.Result(summary) if len(details.Participants) == 0 { return nil @@ -233,20 +235,18 @@ func getAnalyticsSession(ctx context.Context, cmd *cli.Command) error { ) } - fmt.Println(participantTable) + out.Result(participantTable) return nil } func buildAnalyticsListQuery(cmd *cli.Command) (url.Values, error) { query := url.Values{} - if cmd.IsSet("limit") { - limit := cmd.Int("limit") - if limit <= 0 { - return nil, errors.New("limit must be greater than 0") - } - query.Set("limit", strconv.Itoa(limit)) + limit := cmd.Int("limit") + if limit <= 0 { + return nil, errors.New("limit must be greater than 0") } + query.Set("limit", strconv.Itoa(limit)) if cmd.IsSet("page") { page := cmd.Int("page") @@ -333,7 +333,7 @@ func callAnalyticsAPI(ctx context.Context, cmd *cli.Command, sessionID string, q } if printCurl { - fmt.Printf("curl -H \"Authorization: Bearer %s\" \"%s\"\n", token, reqURL.String()) + out.Resultf("curl -H \"Authorization: Bearer %s\" \"%s\"\n", token, reqURL.String()) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) @@ -442,6 +442,28 @@ func rawJSONToString(value json.RawMessage) string { return emptyDash(string(value)) } +func formatBytes(value json.RawMessage) string { + raw := rawJSONToString(value) + bytes, err := strconv.ParseFloat(raw, 64) + if err != nil || bytes < 0 { + return raw + } + + if bytes < 1000 { + return fmt.Sprintf("%.0f B", bytes) + } + + units := "KMGTPE" + unitIndex := 0 + size := bytes / 1000 + for size >= 1000 && unitIndex < len(units)-1 { + size /= 1000 + unitIndex++ + } + + return fmt.Sprintf("%.1f %cB", size, units[unitIndex]) +} + func emptyDash(value string) string { if strings.TrimSpace(value) == "" { return "-" diff --git a/cmd/lk/analytics_test.go b/cmd/lk/analytics_test.go index 421268c24..7e5e761cd 100644 --- a/cmd/lk/analytics_test.go +++ b/cmd/lk/analytics_test.go @@ -15,6 +15,7 @@ package main import ( + "context" "encoding/json" "testing" "time" @@ -22,6 +23,7 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestAnalyticsCommandTree(t *testing.T) { @@ -81,6 +83,31 @@ func TestRawJSONToString(t *testing.T) { assert.Equal(t, "-", rawJSONToString(val)) } +func TestBuildAnalyticsListQueryUsesDefaultLimit(t *testing.T) { + var queryLimit string + cmd := &cli.Command{ + Flags: []cli.Flag{ + &cli.IntFlag{Name: "limit", Value: defaultAnalyticsLimit}, + }, + Action: func(_ context.Context, cmd *cli.Command) error { + query, err := buildAnalyticsListQuery(cmd) + queryLimit = query.Get("limit") + return err + }, + } + + require.NoError(t, cmd.Run(context.Background(), []string{"analytics-list"})) + assert.Equal(t, "10", queryLimit) +} + +func TestFormatBytes(t *testing.T) { + assert.Equal(t, "999 B", formatBytes(json.RawMessage(`999`))) + assert.Equal(t, "1.2 KB", formatBytes(json.RawMessage(`1234`))) + assert.Equal(t, "1.3 MB", formatBytes(json.RawMessage(`1260393`))) + assert.Equal(t, "-", formatBytes(nil)) + assert.Equal(t, "unknown", formatBytes(json.RawMessage(`"unknown"`))) +} + func TestResolveAnalyticsProjectID(t *testing.T) { originalProject := project defer func() {