diff --git a/docs/runware_serverless_apps_logs.md b/docs/runware_serverless_apps_logs.md index b696cea..21baa10 100644 --- a/docs/runware_serverless_apps_logs.md +++ b/docs/runware_serverless_apps_logs.md @@ -4,17 +4,24 @@ Show or follow logs for a serverless application ### Synopsis -Show recent application logs, oldest first, and optionally follow new ones. - -The recent page is read from the runtime log query over --window (default 1h), -and --limit and --cursor page through it. With --follow the command prints the -recent page, then streams new entries until interrupted; the stream reconnects -when the server ends it, and waits for the stream to open when a gateway -answers first, which is what an application that has written nothing does. The -live stream has no window, so --window, --limit and --cursor apply to the -recent page only, and --cursor cannot be combined with --follow. Entries -written between the recent page and the start of the stream, or while the -stream reconnects, can be missed or repeated. +Show recent application logs and optionally follow new ones. + +The recent page is read from the runtime log query over --window (default 1h). +Without --sort the command fetches the newest page (the API default) and +prints it oldest first, so a plain apps logs shows the latest entries as a +readable timeline. --sort newest lists newest first, so nextCursor walks +older. --sort oldest lists the oldest page of the window first, so nextCursor +walks newer. prevCursor walks the other way. Replay either cursor with the +same --sort, --window, and --limit. + +With --follow the command prints the recent page, then streams new entries +until interrupted; the stream reconnects when the server ends it, and waits +for the stream to open when a gateway answers first, which is what an +application that has written nothing does. The live stream has no window, so +--window, --limit, --sort and --cursor apply to the recent page only, and +--cursor cannot be combined with --follow. Entries written between the recent +page and the start of the stream, or while the stream reconnects, can be +missed or repeated. In table format each entry is one line: time, level and message. In json or yaml format the recent page is printed as one document; with --follow every @@ -36,17 +43,24 @@ runware serverless apps logs [flags] # follow new log entries until Ctrl-C runware serverless apps logs my-app --follow - # page through older entries + # page through the recent window runware serverless apps logs my-app --limit 50 --cursor + + # newest first + runware serverless apps logs my-app --sort newest + + # oldest page of the window + runware serverless apps logs my-app --sort oldest ``` ### Options ``` - --cursor string Pagination cursor from a previous nextCursor + --cursor string Pagination cursor (nextCursor or prevCursor) -f, --follow Stream new log entries until interrupted -h, --help help for logs --limit int Maximum number of entries on the recent page (1-100, default 20) + --sort string Order of the recent page (oldest or newest; default: latest page, oldest first) --window string Time window for the recent page (1h, 6h, 24h, 7d, or 30d) (default "1h") ``` diff --git a/internal/cmd/serverless/apps_logs.go b/internal/cmd/serverless/apps_logs.go index 2d4a8cc..74208b7 100644 --- a/internal/cmd/serverless/apps_logs.go +++ b/internal/cmd/serverless/apps_logs.go @@ -28,6 +28,9 @@ const tailReconnectDelay = 2 * time.Second // logWindows lists the accepted --window values, in the order they are documented. const logWindows = "1h, 6h, 24h, 7d, or 30d" +// logSorts lists the accepted --sort values. +const logSorts = "oldest or newest" + // logTailer opens one live log stream and hands each entry to emit until the // stream ends or ctx is cancelled. type logTailer func(ctx context.Context, emit func(serverlessapi.LogEntry) error) error @@ -37,6 +40,7 @@ type logsFlags struct { window string limit int cursor string + sort string follow bool } @@ -46,17 +50,24 @@ func newAppsLogsCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "logs ", Short: "Show or follow logs for a serverless application", - Long: `Show recent application logs, oldest first, and optionally follow new ones. - -The recent page is read from the runtime log query over --window (default 1h), -and --limit and --cursor page through it. With --follow the command prints the -recent page, then streams new entries until interrupted; the stream reconnects -when the server ends it, and waits for the stream to open when a gateway -answers first, which is what an application that has written nothing does. The -live stream has no window, so --window, --limit and --cursor apply to the -recent page only, and --cursor cannot be combined with --follow. Entries -written between the recent page and the start of the stream, or while the -stream reconnects, can be missed or repeated. + Long: `Show recent application logs and optionally follow new ones. + +The recent page is read from the runtime log query over --window (default 1h). +Without --sort the command fetches the newest page (the API default) and +prints it oldest first, so a plain apps logs shows the latest entries as a +readable timeline. --sort newest lists newest first, so nextCursor walks +older. --sort oldest lists the oldest page of the window first, so nextCursor +walks newer. prevCursor walks the other way. Replay either cursor with the +same --sort, --window, and --limit. + +With --follow the command prints the recent page, then streams new entries +until interrupted; the stream reconnects when the server ends it, and waits +for the stream to open when a gateway answers first, which is what an +application that has written nothing does. The live stream has no window, so +--window, --limit, --sort and --cursor apply to the recent page only, and +--cursor cannot be combined with --follow. Entries written between the recent +page and the start of the stream, or while the stream reconnects, can be +missed or repeated. In table format each entry is one line: time, level and message. In json or yaml format the recent page is printed as one document; with --follow every @@ -70,8 +81,14 @@ entry is printed as one JSON object per line.`, # follow new log entries until Ctrl-C runware serverless apps logs my-app --follow - # page through older entries - runware serverless apps logs my-app --limit 50 --cursor `, + # page through the recent window + runware serverless apps logs my-app --limit 50 --cursor + + # newest first + runware serverless apps logs my-app --sort newest + + # oldest page of the window + runware serverless apps logs my-app --sort oldest`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { appID := args[0] @@ -99,12 +116,13 @@ entry is printed as one JSON object per line.`, return err } + page.Entries = chronologicalLogEntries(page.Entries, flags.sort) if !flags.follow { return printLogPage(format, page, out, errOut, extraLogsCursorFlags(flags)) } emit := logEmitter(format, out) - for _, entry := range slices.Backward(page.Entries) { + for _, entry := range page.Entries { if err := emit(entry); err != nil { return err } @@ -118,7 +136,8 @@ entry is printed as one JSON object per line.`, cmd.Flags().StringVar(&flags.window, "window", "1h", "Time window for the recent page ("+logWindows+")") cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of entries on the recent page (1-100, default 20)") - cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + cmd.Flags().StringVar(&flags.sort, "sort", "", "Order of the recent page ("+logSorts+"; default: latest page, oldest first)") + cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor (nextCursor or prevCursor)") cmd.Flags().BoolVarP(&flags.follow, "follow", "f", false, "Stream new log entries until interrupted") return cmd } @@ -135,28 +154,53 @@ func logEntriesParams(appID string, flags logsFlags) (serverlessapi.GetLogEntrie if window == nil { return serverlessapi.GetLogEntriesParams{}, fmt.Errorf("--window is required (want %s)", logWindows) } + sort, err := parseLogSort(flags.sort) + if err != nil { + return serverlessapi.GetLogEntriesParams{}, err + } params := serverlessapi.GetLogEntriesParams{ Window: *window, Deployment: &appID, + Sort: sort, } params.Limit, params.Cursor = listPageParams(flags.limit, flags.cursor) return params, nil } +func parseLogSort(sort string) (*serverlessapi.LogSort, error) { + if sort == "" { + return nil, nil + } + return parseValidFlag[serverlessapi.LogSort]("--sort", sort, logSorts) +} + +// chronologicalLogEntries reverses a newest-first API page when --sort is +// unset, so the default view is the latest entries oldest-first. An explicit +// --sort keeps the API order. +func chronologicalLogEntries(entries []serverlessapi.LogEntry, sort string) []serverlessapi.LogEntry { + if sort != "" || len(entries) < 2 { + return entries + } + out := slices.Clone(entries) + slices.Reverse(out) + return out +} + // extraLogsCursorFlags repeats the filters a next-page --cursor is bound to. func extraLogsCursorFlags(flags logsFlags) string { parts := appendFlag(nil, "--window", flags.window) if flags.limit > 0 { parts = appendFlag(parts, "--limit", fmt.Sprint(flags.limit)) } + if flags.sort != "" { + parts = appendFlag(parts, "--sort", flags.sort) + } return strings.Join(parts, " ") } -// printLogPage prints one page, oldest first: as a document in json or yaml, -// as one line per entry in table format. +// printLogPage prints one page in the order the API returned it: as a document +// in json or yaml, as one line per entry in table format. func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, errOut io.Writer, extraCursorFlags string) error { - page.Entries = slices.Clone(page.Entries) - slices.Reverse(page.Entries) switch format { case output.FormatJSON, output.FormatYAML: return output.Print(format, page) @@ -166,8 +210,15 @@ func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, er return err } } - return printNextCursor(errOut, page.NextCursor, extraCursorFlags) + return printLogCursors(errOut, page, extraCursorFlags) + } +} + +func printLogCursors(errOut io.Writer, page serverlessapi.LogEntryPage, extraFlags string) error { + if err := printNamedCursor(errOut, "Next page", page.NextCursor, extraFlags); err != nil { + return err } + return printNamedCursor(errOut, "Previous page", page.PrevCursor, extraFlags) } // logEmitter returns the per-entry writer a live stream uses for format. diff --git a/internal/cmd/serverless/apps_logs_test.go b/internal/cmd/serverless/apps_logs_test.go index 4044279..fc9efe0 100644 --- a/internal/cmd/serverless/apps_logs_test.go +++ b/internal/cmd/serverless/apps_logs_test.go @@ -43,7 +43,7 @@ func TestLogEntriesParams_OmitsUnsetOptionalFlags(t *testing.T) { if err != nil { t.Fatalf("logEntriesParams: %v", err) } - if params.Limit != nil || params.Cursor != nil { + if params.Limit != nil || params.Cursor != nil || params.Sort != nil { t.Errorf("optional params must be nil: %#v", params) } } @@ -107,25 +107,28 @@ func TestFormatLogLine(t *testing.T) { } } -func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { +func TestPrintLogPage_TablePrintsAPIOrderAndCursorHints(t *testing.T) { next := testLogCursor + prev := "prev-cursor" page := serverlessapi.LogEntryPage{ Entries: []serverlessapi.LogEntry{ - { - Time: 1750000001, - Body: testLogBodySlow, - }, { Time: 1750000000, Body: testLogBodyReady, }, + { + Time: 1750000001, + Body: testLogBodySlow, + }, }, NextCursor: &next, + PrevCursor: &prev, } var out, errOut bytes.Buffer flags := logsFlags{ window: testLogWindow6h, limit: 50, + sort: "newest", } if err := printLogPage(output.FormatTable, page, &out, &errOut, extraLogsCursorFlags(flags)); err != nil { t.Fatalf("printLogPage: %v", err) @@ -134,9 +137,51 @@ func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { if len(lines) != 2 || !strings.HasSuffix(lines[0], testLogBodyReady) || !strings.HasSuffix(lines[1], testLogBodySlow) { t.Fatalf("stdout = %q", out.String()) } - want := "Next page: --window 6h --limit 50 --cursor " + testLogCursor - if !strings.Contains(errOut.String(), want) { - t.Fatalf("stderr = %q, want %q", errOut.String(), want) + wantNext := "Next page: --window 6h --limit 50 --sort newest --cursor " + testLogCursor + wantPrev := "Previous page: --window 6h --limit 50 --sort newest --cursor " + prev + if !strings.Contains(errOut.String(), wantNext) || !strings.Contains(errOut.String(), wantPrev) { + t.Fatalf("stderr = %q, want %q and %q", errOut.String(), wantNext, wantPrev) + } +} + +func TestParseLogSort(t *testing.T) { + got, err := parseLogSort("") + if err != nil || got != nil { + t.Fatalf("unset: got=%v err=%v", got, err) + } + got, err = parseLogSort("oldest") + if err != nil || got == nil || *got != serverlessapi.LogSortOldest { + t.Fatalf("oldest: got=%v err=%v", got, err) + } + got, err = parseLogSort("newest") + if err != nil || got == nil || *got != serverlessapi.LogSortNewest { + t.Fatalf("newest: got=%v err=%v", got, err) + } + _, err = parseLogSort("activity") + if err == nil || !strings.Contains(err.Error(), "oldest or newest") { + t.Fatalf("expected invalid --sort, got %v", err) + } +} + +func TestChronologicalLogEntries_ReversesTheDefaultNewestPage(t *testing.T) { + newestFirst := []serverlessapi.LogEntry{ + {Body: testLogBodySlow}, + {Body: testLogBodyReady}, + } + got := chronologicalLogEntries(newestFirst, "") + if len(got) != 2 || got[0].Body != testLogBodyReady || got[1].Body != testLogBodySlow { + t.Fatalf("default = %#v", got) + } + if newestFirst[0].Body != testLogBodySlow { + t.Fatal("caller slice was reordered") + } + kept := chronologicalLogEntries(newestFirst, "newest") + if kept[0].Body != testLogBodySlow { + t.Fatalf("explicit newest = %#v", kept) + } + oldest := chronologicalLogEntries(newestFirst, "oldest") + if oldest[0].Body != testLogBodySlow { + t.Fatalf("explicit oldest = %#v", oldest) } } diff --git a/internal/cmd/serverless/apps_tasks_test.go b/internal/cmd/serverless/apps_tasks_test.go index 7250b26..881e67e 100644 --- a/internal/cmd/serverless/apps_tasks_test.go +++ b/internal/cmd/serverless/apps_tasks_test.go @@ -58,7 +58,7 @@ func TestTasksResult_Headers(t *testing.T) { r := tasksResult{ {Id: testTaskID, Status: serverlessapi.TaskStatusPending, CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)}, } - if got := r.Headers(); len(got) != 5 { + if got := r.Headers(); len(got) != 6 || got[2] != colEndpoint { t.Fatalf("headers: %v", got) } rows := r.Rows() diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 7aa0a85..38efd02 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -48,6 +48,13 @@ const ( colIdleTTL = "Idle TTL (s)" colScalingDelay = "Scaling delay (s)" colConcurrency = "Concurrency" + colEffectiveMaxWorkers = "Effective max workers" + colActiveWorkers = "Active workers" + colQueueDepth = "Queue depth" + colRequests24h = "Requests (24h)" + colReqPerMin = "Req/min" + colP95 = "p95 (s)" + colP99 = "p99 (s)" ) // appResult wraps a single app for table/json/yaml display. @@ -77,6 +84,10 @@ func (r appResult) Rows() [][]any { {colIdleTTL, cfg.IdleTtlSecs}, {colScalingDelay, cfg.ScalingDelaySecs}, {colConcurrency, cfg.Concurrency}, + {colEffectiveMaxWorkers, formatOptionalInt32(r.EffectiveMaxWorkers)}, + {colActiveWorkers, r.Runtime.ActiveWorkers}, + {colQueueDepth, formatOptionalInt64(r.Runtime.QueueDepth)}, + {colRequests24h, formatOptionalInt64(r.Runtime.Requests24h)}, } } @@ -105,7 +116,7 @@ func (r appsResult) Rows() [][]any { type endpointsResult []serverlessapi.Endpoint func (r endpointsResult) Headers() []string { - return []string{"Path", colID, colCreated} + return []string{"Path", colID, colCreated, colStatus, colReqPerMin, colP95, colP99} } func (r endpointsResult) Rows() [][]any { @@ -116,6 +127,10 @@ func (r endpointsResult) Rows() [][]any { e.Path, e.Id.String(), formatOptionalTime(e.CreatedAt), + formatEndpointStatus(e.Runtime), + formatEndpointRate(e.Runtime), + formatEndpointP95(e.Runtime), + formatEndpointP99(e.Runtime), } } return rows @@ -135,6 +150,10 @@ func (r endpointResult) Rows() [][]any { {colApp, r.AppId}, {colCreated, formatOptionalTime(r.CreatedAt)}, {colUpdated, formatOptionalTime(r.UpdatedAt)}, + {colStatus, formatEndpointStatus(r.Runtime)}, + {colReqPerMin, formatEndpointRate(r.Runtime)}, + {colP95, formatEndpointP95(r.Runtime)}, + {colP99, formatEndpointP99(r.Runtime)}, } } @@ -262,7 +281,7 @@ func (r workerResult) Rows() [][]any { type tasksResult []serverlessapi.Task func (r tasksResult) Headers() []string { - return []string{colID, colStatus, colError, colCreated, colCompleted} + return []string{colID, colStatus, colEndpoint, colError, colCreated, colCompleted} } func (r tasksResult) Rows() [][]any { @@ -272,6 +291,7 @@ func (r tasksResult) Rows() [][]any { rows[i] = []any{ task.Id, string(task.Status), + task.EndpointPath, formatOptionalString(task.Error), formatTaskTime(task.CreatedAt), formatOptionalTime(task.CompletedAt), @@ -292,6 +312,7 @@ func (r taskResult) Rows() [][]any { {colID, r.Id}, {colApp, r.AppId}, {colStatus, string(r.Status)}, + {colEndpoint, r.EndpointPath}, {colCreated, formatTaskTime(r.CreatedAt)}, {colCompleted, formatOptionalTime(r.CompletedAt)}, {colError, formatOptionalString(r.Error)}, @@ -327,6 +348,48 @@ func formatOptionalInt32(v *int32) string { return fmt.Sprintf("%d", *v) } +func formatOptionalInt64(v *int64) string { + if v == nil { + return "" + } + return fmt.Sprintf("%d", *v) +} + +func formatOptionalFloat64(v *float64) string { + if v == nil { + return "" + } + return fmt.Sprintf("%.2f", *v) +} + +func formatEndpointStatus(rt *serverlessapi.EndpointRuntime) string { + if rt == nil || rt.Status == nil { + return "" + } + return string(*rt.Status) +} + +func formatEndpointRate(rt *serverlessapi.EndpointRuntime) string { + if rt == nil { + return "" + } + return formatOptionalFloat64(rt.RequestsPerMinute) +} + +func formatEndpointP95(rt *serverlessapi.EndpointRuntime) string { + if rt == nil { + return "" + } + return formatOptionalFloat64(rt.P95RequestDuration) +} + +func formatEndpointP99(rt *serverlessapi.EndpointRuntime) string { + if rt == nil { + return "" + } + return formatOptionalFloat64(rt.P99RequestDuration) +} + // buildsResult wraps build lists for table display. Log tail is omitted. type buildsResult []serverlessapi.Build @@ -567,13 +630,17 @@ func printPage[T any](format output.Format, page serverlessapi.Page[T], table ou } func printNextCursor(errOut io.Writer, next *string, extraFlags string) error { - if next == nil || *next == "" { + return printNamedCursor(errOut, "Next page", next, extraFlags) +} + +func printNamedCursor(errOut io.Writer, label string, cursor *string, extraFlags string) error { + if cursor == nil || *cursor == "" { return nil } - hint := "--cursor " + *next + hint := "--cursor " + *cursor if extraFlags != "" { hint = extraFlags + " " + hint } - _, err := fmt.Fprintf(errOut, "\nNext page: %s\n", hint) + _, err := fmt.Fprintf(errOut, "\n%s: %s\n", label, hint) return err } diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 1d2c57e..51ca266 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -325,6 +325,10 @@ func TestAppResult_IncludesConfiguration(t *testing.T) { colIdleTTL: int32(60), colScalingDelay: int32(10), colConcurrency: int32(1), + colEffectiveMaxWorkers: "", + colActiveWorkers: int64(0), + colQueueDepth: "", + colRequests24h: "", } got := make(map[string]any, len(rows)) for _, row := range rows { @@ -557,8 +561,8 @@ func TestEndpointResult(t *testing.T) { Path: testEndpointPath, CreatedAt: &created, }).Rows() - if len(rows) != 5 { - t.Fatalf("expected 5 rows, got %d", len(rows)) + if len(rows) != 9 { + t.Fatalf("expected 9 rows, got %d", len(rows)) } if rows[0][1] != "generate" || rows[2][1] != testAppID { t.Fatalf("unexpected rows %#v", rows) @@ -566,6 +570,13 @@ func TestEndpointResult(t *testing.T) { if rows[4][1] != "" { t.Fatalf("nil UpdatedAt should render empty, got %#v", rows[4][1]) } + got := make(map[string]any, len(rows)) + for _, row := range rows { + got[row[0].(string)] = row[1] + } + if v, ok := got[colStatus]; !ok || v != "" { + t.Fatalf("nil runtime status should render empty, got %#v", v) + } } func TestWorkerResult_NilOptionalFields(t *testing.T) {