Skip to content

Add nh device logs for reading device log lines - #7

Merged
joshk merged 3 commits into
mainfrom
feat/device-logs
Aug 23, 2026
Merged

joshk merged 3 commits into
mainfrom
feat/device-logs

Conversation

@joshk

@joshk joshk commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

What

CLI support for the device log APIs added in nerves-hub/nerves_hub_web#2939:

GET /orgs/:org/products/:product/devices/:identifier/logs
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 --meta -o json

Changes

  • internal/api — LogLine plus ListDeviceLogs with a DeviceLogsFilter (level, since, before, limit, order). Unset fields are omitted entirely so the server applies its own defaults (100 lines, newest first). Repeated --level is joined into the comma-separated list the API expects.
  • cmd — nh device logs <identifier>: a TIMESTAMP / LEVEL / MESSAGE table by default, full lines under -o json.

Choices worth a look

Offsets as well as timestamps. --since/--before take ISO 8601 or an offset from now (30m, 2h, 3d), resolved client-side to absolute timestamps. A log window is usually described relatively, and lines are only kept three days, so --since 2h is the common case. 3d is handled explicitly since time.ParseDuration has no day unit.

Metadata behind --meta. It's often long and the message is what you're scanning for, so it's hidden by default and always present in -o json. When shown it's sorted key=value pairs, so repeated runs read the same.

Local validation. --order and --limit (1–1000) are checked against the server's own rules before any request, so a typo fails immediately instead of costing a round trip and a 422. Levels are deliberately not validated — the server matches them as given, since a device may log at any level.

meta typed as map[string]any. The server documents string values, but a permissive type can't fail the whole response if that ever varies.

joshk and others added 2 commits August 22, 2026 19:11
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 <identifier>`, 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@joshk

joshk commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Added --follow (-f), implemented as polling since the API has no streaming endpoint.

nh device logs thermostat-4021 --follow
nh device logs thermostat-4021 -f --level error --interval 5s

How it works: the first page is fetched newest-first (so it's the most recent lines), then reversed — a tail reads oldest to newest. Polls then ask oldest-first from the last timestamp seen.

The subtle part — since is inclusive, so the boundary line comes back on every poll. Nudging the timestamp forward by a microsecond would be simpler, but would silently drop a line sharing that microsecond. Instead 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. There's a test asserting a different line at the same microsecond still prints.

Other behaviour:

  • No history → follow starts at now, rather than replaying the oldest page.
  • A full page → poll again immediately instead of pacing behind the interval, so bursts catch up.
  • Ctrl-C ends the tail cleanly; signal handling is scoped to the loop, leaving the rest of the CLI unchanged.
  • JSON becomes one object per line while following (a stream has no closing bracket). One-shot -o json is unchanged — still an indented array.
  • --order and --before contradict --follow and are refused, as is --interval without --follow.

Test-infrastructure fix included. resetState now clears each command's context between tests. PersistentPreRunE stores the resolved config on the context, and cobra only inherits the root's context when a command's own is nil (command.go#L1144) — so a command kept the previous test's context and ignored the one passed to ExecuteContext. That hung the follow tests whenever they ran after any other test in the package. Worth knowing beyond this PR: it also meant a cancelled context could leak into a later test.

Full suite green, including under -race.

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) <noreply@anthropic.com>
@joshk

joshk commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Added --search, matching the param just added to the server PR.

nh device logs thermostat-4021 --search "sensor bus"
nh device logs thermostat-4021 -f --search timeout --level error

Notes on the implementation:

  • The term is passed through untouched. The server matches literally, so % and _ are searched for rather than treated as wildcards — escaping or stripping them here would stop a device that logs 100% being found by searching for 100%. There's a test pinning that.
  • A blank term is omitted, not sent as an empty value — the server treats blank as "no filter" anyway, so there's no reason to put it on the wire.
  • It applies to every poll a --follow tail makes, not just the first page, with a test asserting the filter survives across requests.

Full suite green.

@joshk
joshk merged commit dd828cf into main Aug 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant