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
40 changes: 27 additions & 13 deletions docs/runware_serverless_apps_logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,17 +43,24 @@ runware serverless apps logs <appId> [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 <nextCursor>

# 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")
```

Expand Down
91 changes: 71 additions & 20 deletions internal/cmd/serverless/apps_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +40,7 @@ type logsFlags struct {
window string
limit int
cursor string
sort string
follow bool
}

Expand All @@ -46,17 +50,24 @@ func newAppsLogsCmd(logger *log.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "logs <appId>",
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
Expand All @@ -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 <nextCursor>`,
# page through the recent window
runware serverless apps logs my-app --limit 50 --cursor <nextCursor>

# 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]
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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.
Expand Down
63 changes: 54 additions & 9 deletions internal/cmd/serverless/apps_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/serverless/apps_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading