Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 152 additions & 4 deletions cmd/lk/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const (
analyticsProjectSelectHint = "Select a cloud project via --project or run `lk cloud auth`"
)

// defaultPageLimit is how many of a session's participants a page reads by
// default.
const defaultPageLimit = 50

var (
AnalyticsCommands = []*cli.Command{
{
Expand Down Expand Up @@ -68,6 +72,19 @@ var (
jsonFlag,
},
},
{
Name: "participant",
Usage: "List a session's participants",
Commands: []*cli.Command{
{
Name: "list",
Usage: "List a session's participants (requires --experimental-auth)",
ArgsUsage: "SESSION_ID",
Action: sessionRead(participantListOptions, fetchSessionParticipants),
Flags: append([]cli.Flag{jsonFlag}, analyticsParticipantListFlags()...),
},
},
},
},
},
},
Expand Down Expand Up @@ -168,6 +185,42 @@ func analyticsSessionListFlags() []cli.Flag {
}
}

// pageFlags returns fresh --limit and --cursor flags for a command that prints
// one page of a session's items, for the same reason as
// analyticsSessionListFlags. These commands exist only under
// --experimental-auth, so no flag needs an auth-mode check.
func pageFlags(defaultLimit int, items string) []cli.Flag {
return []cli.Flag{
&cli.IntFlag{
Name: "limit",
Usage: "Maximum number of " + items + " to read (the server caps it at 100)",
Value: defaultLimit,
},
// Hidden like session list's: pass the cursor a prior page printed,
// with the same filters and order, to fetch the next.
&cli.StringFlag{
Name: "cursor",
Usage: "Page `CURSOR` from a prior page",
Hidden: true,
},
}
}

// analyticsParticipantListFlags returns fresh instances of the participant
// list's own flags (the shared jsonFlag is added by the command).
func analyticsParticipantListFlags() []cli.Flag {
return append([]cli.Flag{
&cli.StringFlag{
Name: "sort-by",
Usage: "Order by `FIELD`: joined or left, default joined",
},
&cli.StringFlag{
Name: "sort-order",
Usage: "Order direction: `ORDER` asc or desc, default desc",
},
}, pageFlags(defaultPageLimit, "participants")...)
}

// 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.
Expand Down Expand Up @@ -550,8 +603,9 @@ func sessionListOptions(cmd *cli.Command) (public.SessionListOptions, error) {
return opts, nil
}

// getUserAnalyticsSession fetches a single project session via the Public API
// under --experimental-auth.
// getUserAnalyticsSession fetches a single project session and its detail
// (totals, timelines, first page of participants) via the Public API under
// --experimental-auth.
func getUserAnalyticsSession(ctx context.Context, cmd *cli.Command) error {
client, conf, user, err := requireCloudClient(cmd)
if err != nil {
Expand All @@ -566,9 +620,103 @@ func getUserAnalyticsSession(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}
session, err := client.GetSession(ctx, projectID, sessionID)
session, detail, err := client.GetSession(ctx, projectID, sessionID)
if err != nil {
return cloudAPIError(err)
}
return render.Session(out, cmd.Bool("json"), *session)
return render.SessionDetail(out, cmd.Bool("json"), *session, detail)
}

// 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.
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,
) cli.ActionFunc {
return func(ctx context.Context, cmd *cli.Command) error {
if err := requireExperimentalAuth(cmd); err != nil {
return err
}
sessionID, err := extractArg(cmd)
if err != nil {
_ = cli.ShowSubcommandHelp(cmd)
return errors.New("session ID is required")
}
opts, err := readOptions(cmd)
if err != nil {
return err
}
client, conf, user, err := requireCloudClient(cmd)
if err != nil {
return err
}
projectID, err := resolveProjectRef(ctx, cmd, conf, user, "")
if err != nil {
return err
}
return fetch(ctx, client, projectID, sessionID, opts, cmd.Bool("json"))
}
}

// fetchSessionParticipants reads one page of a session's participants and
// prints it.
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 render.SessionParticipantsPage(out, asJSON, participants, nextCursor)
}

// participantListOptions reads the participant list flags. Bad limits and sort
// names fail here, before the project lookup.
func participantListOptions(cmd *cli.Command) (public.ParticipantListOptions, error) {
page, err := pageOptions(cmd)
if err != nil {
return public.ParticipantListOptions{}, err
}
opts := public.ParticipantListOptions{
PageOptions: page,
SortBy: cmd.String("sort-by"),
SortOrder: cmd.String("sort-order"),
}
if err := opts.Validate(); err != nil {
return public.ParticipantListOptions{}, err
}
return opts, nil
}

// 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) {
limit := cmd.Int("limit")
if limit <= 0 {
return public.PageOptions{}, errors.New("limit must be greater than 0")
}
return public.PageOptions{Limit: int32(limit), Cursor: cmd.String("cursor")}, nil
}

// 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 {
if public.IsNotFound(err) {
return fmt.Errorf("no session %s in project %s (%w)", sessionID, projectID, err)
}
return sessionAPIError(err)
}

// 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 {
if !public.IsPermissionDenied(err) {
return cloudAPIError(err)
}
return fmt.Errorf("%w — you don't have access to this project", err)
}
177 changes: 177 additions & 0 deletions cmd/lk/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@
package main

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/livekit/livekit-cli/v2/pkg/config"
"github.com/livekit/livekit-cli/v2/pkg/public"
"github.com/livekit/livekit-cli/v2/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
Expand All @@ -40,6 +46,15 @@ func TestAnalyticsCommandTree(t *testing.T) {
getCmd := findCommandByName(sessionCmd.Commands, "get")
require.NotNil(t, getCmd, "'analytics session get' command must exist")
require.NotNil(t, getCmd.Action, "'analytics session get' must have an action")

participantCmd := findCommandByName(sessionCmd.Commands, "participant")
require.NotNil(t, participantCmd, "'analytics session participant' command must exist")
participantListCmd := findCommandByName(participantCmd.Commands, "list")
require.NotNil(t, participantListCmd, "'analytics session participant list' command must exist")
require.NotNil(t, participantListCmd.Action, "'analytics session participant list' must have an action")
cursor := findFlagByName(participantListCmd.Flags, "cursor")
require.NotNil(t, cursor, "'analytics session participant list' must declare --cursor")
assert.True(t, cursor.(*cli.StringFlag).Hidden, "--cursor must be hidden")
}

func TestAnalyticsCommandRequiresExperimentalFlag(t *testing.T) {
Expand Down Expand Up @@ -325,3 +340,165 @@ func TestAnalyticsListModeFlags(t *testing.T) {
})
}
}

// participantListCmdOptions runs participantListOptions with the given
// arguments on a command built from fresh analyticsParticipantListFlags.
func participantListCmdOptions(t *testing.T, args ...string) (public.ParticipantListOptions, error) {
t.Helper()
var opts public.ParticipantListOptions
var optsErr error
cmd := &cli.Command{
Name: "list",
Flags: analyticsParticipantListFlags(),
Action: func(_ context.Context, cmd *cli.Command) error {
opts, optsErr = participantListOptions(cmd)
return nil
},
}
require.NoError(t, cmd.Run(context.Background(), append([]string{"list"}, args...)))
return opts, optsErr
}

func TestParticipantListOptions(t *testing.T) {
tests := []struct {
name string
args []string
want public.ParticipantListOptions
wantErr string
}{
{name: "defaults", want: public.ParticipantListOptions{PageOptions: public.PageOptions{Limit: defaultPageLimit}}},
{
name: "paging and sort",
args: []string{"--limit", "25", "--cursor", "abc", "--sort-by", "left", "--sort-order", "asc"},
want: public.ParticipantListOptions{PageOptions: public.PageOptions{Limit: 25, Cursor: "abc"}, SortBy: "left", SortOrder: "asc"},
},
{name: "unknown sort field", args: []string{"--sort-by", "name"}, wantErr: `invalid participant sort "name"`},
{name: "unknown sort order", args: []string{"--sort-order", "newest"}, wantErr: `invalid sort order "newest"`},
{name: "non-positive limit", args: []string{"--limit", "0"}, wantErr: "limit must be greater than 0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts, err := participantListCmdOptions(t, tt.args...)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, opts)
})
}
}

// TestParticipantListRequiresExperimentalAuth checks the participant listing,
// which has no API-key endpoint, refuses to run without --experimental-auth
// before reading its arguments or any config.
func TestParticipantListRequiresExperimentalAuth(t *testing.T) {
for _, args := range [][]string{
{"--experimental", "session", "participant", "list", "RM_1"},
{"--experimental", "session", "participant", "list"},
} {
err := runAnalytics(args...)
require.ErrorContains(t, err, "only available under --experimental-auth")
}
}

// participantsAPI starts a stand-in Public API that answers the participant
// list with status and body, and records the query it was asked with.
func participantsAPI(t *testing.T, status int, body string) (*public.Client, *url.Values) {
t.Helper()
var query url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/projects/p1/sessions/RM_1/participants" || r.Header.Get("Authorization") != "Bearer sekret" {
http.NotFound(w, r)
return
}
query = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
client, err := public.New(srv.URL, "sekret")
require.NoError(t, err)
return client, &query
}

// TestFetchSessionParticipants checks a page of participants prints as a
// table with a hint to re-run with the next cursor, sending the limit, cursor
// and order asked for, and --json prints {items, nextCursor}.
func TestFetchSessionParticipants(t *testing.T) {
const page = `{"items":[{"participantIdentity":"alice","region":"US East"}],` +
`"pageInfo":{"nextCursor":"c2","hasMore":true}}`
opts := public.ParticipantListOptions{PageOptions: public.PageOptions{Limit: 50, Cursor: "c1"}, SortBy: "left", SortOrder: "asc"}

client, query := participantsAPI(t, http.StatusOK, page)
stdout, stderr := captureOut(t)
require.NoError(t, fetchSessionParticipants(context.Background(), client, "p1", "RM_1", opts, false))
assert.Equal(t, url.Values{
"page.pageSize": {"50"}, "page.cursor": {"c1"},
"sortBy": {"PARTICIPANT_SORT_FIELD_LEFT_AT"}, "sortOrder": {"SORT_ORDER_ASC"},
}, *query)
for _, want := range []string{"alice", "US East"} {
assert.Contains(t, stdout.String(), want)
}
assert.Contains(t, stderr.String(), "More participants available — re-run with --cursor c2")

stdout, _ = captureOut(t)
require.NoError(t, fetchSessionParticipants(context.Background(), client, "p1", "RM_1", public.ParticipantListOptions{}, true))
var got struct {
Items []map[string]any `json:"items"`
NextCursor string `json:"nextCursor"`
}
require.NoError(t, json.Unmarshal(stdout.Bytes(), &got))
require.Len(t, got.Items, 1)
assert.Equal(t, "alice", got.Items[0]["participantIdentity"])
assert.Equal(t, "c2", got.NextCursor)
}

// TestFetchSessionParticipantsErrors checks a failed read says why: signed
// out, without access to the project, or no such session in the project.
func TestFetchSessionParticipantsErrors(t *testing.T) {
tests := []struct {
name string
status int
body string
wantErr string
}{
{name: "signed out", status: http.StatusUnauthorized, body: `{"code":16,"message":"authentication required"}`, wantErr: "lk cloud auth"},
{name: "permission denied", status: http.StatusForbidden, body: `{"code":7,"message":"permission denied"}`, wantErr: "permission denied — you don't have access to this project"},
{name: "no such session", status: http.StatusNotFound, body: `{"code":5,"message":"session not found"}`, wantErr: "no session RM_1 in project p1 (session not found)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, _ := participantsAPI(t, tt.status, tt.body)
stdout, _ := captureOut(t)
err := fetchSessionParticipants(context.Background(), client, "p1", "RM_1", public.ParticipantListOptions{}, false)
require.ErrorContains(t, err, tt.wantErr)
assert.Empty(t, stdout.String())
})
}
}

// captureOut points the command printer at buffers for one test.
func captureOut(t *testing.T) (stdout, stderr *bytes.Buffer) {
t.Helper()
stdout, stderr = &bytes.Buffer{}, &bytes.Buffer{}
prev := out
out = util.NewPrinter(stdout, stderr, false)
t.Cleanup(func() { out = prev })
return stdout, stderr
}

// 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")
assert.True(t, public.IsPermissionDenied(err))

signedOut := &public.APIError{Status: http.StatusUnauthorized, Message: "authentication required"}
assert.ErrorContains(t, sessionAPIError(signedOut), "lk cloud auth")
}
Loading
Loading