From 33ccbc28776c1f0b915243edbe8d3d14ac360544 Mon Sep 17 00:00:00 2001 From: Josh Kalderimis Date: Sat, 22 Aug 2026 19:11:24 +1200 Subject: [PATCH 1/3] Add `nh device logs` for reading device log lines Support for the device log APIs added in nerves-hub/nerves_hub_web#2939: GET /orgs/:org/products/:product/devices/:identifier/logs - internal/api: LogLine and ListDeviceLogs, with a filter for level, since, before, limit and order. Unset fields are omitted so the server applies its own defaults (100 lines, newest first). - cmd: `nh device logs `, rendering a table by default and the full lines under -o json. Metadata is hidden unless --meta is passed, since it is often long and the message is what is being scanned for. --since and --before also accept an offset from now (30m, 2h, 3d) rather than only ISO 8601, because that is how a log window is usually described. Offsets resolve to absolute timestamps client-side. --order and --limit are validated locally against the server's own rules so a typo fails immediately. Meta is decoded as map[string]any rather than map[string]string: the server documents string values, but a permissive type cannot fail the whole response if that ever varies. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + cmd/device_logs.go | 226 ++++++++++++++++++++++++++++++++++ cmd/device_logs_test.go | 240 +++++++++++++++++++++++++++++++++++++ internal/api/device_log.go | 115 ++++++++++++++++++ 4 files changed, 583 insertions(+) create mode 100644 cmd/device_logs.go create mode 100644 cmd/device_logs_test.go create mode 100644 internal/api/device_log.go diff --git a/README.md b/README.md index def7c1c..6bc3722 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ variable — see [Configuration](#configuration)): - `console `, `shell ` — connect to a device's console/shell - `iroh-console ` — open a remote IEx console over a peer-to-peer iroh connection (`--auth`, `--instance`; Ctrl-] to detach) +- `logs ` — show the log lines a device has sent + (`--level`, `--since`, `--before`, `--limit`, `--order`, `--meta`) - `run-code ` — run Elixir code on a device - `scripts `, `run-script ` — support scripts - `network-identities ` — list the keys a device reports holding on diff --git a/cmd/device_logs.go b/cmd/device_logs.go new file mode 100644 index 0000000..2673181 --- /dev/null +++ b/cmd/device_logs.go @@ -0,0 +1,226 @@ +/* +Copyright © 2026 NervesHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/nerves-hub/nh/internal/api" + "github.com/nerves-hub/nh/internal/config" + "github.com/spf13/cobra" +) + +// logsMaxLimit mirrors the server's cap, so an out-of-range --limit fails here +// rather than after a round trip. +const logsMaxLimit = 1000 + +// logOrders are the accepted --order values. +var logOrders = []string{"desc", "asc"} + +// relativeDays matches a plain day offset ("3d"), which time.ParseDuration does +// not accept but is the natural way to ask for logs from the last few days. +var relativeDays = regexp.MustCompile(`^(\d+)d$`) + +var deviceLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Show the log lines a device has sent", + Long: `Show the log lines a device has sent over the logging extension. + +Newest first by default; pass --order asc for oldest first. --since is +inclusive and --before is exclusive, so the oldest timestamp of one page can be +passed straight back as the next --before without repeating that line. + +Both accept an ISO 8601 timestamp (2026-08-16T09:14:00Z) or an offset from now +(30m, 2h, 3d). + +Log lines are dropped three days after they were logged, and are only collected +while the logging extension is enabled for the product or device.`, + Example: ` nh device logs thermostat-4021 + nh device logs thermostat-4021 --level error,warning --limit 50 + nh device logs thermostat-4021 --since 2h --order asc + nh device logs thermostat-4021 -o json`, + Args: deviceIdentifierArgs, + RunE: runDeviceLogs, +} + +func init() { + deviceLogsCmd.Flags().StringSlice("level", nil, "only lines at these levels (repeatable, or comma-separated)") + deviceLogsCmd.Flags().String("since", "", "only lines at or after this time (ISO 8601, or an offset like 2h)") + deviceLogsCmd.Flags().String("before", "", "only lines before this time (ISO 8601, or an offset like 2h)") + deviceLogsCmd.Flags().Int("limit", 0, fmt.Sprintf("maximum lines to return, 1-%d (default 100)", logsMaxLimit)) + deviceLogsCmd.Flags().String("order", "", "desc for newest first (default), or asc") + deviceLogsCmd.Flags().Bool("meta", false, "append the metadata the device attached to each line") + deviceCmd.AddCommand(deviceLogsCmd) +} + +func runDeviceLogs(cmd *cobra.Command, args []string) error { + identifier := args[0] + + cfg := config.From(cmd.Context()) + org, product, err := requireOrgProduct(cfg) + if err != nil { + return err + } + + filter, err := deviceLogsFilter(cmd) + if err != nil { + return err + } + + client, err := newAuthedClient(cfg) + if err != nil { + return err + } + + lines, err := client.ListDeviceLogs(cmd.Context(), org, product, identifier, filter) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + if cfg.Output == config.OutputJSON { + return printJSON(w, lines) + } + + if len(lines) == 0 { + fmt.Fprintf(w, "No log lines found for device %s.\n", identifier) + return nil + } + + showMeta, _ := cmd.Flags().GetBool("meta") + tw := newTableWriter(w) + fmt.Fprintln(tw, "TIMESTAMP\tLEVEL\tMESSAGE") + for _, line := range lines { + message := line.Message + if showMeta { + if meta := formatLogMeta(line.Meta); meta != "" { + message += " " + meta + } + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", formatLogTime(line.Timestamp), orDash(line.Level), message) + } + return tw.Flush() +} + +// deviceLogsFilter builds the query from the flags, validating locally what the +// server would reject anyway so mistakes surface without a round trip. +func deviceLogsFilter(cmd *cobra.Command) (api.DeviceLogsFilter, error) { + var filter api.DeviceLogsFilter + + levels, _ := cmd.Flags().GetStringSlice("level") + filter.Levels = levels + + since, err := resolveLogTime(mustString(cmd, "since")) + if err != nil { + return filter, fmt.Errorf("invalid --since: %w", err) + } + filter.Since = since + + before, err := resolveLogTime(mustString(cmd, "before")) + if err != nil { + return filter, fmt.Errorf("invalid --before: %w", err) + } + filter.Before = before + + limit, _ := cmd.Flags().GetInt("limit") + if cmd.Flags().Changed("limit") && (limit < 1 || limit > logsMaxLimit) { + return filter, fmt.Errorf("invalid --limit %d: must be between 1 and %d", limit, logsMaxLimit) + } + filter.Limit = limit + + order := strings.ToLower(strings.TrimSpace(mustString(cmd, "order"))) + if order != "" && !slices.Contains(logOrders, order) { + return filter, fmt.Errorf("invalid --order %q: must be one of %s", order, strings.Join(logOrders, ", ")) + } + filter.Order = order + + return filter, nil +} + +// resolveLogTime accepts either an ISO 8601 timestamp, passed through, or an +// offset from now ("30m", "2h", "3d"), resolved to an absolute UTC timestamp. +// An empty value stays empty so the server applies no bound. +func resolveLogTime(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + + if m := relativeDays.FindStringSubmatch(value); m != nil { + days, err := strconv.Atoi(m[1]) + if err != nil { + return "", fmt.Errorf("%q is not a valid day offset", value) + } + return time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339), nil + } + + if d, err := time.ParseDuration(value); err == nil { + if d < 0 { + return "", fmt.Errorf("%q must be a positive offset", value) + } + return time.Now().UTC().Add(-d).Format(time.RFC3339), nil + } + + // Not an offset, so it must be a timestamp. Parse it here rather than + // letting the server 422, and normalise it to RFC 3339. + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} { + if t, err := time.Parse(layout, value); err == nil { + return t.UTC().Format(time.RFC3339Nano), nil + } + } + return "", fmt.Errorf("%q is neither an ISO 8601 timestamp (2026-08-16T09:14:00Z) nor an offset (30m, 2h, 3d)", value) +} + +// formatLogTime renders a log timestamp keeping millisecond precision, which +// ordering by hand depends on. +func formatLogTime(ts api.Timestamp) string { + if ts.IsZero() { + return "-" + } + return ts.Format("2006-01-02 15:04:05.000 MST") +} + +// formatLogMeta renders metadata as sorted key=value pairs, so repeated runs +// read the same way. +func formatLogMeta(meta map[string]any) string { + if len(meta) == 0 { + return "" + } + keys := make([]string, 0, len(meta)) + for k := range meta { + keys = append(keys, k) + } + sort.Strings(keys) + + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%v", k, meta[k])) + } + return strings.Join(pairs, " ") +} diff --git a/cmd/device_logs_test.go b/cmd/device_logs_test.go new file mode 100644 index 0000000..caafa5c --- /dev/null +++ b/cmd/device_logs_test.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const deviceLogsResponse = `{"data":[ + {"timestamp":"2026-08-16T09:14:00.123456Z","level":"error", + "message":"Failed to reach the sensor bus", + "meta":{"file":"lib/thermostat/sensors.ex","line":"42"}}, + {"timestamp":"2026-08-16T09:13:59.998211Z","level":"info", + "message":"Booted","meta":{}} +]}` + +// logsServer serves the canned response and records the query it was called +// with. +func logsServer(t *testing.T) (*httptest.Server, *string, *url.Values) { + t.Helper() + path := new(string) + query := new(url.Values) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *path = r.URL.Path + *query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(deviceLogsResponse)) + })) + t.Cleanup(srv.Close) + return srv, path, query +} + +func TestDeviceLogsTable(t *testing.T) { + srv, path, _ := logsServer(t) + + out, err := execCmd(t, "", + "device", "logs", "dev1", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ) + if err != nil { + t.Fatalf("logs: %v\n%s", err, out) + } + if *path != "/api/orgs/acme/products/thermostat/devices/dev1/logs" { + t.Errorf("path: got %q", *path) + } + for _, want := range []string{"TIMESTAMP", "LEVEL", "MESSAGE", "error", "Failed to reach the sensor bus", "Booted"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q, got:\n%s", want, out) + } + } + // Metadata is hidden unless asked for. + if strings.Contains(out, "sensors.ex") { + t.Errorf("metadata should be hidden without --meta, got:\n%s", out) + } +} + +func TestDeviceLogsMetaFlag(t *testing.T) { + srv, _, _ := logsServer(t) + + out, err := execCmd(t, "", + "device", "logs", "dev1", "--meta", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ) + if err != nil { + t.Fatalf("logs --meta: %v\n%s", err, out) + } + // Sorted key=value pairs, so output is stable run to run. + if !strings.Contains(out, "file=lib/thermostat/sensors.ex line=42") { + t.Errorf("expected sorted metadata pairs, got:\n%s", out) + } +} + +func TestDeviceLogsJSON(t *testing.T) { + srv, _, _ := logsServer(t) + + out, err := execCmd(t, "", + "device", "logs", "dev1", "-o", "json", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ) + if err != nil { + t.Fatalf("logs json: %v\n%s", err, out) + } + for _, want := range []string{`"level": "error"`, `"message": "Failed to reach the sensor bus"`, `"file": "lib/thermostat/sensors.ex"`} { + if !strings.Contains(out, want) { + t.Errorf("json missing %s, got:\n%s", want, out) + } + } +} + +func TestDeviceLogsFiltersSentAsQuery(t *testing.T) { + srv, _, query := logsServer(t) + + _, err := execCmd(t, "", + "device", "logs", "dev1", + "--level", "error", "--level", "warning", + "--limit", "50", "--order", "asc", + "--since", "2026-08-16T09:00:00Z", + "--before", "2026-08-16T10:00:00Z", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ) + if err != nil { + t.Fatalf("logs: %v", err) + } + q := *query + // Repeated --level is sent as the comma-separated list the API expects. + if got := q.Get("level"); got != "error,warning" { + t.Errorf("level = %q, want error,warning", got) + } + if got := q.Get("limit"); got != "50" { + t.Errorf("limit = %q", got) + } + if got := q.Get("order"); got != "asc" { + t.Errorf("order = %q", got) + } + if got := q.Get("since"); !strings.HasPrefix(got, "2026-08-16T09:00:00") { + t.Errorf("since = %q", got) + } + if got := q.Get("before"); !strings.HasPrefix(got, "2026-08-16T10:00:00") { + t.Errorf("before = %q", got) + } +} + +// Unset filters must not be sent at all, so the server applies its own defaults. +func TestDeviceLogsOmitsUnsetFilters(t *testing.T) { + srv, _, query := logsServer(t) + + if _, err := execCmd(t, "", + "device", "logs", "dev1", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs: %v", err) + } + if len(*query) != 0 { + t.Errorf("no query params expected, got %v", *query) + } +} + +func TestDeviceLogsRelativeSince(t *testing.T) { + srv, _, query := logsServer(t) + + before := time.Now().UTC().Add(-2 * time.Hour) + if _, err := execCmd(t, "", + "device", "logs", "dev1", "--since", "2h", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs: %v", err) + } + + got := (*query).Get("since") + parsed, err := time.Parse(time.RFC3339, got) + if err != nil { + t.Fatalf("since %q is not RFC 3339: %v", got, err) + } + // Should be ~2h ago; allow a wide window for slow machines. + if diff := parsed.Sub(before); diff < -time.Minute || diff > time.Minute { + t.Errorf("since = %q, want roughly 2h ago (%s)", got, before.Format(time.RFC3339)) + } +} + +func TestDeviceLogsRelativeDays(t *testing.T) { + srv, _, query := logsServer(t) + + if _, err := execCmd(t, "", + "device", "logs", "dev1", "--since", "3d", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs: %v", err) + } + got := (*query).Get("since") + parsed, err := time.Parse(time.RFC3339, got) + if err != nil { + t.Fatalf("since %q is not RFC 3339: %v", got, err) + } + if days := time.Since(parsed).Hours() / 24; days < 2.9 || days > 3.1 { + t.Errorf("since = %q, want roughly 3 days ago", got) + } +} + +func TestDeviceLogsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + out, err := execCmd(t, "", + "device", "logs", "dev1", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ) + if err != nil { + t.Fatalf("logs: %v", err) + } + if !strings.Contains(out, "No log lines found for device dev1") { + t.Errorf("expected an empty message, got:\n%s", out) + } +} + +// Bad filters are rejected locally, without troubling the server. +func TestDeviceLogsRejectsBadFilters(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"order", []string{"--order", "sideways"}, "invalid --order"}, + {"limit low", []string{"--limit", "0"}, "invalid --limit"}, + {"limit high", []string{"--limit", "1001"}, "invalid --limit"}, + {"since", []string{"--since", "yesterday"}, "invalid --since"}, + {"before", []string{"--before", "soon"}, "invalid --before"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer srv.Close() + + args := append([]string{"device", "logs", "dev1"}, tc.args...) + args = append(args, "--org", "acme", "--product", "thermostat", "--uri", srv.URL, "--token", "tok") + + _, err := execCmd(t, "", args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %q, got %v", tc.want, err) + } + if called { + t.Error("no request should be made for an invalid filter") + } + }) + } +} diff --git a/internal/api/device_log.go b/internal/api/device_log.go new file mode 100644 index 0000000..c410bce --- /dev/null +++ b/internal/api/device_log.go @@ -0,0 +1,115 @@ +/* +Copyright © 2026 NervesHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package api + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +// LogLine is a line a device sent over the logging extension, as returned by +// GET /orgs/{org}/products/{product}/devices/{identifier}/logs. +type LogLine struct { + Timestamp Timestamp `json:"timestamp"` + // Level is usually an Elixir Logger level (debug, info, notice, warning, + // error, critical, alert, emergency), but a device may log at any level. + Level string `json:"level"` + Message string `json:"message"` + // Meta is the Logger metadata the device attached to the line. The server + // flattens values to strings, but this stays permissive so an unexpected + // type cannot fail the whole response. + Meta map[string]any `json:"meta,omitempty"` +} + +// DeviceLogsFilter narrows a device log query. Zero values are omitted, letting +// the server apply its own defaults (100 lines, newest first). +type DeviceLogsFilter struct { + // Levels matches any of the given levels. They are sent as the + // comma-separated list the API expects, and are matched as given: a level + // the device never logs at simply matches nothing. + Levels []string + // Since is inclusive, Before exclusive, both ISO 8601. Before being + // exclusive means the oldest line of a page can be passed straight back to + // fetch the next one without repeating it. + Since string + Before string + // Limit is 1–1000; 0 leaves the server default of 100. + Limit int + // Order is "desc" (default) or "asc". + Order string +} + +// ListDeviceLogs returns the log lines a device has sent, via +// GET /orgs/{org}/products/{product}/devices/{identifier}/logs. +func (c *Client) ListDeviceLogs(ctx context.Context, org, product, identifier string, filter DeviceLogsFilter) ([]LogLine, error) { + if org == "" { + return nil, errors.New("api: org is required") + } + if product == "" { + return nil, errors.New("api: product is required") + } + if identifier == "" { + return nil, errors.New("api: device identifier is required") + } + + query := url.Values{} + if levels := trimmedNonEmpty(filter.Levels); len(levels) > 0 { + query.Set("level", strings.Join(levels, ",")) + } + if filter.Since != "" { + query.Set("since", filter.Since) + } + if filter.Before != "" { + query.Set("before", filter.Before) + } + if filter.Limit != 0 { + query.Set("limit", strconv.Itoa(filter.Limit)) + } + if filter.Order != "" { + query.Set("order", filter.Order) + } + + var resp struct { + Data []LogLine `json:"data"` + } + if err := c.Get(ctx, devicePath(org, product, identifier)+"/logs", query, &resp); err != nil { + return nil, fmt.Errorf("fetching logs for device %s: %w", identifier, err) + } + return resp.Data, nil +} + +// trimmedNonEmpty drops blank entries so a stray comma cannot send an empty +// filter value. +func trimmedNonEmpty(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +} From f497bd8d9eecd61395caa26778335ee88ed09524 Mon Sep 17 00:00:00 2001 From: Josh Kalderimis Date: Sat, 22 Aug 2026 19:55:30 +1200 Subject: [PATCH 2/3] Add --follow to `nh device logs` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API has no streaming endpoint, so following is polling: print the most recent page, then ask for anything newer every --interval (2s by default) until interrupted. - The first page is fetched newest-first so it is the most recent lines, then reversed — a tail reads oldest to newest. Polls then ask oldest-first from the last timestamp seen. - `since` is inclusive, so the boundary line comes back on every poll. Rather than nudging the timestamp forward, which would silently drop a line sharing the same microsecond, the lines already printed at that exact timestamp are remembered and skipped. Only the boundary is tracked, so memory stays flat however long the tail runs. - With no history there is nothing to resume from, so following starts at now rather than replaying the oldest page. - A full page means more is probably waiting, so the next poll happens immediately instead of pacing behind the interval. - Ctrl-C ends the tail cleanly. Signal handling is scoped to the loop, leaving the rest of the CLI as it was. - JSON is emitted one object per line while following, since a stream has no end at which to close an array. One-shot output stays an indented array. - --order and --before contradict --follow and are refused, as is --interval without --follow. resetState now clears each command's context between tests. PersistentPreRunE stores config on the context, and cobra only inherits the root's context when a command's own is nil, so a command kept the previous test's context and ignored the one passed to ExecuteContext — which hung the follow tests when they ran after any other test in the package. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- cmd/device_logs.go | 236 ++++++++++++++++++++++++++++++++++++++-- cmd/device_logs_test.go | 192 ++++++++++++++++++++++++++++++++ cmd/helpers_test.go | 6 + 4 files changed, 428 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6bc3722..ff25988 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ variable — see [Configuration](#configuration)): - `iroh-console ` — open a remote IEx console over a peer-to-peer iroh connection (`--auth`, `--instance`; Ctrl-] to detach) - `logs ` — show the log lines a device has sent - (`--level`, `--since`, `--before`, `--limit`, `--order`, `--meta`) + (`--level`, `--since`, `--before`, `--limit`, `--order`, `--meta`); + `-f`/`--follow` tails new lines by polling (`--interval`) - `run-code ` — run Elixir code on a device - `scripts `, `run-script ` — support scripts - `network-identities ` — list the keys a device reports holding on diff --git a/cmd/device_logs.go b/cmd/device_logs.go index 2673181..d662afc 100644 --- a/cmd/device_logs.go +++ b/cmd/device_logs.go @@ -23,7 +23,13 @@ THE SOFTWARE. package cmd import ( + "context" + "encoding/json" + "errors" "fmt" + "io" + "os" + "os/signal" "regexp" "slices" "sort" @@ -43,6 +49,15 @@ const logsMaxLimit = 1000 // logOrders are the accepted --order values. var logOrders = []string{"desc", "asc"} +// defaultFollowInterval is how often --follow asks for new lines. The API has +// no streaming endpoint, so following is polling; a couple of seconds keeps a +// tail feeling live without hammering the server. +const defaultFollowInterval = 2 * time.Second + +// serverDefaultLimit mirrors the page size the API applies when no limit is +// given, so follow can tell a full page from a partial one. +const serverDefaultLimit = 100 + // relativeDays matches a plain day offset ("3d"), which time.ParseDuration does // not accept but is the natural way to ask for logs from the last few days. var relativeDays = regexp.MustCompile(`^(\d+)d$`) @@ -59,11 +74,16 @@ passed straight back as the next --before without repeating that line. Both accept an ISO 8601 timestamp (2026-08-16T09:14:00Z) or an offset from now (30m, 2h, 3d). +--follow prints the most recent lines and then keeps printing new ones until +interrupted, the way tail -f does. The API has no streaming endpoint, so this +is polling; --interval sets how often it asks. + Log lines are dropped three days after they were logged, and are only collected while the logging extension is enabled for the product or device.`, Example: ` nh device logs thermostat-4021 nh device logs thermostat-4021 --level error,warning --limit 50 nh device logs thermostat-4021 --since 2h --order asc + nh device logs thermostat-4021 --follow nh device logs thermostat-4021 -o json`, Args: deviceIdentifierArgs, RunE: runDeviceLogs, @@ -76,6 +96,8 @@ func init() { deviceLogsCmd.Flags().Int("limit", 0, fmt.Sprintf("maximum lines to return, 1-%d (default 100)", logsMaxLimit)) deviceLogsCmd.Flags().String("order", "", "desc for newest first (default), or asc") deviceLogsCmd.Flags().Bool("meta", false, "append the metadata the device attached to each line") + deviceLogsCmd.Flags().BoolP("follow", "f", false, "print new lines as they arrive, by polling, until interrupted") + deviceLogsCmd.Flags().Duration("interval", defaultFollowInterval, "how often --follow polls for new lines") deviceCmd.AddCommand(deviceLogsCmd) } @@ -98,27 +120,211 @@ func runDeviceLogs(cmd *cobra.Command, args []string) error { return err } + printer := newLogPrinter(cmd, cfg) + + if follow, _ := cmd.Flags().GetBool("follow"); follow { + interval, _ := cmd.Flags().GetDuration("interval") + printer.stream = true + return followDeviceLogs(cmd, client, printer, org, product, identifier, filter, interval) + } + lines, err := client.ListDeviceLogs(cmd.Context(), org, product, identifier, filter) if err != nil { return err } - w := cmd.OutOrStdout() - if cfg.Output == config.OutputJSON { - return printJSON(w, lines) + if len(lines) == 0 && !printer.json { + fmt.Fprintf(printer.w, "No log lines found for device %s.\n", identifier) + return nil } + return printer.print(lines) +} - if len(lines) == 0 { - fmt.Fprintf(w, "No log lines found for device %s.\n", identifier) +// followDeviceLogs prints the most recent lines and then polls for new ones +// until the context is cancelled or the user interrupts. +// +// The first page is fetched newest-first so it is the most recent lines, then +// reversed: a tail reads oldest to newest. Every poll after that asks +// oldest-first from the last timestamp seen. +func followDeviceLogs(cmd *cobra.Command, client *api.Client, printer *logPrinter, org, product, identifier string, filter api.DeviceLogsFilter, interval time.Duration) error { + if interval <= 0 { + return fmt.Errorf("invalid --interval %s: must be positive", interval) + } + + // Ctrl-C ends the tail cleanly rather than killing the process mid-write. + // Scoped to this loop, so the rest of the CLI keeps default behaviour. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stop() + + pageSize := filter.Limit + if pageSize == 0 { + pageSize = serverDefaultLimit + } + + initial := filter + initial.Order = "desc" + lines, err := client.ListDeviceLogs(ctx, org, product, identifier, initial) + if err != nil { + return followError(ctx, err) + } + slices.Reverse(lines) + + cursor := &logCursor{} + if err := printer.print(lines); err != nil { + return err + } + cursor.advance(lines) + + // With no history there is nothing to anchor to, so follow from now rather + // than replaying whatever the server considers the oldest page. + if cursor.last.IsZero() { + cursor.last = time.Now().UTC() + } + + for { + poll := filter + poll.Order = "asc" + poll.Before = "" + poll.Since = cursor.last.UTC().Format(time.RFC3339Nano) + + lines, err := client.ListDeviceLogs(ctx, org, product, identifier, poll) + if err != nil { + return followError(ctx, err) + } + + fresh := cursor.unseen(lines) + if err := printer.print(fresh); err != nil { + return err + } + cursor.advance(fresh) + + // A full page means there is probably more waiting, so catch up + // immediately instead of pacing behind the interval. + if len(lines) >= pageSize { + continue + } + + select { + case <-ctx.Done(): + return nil + case <-time.After(interval): + } + } +} + +// followError turns cancellation into a clean exit; a tail ended by Ctrl-C has +// not failed. +func followError(ctx context.Context, err error) error { + if ctx.Err() != nil { return nil } + return err +} +// logCursor tracks the newest line printed so polling can resume from it. +// +// The API's `since` is inclusive, so resuming from the last timestamp returns +// that line again. Rather than nudging the timestamp forward — which would +// silently drop a line sharing the same microsecond — the lines already printed +// at that exact timestamp are remembered and skipped. Only the boundary +// timestamp is tracked, so this stays small however long the tail runs. +type logCursor struct { + last time.Time + seen map[string]bool +} + +// unseen returns the lines that have not been printed yet. +func (c *logCursor) unseen(lines []api.LogLine) []api.LogLine { + fresh := make([]api.LogLine, 0, len(lines)) + for _, line := range lines { + ts := line.Timestamp.Time + switch { + case ts.Before(c.last): + continue + case ts.Equal(c.last) && c.seen[logLineKey(line)]: + continue + } + fresh = append(fresh, line) + } + return fresh +} + +// advance moves the cursor past the given lines. +func (c *logCursor) advance(lines []api.LogLine) { + for _, line := range lines { + ts := line.Timestamp.Time + if ts.After(c.last) { + c.last = ts + c.seen = nil + } + if ts.Equal(c.last) { + if c.seen == nil { + c.seen = map[string]bool{} + } + c.seen[logLineKey(line)] = true + } + } +} + +// logLineKey identifies a line within a single timestamp. +func logLineKey(line api.LogLine) string { + return line.Level + "\x00" + line.Message +} + +// logPrinter renders log lines in the configured output format. +type logPrinter struct { + w io.Writer + json bool + showMeta bool + // stream is set while following. It switches JSON output to one object per + // line, because a tail has no end at which to close an array. + stream bool + // headerWritten keeps the table header to the first batch, so a followed + // tail does not repeat it on every poll. + headerWritten bool +} + +func newLogPrinter(cmd *cobra.Command, cfg *config.Config) *logPrinter { showMeta, _ := cmd.Flags().GetBool("meta") - tw := newTableWriter(w) - fmt.Fprintln(tw, "TIMESTAMP\tLEVEL\tMESSAGE") + return &logPrinter{ + w: cmd.OutOrStdout(), + json: cfg.Output == config.OutputJSON, + showMeta: showMeta, + } +} + +// print writes the lines, flushing as it goes so a tail appears live. +// +// JSON output is one object per line rather than an array: a followed stream +// has no end, so there is no closing bracket to write. +func (p *logPrinter) print(lines []api.LogLine) error { + if len(lines) == 0 { + return nil + } + + if p.json { + // One-shot output stays a single indented array, like every other + // command. Only a stream is emitted line by line. + if !p.stream { + return printJSON(p.w, lines) + } + enc := json.NewEncoder(p.w) + for _, line := range lines { + if err := enc.Encode(line); err != nil { + return err + } + } + return nil + } + + tw := newTableWriter(p.w) + if !p.headerWritten { + fmt.Fprintln(tw, "TIMESTAMP\tLEVEL\tMESSAGE") + p.headerWritten = true + } for _, line := range lines { message := line.Message - if showMeta { + if p.showMeta { if meta := formatLogMeta(line.Meta); meta != "" { message += " " + meta } @@ -133,6 +339,20 @@ func runDeviceLogs(cmd *cobra.Command, args []string) error { func deviceLogsFilter(cmd *cobra.Command) (api.DeviceLogsFilter, error) { var filter api.DeviceLogsFilter + // --follow decides its own ordering and has no upper bound, so combining it + // with either is a contradiction rather than something to resolve silently. + follow, _ := cmd.Flags().GetBool("follow") + if follow { + if cmd.Flags().Changed("order") { + return filter, errors.New("--order cannot be used with --follow, which always prints oldest first") + } + if cmd.Flags().Changed("before") { + return filter, errors.New("--before cannot be used with --follow, which prints lines as they arrive") + } + } else if cmd.Flags().Changed("interval") { + return filter, errors.New("--interval only applies with --follow") + } + levels, _ := cmd.Flags().GetStringSlice("level") filter.Levels = levels diff --git a/cmd/device_logs_test.go b/cmd/device_logs_test.go index caafa5c..f03f3ab 100644 --- a/cmd/device_logs_test.go +++ b/cmd/device_logs_test.go @@ -1,10 +1,13 @@ package cmd import ( + "bytes" + "context" "net/http" "net/http/httptest" "net/url" "strings" + "sync" "testing" "time" ) @@ -238,3 +241,192 @@ func TestDeviceLogsRejectsBadFilters(t *testing.T) { }) } } + +// followServer serves a scripted sequence of responses, one per request, and +// records the queries. The last response repeats once exhausted. +func followServer(t *testing.T, bodies ...string) (*httptest.Server, func() []url.Values) { + t.Helper() + var mu sync.Mutex + var queries []url.Values + n := 0 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + queries = append(queries, r.URL.Query()) + body := bodies[min(n, len(bodies)-1)] + n++ + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return srv, func() []url.Values { + mu.Lock() + defer mu.Unlock() + return append([]url.Values(nil), queries...) + } +} + +// runFollow runs a --follow session and stops it once stopAfter has elapsed, by +// which point the scripted responses have been consumed. +func runFollow(t *testing.T, srvURL string, stopAfter time.Duration, extra ...string) string { + t.Helper() + resetState(t) + + args := append([]string{ + "device", "logs", "dev1", "--follow", "--interval", "20ms", + "--org", "acme", "--product", "thermostat", + "--uri", srvURL, "--token", "tok", + }, extra...) + + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), stopAfter) + defer cancel() + + rootCmd.SetArgs(args) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + // Cancellation is the only way a tail ends; it must exit cleanly. + if err := rootCmd.ExecuteContext(ctx); err != nil { + t.Fatalf("follow: %v\n%s", err, out.String()) + } + return out.String() +} + +func TestDeviceLogsFollowPrintsInitialThenNewLines(t *testing.T) { + initial := `{"data":[ + {"timestamp":"2026-08-16T09:14:00.000000Z","level":"info","message":"second"}, + {"timestamp":"2026-08-16T09:13:00.000000Z","level":"info","message":"first"} + ]}` + poll := `{"data":[{"timestamp":"2026-08-16T09:15:00.000000Z","level":"warning","message":"third"}]}` + srv, queries := followServer(t, initial, poll, `{"data":[]}`) + + out := runFollow(t, srv.URL, 250*time.Millisecond) + + // The initial page arrives newest-first and must be reversed for reading. + firstIdx := strings.Index(out, "first") + secondIdx := strings.Index(out, "second") + thirdIdx := strings.Index(out, "third") + if firstIdx < 0 || secondIdx < 0 || thirdIdx < 0 { + t.Fatalf("missing lines in output:\n%s", out) + } + if !(firstIdx < secondIdx && secondIdx < thirdIdx) { + t.Errorf("lines should print oldest first, got:\n%s", out) + } + // The header belongs to the first batch only. + if n := strings.Count(out, "TIMESTAMP"); n != 1 { + t.Errorf("header printed %d times, want 1", n) + } + + q := queries() + if len(q) < 2 { + t.Fatalf("expected at least 2 requests, got %d", len(q)) + } + if got := q[0].Get("order"); got != "desc" { + t.Errorf("initial order = %q, want desc (most recent page)", got) + } + if got := q[1].Get("order"); got != "asc" { + t.Errorf("poll order = %q, want asc", got) + } + if got := q[1].Get("since"); !strings.HasPrefix(got, "2026-08-16T09:14:00") { + t.Errorf("poll should resume from the newest line seen, got since=%q", got) + } +} + +// `since` is inclusive, so the boundary line comes back on the next poll. It +// must not be printed twice, and a different line sharing that timestamp must +// still be printed. +func TestDeviceLogsFollowDeduplicatesBoundaryLine(t *testing.T) { + initial := `{"data":[{"timestamp":"2026-08-16T09:14:00.000000Z","level":"info","message":"boundary"}]}` + poll := `{"data":[ + {"timestamp":"2026-08-16T09:14:00.000000Z","level":"info","message":"boundary"}, + {"timestamp":"2026-08-16T09:14:00.000000Z","level":"error","message":"same-microsecond"}, + {"timestamp":"2026-08-16T09:16:00.000000Z","level":"info","message":"later"} + ]}` + srv, _ := followServer(t, initial, poll, `{"data":[]}`) + + out := runFollow(t, srv.URL, 250*time.Millisecond) + + if n := strings.Count(out, "boundary"); n != 1 { + t.Errorf("boundary line printed %d times, want 1:\n%s", n, out) + } + // A distinct line at the same microsecond must not be lost to dedupe. + if !strings.Contains(out, "same-microsecond") { + t.Errorf("a different line at the boundary timestamp was dropped:\n%s", out) + } + if !strings.Contains(out, "later") { + t.Errorf("missing the later line:\n%s", out) + } +} + +// With no history there is nothing to resume from, so following must start at +// now rather than replaying the oldest page. +func TestDeviceLogsFollowWithNoHistoryStartsFromNow(t *testing.T) { + srv, queries := followServer(t, `{"data":[]}`) + + _ = runFollow(t, srv.URL, 200*time.Millisecond) + + q := queries() + if len(q) < 2 { + t.Fatalf("expected a poll after the empty initial page, got %d requests", len(q)) + } + since := q[1].Get("since") + if since == "" { + t.Fatal("poll must be bounded by since, or it would replay old lines") + } + ts, err := time.Parse(time.RFC3339Nano, since) + if err != nil { + t.Fatalf("since %q is not RFC 3339: %v", since, err) + } + if time.Since(ts) > time.Minute { + t.Errorf("since = %q, want roughly now", since) + } +} + +func TestDeviceLogsFollowJSONIsOnePerLine(t *testing.T) { + initial := `{"data":[{"timestamp":"2026-08-16T09:14:00.000000Z","level":"info","message":"one"}]}` + srv, _ := followServer(t, initial, `{"data":[]}`) + + out := runFollow(t, srv.URL, 200*time.Millisecond, "-o", "json") + + // A stream has no closing bracket, so it must not be wrapped in an array. + if strings.Contains(out, "[") { + t.Errorf("streamed JSON should not be an array, got:\n%s", out) + } + line := strings.TrimSpace(out) + if !strings.HasPrefix(line, "{") || !strings.Contains(line, `"message":"one"`) { + t.Errorf("expected one JSON object per line, got:\n%s", out) + } +} + +func TestDeviceLogsFollowRejectsConflictingFlags(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"order", []string{"--follow", "--order", "asc"}, "--order cannot be used with --follow"}, + {"before", []string{"--follow", "--before", "2026-08-16T09:00:00Z"}, "--before cannot be used with --follow"}, + {"interval without follow", []string{"--interval", "5s"}, "--interval only applies with --follow"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer srv.Close() + + args := append([]string{"device", "logs", "dev1"}, tc.args...) + args = append(args, "--org", "acme", "--product", "thermostat", "--uri", srv.URL, "--token", "tok") + + _, err := execCmd(t, "", args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %q, got %v", tc.want, err) + } + if called { + t.Error("no request should be made for conflicting flags") + } + }) + } +} diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 55ae3c9..267d00c 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -38,6 +38,12 @@ func resetState(t *testing.T) { walk = func(c *cobra.Command) { resetFlagSet(c.PersistentFlags()) resetFlagSet(c.Flags()) + // PersistentPreRunE stores the resolved config on each command's + // context, and cobra only inherits the root's context when a command's + // own is nil. Left set, a command would keep the previous test's + // context — including one already cancelled — and ignore the one passed + // to ExecuteContext. + c.SetContext(nil) for _, sub := range c.Commands() { walk(sub) } From 9ac6421aeb1be8699b5e9431968eba69decfbc19 Mon Sep 17 00:00:00 2001 From: Josh Kalderimis Date: Sat, 22 Aug 2026 20:06:18 +1200 Subject: [PATCH 3/3] Add --search to `nh device logs` Support for the search param added to nerves-hub/nerves_hub_web#2939: keep only lines whose message contains the given text, ignoring case. The term is passed through untouched. The server matches it literally, so % and _ are searched for rather than treated as wildcards, and escaping them here would stop a device that logs "100%" being found by searching for it. A blank term is not a filter and is omitted rather than sent as an empty value. Search applies to every request a --follow tail makes, not just the first page. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- cmd/device_logs.go | 7 ++++ cmd/device_logs_test.go | 67 ++++++++++++++++++++++++++++++++++++++ internal/api/device_log.go | 7 ++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff25988..57ccc5e 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ variable — see [Configuration](#configuration)): - `iroh-console ` — open a remote IEx console over a peer-to-peer iroh connection (`--auth`, `--instance`; Ctrl-] to detach) - `logs ` — show the log lines a device has sent - (`--level`, `--since`, `--before`, `--limit`, `--order`, `--meta`); + (`--level`, `--search`, `--since`, `--before`, `--limit`, `--order`, `--meta`); `-f`/`--follow` tails new lines by polling (`--interval`) - `run-code ` — run Elixir code on a device - `scripts `, `run-script ` — support scripts diff --git a/cmd/device_logs.go b/cmd/device_logs.go index d662afc..6ecb1bc 100644 --- a/cmd/device_logs.go +++ b/cmd/device_logs.go @@ -74,6 +74,10 @@ passed straight back as the next --before without repeating that line. Both accept an ISO 8601 timestamp (2026-08-16T09:14:00Z) or an offset from now (30m, 2h, 3d). +--search keeps only lines whose message contains the given text, ignoring case. +It is matched literally, so % and _ are searched for rather than treated as +wildcards. + --follow prints the most recent lines and then keeps printing new ones until interrupted, the way tail -f does. The API has no streaming endpoint, so this is polling; --interval sets how often it asks. @@ -82,6 +86,7 @@ Log lines are dropped three days after they were logged, and are only collected while the logging extension is enabled for the product or device.`, Example: ` nh device logs thermostat-4021 nh device logs thermostat-4021 --level error,warning --limit 50 + nh device logs thermostat-4021 --search "sensor bus" nh device logs thermostat-4021 --since 2h --order asc nh device logs thermostat-4021 --follow nh device logs thermostat-4021 -o json`, @@ -91,6 +96,7 @@ while the logging extension is enabled for the product or device.`, func init() { deviceLogsCmd.Flags().StringSlice("level", nil, "only lines at these levels (repeatable, or comma-separated)") + deviceLogsCmd.Flags().String("search", "", "only lines whose message contains this text (case-insensitive, matched literally)") deviceLogsCmd.Flags().String("since", "", "only lines at or after this time (ISO 8601, or an offset like 2h)") deviceLogsCmd.Flags().String("before", "", "only lines before this time (ISO 8601, or an offset like 2h)") deviceLogsCmd.Flags().Int("limit", 0, fmt.Sprintf("maximum lines to return, 1-%d (default 100)", logsMaxLimit)) @@ -355,6 +361,7 @@ func deviceLogsFilter(cmd *cobra.Command) (api.DeviceLogsFilter, error) { levels, _ := cmd.Flags().GetStringSlice("level") filter.Levels = levels + filter.Search = mustString(cmd, "search") since, err := resolveLogTime(mustString(cmd, "since")) if err != nil { diff --git a/cmd/device_logs_test.go b/cmd/device_logs_test.go index f03f3ab..e5fd690 100644 --- a/cmd/device_logs_test.go +++ b/cmd/device_logs_test.go @@ -430,3 +430,70 @@ func TestDeviceLogsFollowRejectsConflictingFlags(t *testing.T) { }) } } + +func TestDeviceLogsSearchSentAsQuery(t *testing.T) { + srv, _, query := logsServer(t) + + if _, err := execCmd(t, "", + "device", "logs", "dev1", "--search", "sensor bus", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs --search: %v", err) + } + if got := (*query).Get("search"); got != "sensor bus" { + t.Errorf("search = %q, want %q", got, "sensor bus") + } +} + +// The term is passed through untouched: the server matches it literally, so +// wildcard characters must not be escaped or stripped on the way out. +func TestDeviceLogsSearchPassesTermThroughLiterally(t *testing.T) { + srv, _, query := logsServer(t) + + if _, err := execCmd(t, "", + "device", "logs", "dev1", "--search", "100% of _reads_", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs --search: %v", err) + } + if got := (*query).Get("search"); got != "100% of _reads_" { + t.Errorf("search = %q, want it passed through unchanged", got) + } +} + +// A blank search is not a filter, so it must not be sent at all. +func TestDeviceLogsBlankSearchIsOmitted(t *testing.T) { + srv, _, query := logsServer(t) + + if _, err := execCmd(t, "", + "device", "logs", "dev1", "--search", " ", + "--org", "acme", "--product", "thermostat", + "--uri", srv.URL, "--token", "tok", + ); err != nil { + t.Fatalf("logs: %v", err) + } + if _, ok := (*query)["search"]; ok { + t.Errorf("a blank --search should not be sent, got %v", *query) + } +} + +// Filters must keep applying to the lines a tail picks up, not just the first +// page. +func TestDeviceLogsFollowKeepsSearchOnPolls(t *testing.T) { + initial := `{"data":[{"timestamp":"2026-08-16T09:14:00.000000Z","level":"error","message":"sensor bus failed"}]}` + srv, queries := followServer(t, initial, `{"data":[]}`) + + _ = runFollow(t, srv.URL, 200*time.Millisecond, "--search", "sensor bus") + + q := queries() + if len(q) < 2 { + t.Fatalf("expected a poll after the initial page, got %d requests", len(q)) + } + for i, query := range q { + if got := query.Get("search"); got != "sensor bus" { + t.Errorf("request %d dropped the search filter: search=%q", i, got) + } + } +} diff --git a/internal/api/device_log.go b/internal/api/device_log.go index c410bce..7bd6ef0 100644 --- a/internal/api/device_log.go +++ b/internal/api/device_log.go @@ -52,6 +52,10 @@ type DeviceLogsFilter struct { // comma-separated list the API expects, and are matched as given: a level // the device never logs at simply matches nothing. Levels []string + // Search matches lines whose message contains this text, ignoring case. It + // is matched literally, so % and _ are searched for rather than treated as + // wildcards. + Search string // Since is inclusive, Before exclusive, both ISO 8601. Before being // exclusive means the oldest line of a page can be passed straight back to // fetch the next one without repeating it. @@ -80,6 +84,9 @@ func (c *Client) ListDeviceLogs(ctx context.Context, org, product, identifier st if levels := trimmedNonEmpty(filter.Levels); len(levels) > 0 { query.Set("level", strings.Join(levels, ",")) } + if search := strings.TrimSpace(filter.Search); search != "" { + query.Set("search", search) + } if filter.Since != "" { query.Set("since", filter.Since) }