Skip to content

feat: add 'mobilecli device logs' support for streaming logs from mobile devices#195

Open
gmegidish wants to merge 8 commits intomainfrom
feat/device-logs
Open

feat: add 'mobilecli device logs' support for streaming logs from mobile devices#195
gmegidish wants to merge 8 commits intomainfrom
feat/device-logs

Conversation

@gmegidish
Copy link
Copy Markdown
Member

No description provided.

Adds real-time log streaming from iOS simulators (via xcrun simctl log stream)
and real iOS devices (via go-ios syslog_relay). Android and remote devices
return "not yet supported". Supports --limit flag to stop after N entries.
Filters are applied before --limit, so the user sees exactly N matching
entries. --process does substring matching on the process name.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

Walkthrough

Adds end-to-end device log streaming. Introduces a device logs CLI subcommand with flags --limit, repeatable --filter, and reuses --device for device selection. Adds LogEntry and extends ControllableDevice with StreamLogs(onLog func(LogEntry) bool) error. Implements StreamLogs for iOS (syslog), Simulator (xcrun simctl JSON stream), and Android (adb logcat with PID→process mapping); RemoteDevice.StreamLogs returns unsupported. Adds ParseLogFilters and LogsCommand to apply allowlisted filters and stream JSON Lines output, honoring an optional limit.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No pull request description was provided by the author; the description is empty. Add a brief description explaining the purpose, implementation approach, and any relevant context for the log streaming feature.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature being added—log streaming support for the 'device logs' command across mobile devices.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/device-logs

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
commands/logs.go (1)

31-33: PID filter condition may have unintended behavior with PID 0.

The condition req.PID >= 0 means PID 0 is treated as a valid filter value. If a user passes --pid 0, it would filter for processes with PID 0 (typically the kernel scheduler). This might be intentional, but consider whether -1 should be the only "unset" sentinel, or if you want to use a pointer type (*int) to distinguish "not set" from "filter for PID 0".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@commands/logs.go` around lines 31 - 33, The PID-check treats 0 as a valid
filter because it uses req.PID >= 0; change the filter to distinguish "unset"
from a real PID by making req.PID a pointer (*int) or by using -1 as the
explicit unset sentinel; then update the check in the logs filtering code to
something like "if req.PID != nil && entry.PID != *req.PID { return true }" (or
if using sentinel: "if req.PID != -1 && entry.PID != req.PID { return true }")
and adjust flag/constructors that populate req.PID accordingly so callers can
actually signal "no PID filter".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@commands/logs.go`:
- Around line 35-37: The current handler silently swallows JSON encoding
failures by returning false on encoder.Encode(entry) and later returning
NewSuccessResponse("done"); modify the error path so encode failures are
propagated instead of masked: when encoder.Encode(entry) returns an error,
return a failure value (e.g., propagate the error to the caller or return a
NewErrorResponse with err.Error()) rather than returning false, and ensure the
function no longer returns NewSuccessResponse("done") when an encode error
occurred. Update the code around encoder.Encode(entry) to surface the actual err
and stop further processing, and adjust any callers of this function to handle
the propagated error if needed.

In `@devices/ios.go`:
- Around line 1667-1717: StreamLogs currently calls d.getEnhancedDevice()
directly which can fail on iOS 17+ if the tunnel isn't started; mirror other
methods (Reboot, LaunchApp, TerminateApp) by calling d.startTunnel() first and
returning any error. Modify StreamLogs to invoke d.startTunnel(), check and
return its error, then proceed to call d.getEnhancedDevice() and the rest of the
existing logic; ensure you reference the IOSDevice methods startTunnel and
getEnhancedDevice and preserve existing defer conn.Close() behavior.

---

Nitpick comments:
In `@commands/logs.go`:
- Around line 31-33: The PID-check treats 0 as a valid filter because it uses
req.PID >= 0; change the filter to distinguish "unset" from a real PID by making
req.PID a pointer (*int) or by using -1 as the explicit unset sentinel; then
update the check in the logs filtering code to something like "if req.PID != nil
&& entry.PID != *req.PID { return true }" (or if using sentinel: "if req.PID !=
-1 && entry.PID != req.PID { return true }") and adjust flag/constructors that
populate req.PID accordingly so callers can actually signal "no PID filter".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c7acb32-e5d2-4071-b9d8-7d12c6621eaf

📥 Commits

Reviewing files that changed from the base of the PR and between d87b358 and c5fdce5.

📒 Files selected for processing (7)
  • cli/logs.go
  • commands/logs.go
  • devices/android.go
  • devices/common.go
  • devices/ios.go
  • devices/remote.go
  • devices/simulator.go

Comment thread commands/logs.go
Comment thread devices/ios.go
Comment on lines +1667 to +1717
func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error {
device, err := d.getEnhancedDevice()
if err != nil {
return fmt.Errorf("failed to get device: %w", err)
}

conn, err := syslog.New(device)
if err != nil {
return fmt.Errorf("failed to connect to syslog: %w", err)
}
defer conn.Close()

parse := syslog.Parser()

for {
msg, err := conn.ReadLogMessage()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("syslog read error: %w", err)
}

msg = strings.TrimSuffix(msg, "\x00")
msg = strings.TrimSuffix(msg, "\x0A")
if msg == "" {
continue
}

entry, err := parse(msg)
if err != nil {
// unparseable line — emit raw message
if !onLog(LogEntry{
Message: msg,
}) {
return nil
}
continue
}

if !onLog(LogEntry{
Timestamp: entry.Timestamp,
Message: entry.Message,
Level: entry.Level,
Process: entry.Process,
PID: atoiOrZero(entry.PID),
}) {
return nil
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider calling startTunnel() before getEnhancedDevice() for iOS 17+ devices.

Other methods that call getEnhancedDevice() (e.g., Reboot, LaunchApp, TerminateApp) first call d.startTunnel() to ensure the tunnel is running for iOS 17+ devices. This method skips that step, which could cause failures on iOS 17+ if no tunnel is established.

Proposed fix
 func (d *IOSDevice) StreamLogs(onLog func(LogEntry) bool) error {
+	err := d.startTunnel()
+	if err != nil {
+		return fmt.Errorf("failed to start tunnel: %w", err)
+	}
+
 	device, err := d.getEnhancedDevice()
 	if err != nil {
 		return fmt.Errorf("failed to get device: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@devices/ios.go` around lines 1667 - 1717, StreamLogs currently calls
d.getEnhancedDevice() directly which can fail on iOS 17+ if the tunnel isn't
started; mirror other methods (Reboot, LaunchApp, TerminateApp) by calling
d.startTunnel() first and returning any error. Modify StreamLogs to invoke
d.startTunnel(), check and return its error, then proceed to call
d.getEnhancedDevice() and the rest of the existing logic; ensure you reference
the IOSDevice methods startTunnel and getEnhancedDevice and preserve existing
defer conn.Close() behavior.

Streams logs via adb logcat with threadtime,year format. Parses using
existing logcatLineRegex. Resolves PID to package name via one-time
adb shell ps call for --process filtering. Adds tag field to LogEntry
(populated for Android, omitted for iOS).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
commands/logs.go (1)

34-36: ⚠️ Potential issue | 🟠 Major

Propagate JSON encode failures instead of returning success.

At Line 34, encode errors stop the callback, but the error is discarded; then Line 47 can still return success. This masks partial stream failures.

Suggested fix
 	encoder := json.NewEncoder(os.Stdout)
 	count := 0
+	var encodeErr error
 	err = device.StreamLogs(func(entry devices.LogEntry) bool {
 		if req.Process != "" && entry.Process != req.Process {
 			return true
 		}
 		if req.PID >= 0 && entry.PID != req.PID {
 			return true
 		}

 		if err := encoder.Encode(entry); err != nil {
+			encodeErr = err
 			return false
 		}
 		count++
 		if req.Limit > 0 && count >= req.Limit {
 			return false
 		}
 		return true
 	})
 	if err != nil {
 		return NewErrorResponse(fmt.Errorf("error streaming logs: %w", err))
 	}
+	if encodeErr != nil {
+		return NewErrorResponse(fmt.Errorf("error encoding log entry: %w", encodeErr))
+	}

 	return NewSuccessResponse("done")

Also applies to: 43-47

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@commands/logs.go` around lines 34 - 36, The JSON encode failures are being
swallowed where encoder.Encode(entry) is called; change the callback to
propagate the error instead of returning a bool success sentinel: when
encoder.Encode(entry) (and the similar block at 43-47) returns an error, return
that error (or set a named err variable and break) so the caller can detect and
surface the failure, and update the outer caller/return path to return the error
rather than always returning success; reference the encoder.Encode(entry) calls
and the surrounding callback/handler to locate and update the code paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@commands/logs.go`:
- Around line 34-36: The JSON encode failures are being swallowed where
encoder.Encode(entry) is called; change the callback to propagate the error
instead of returning a bool success sentinel: when encoder.Encode(entry) (and
the similar block at 43-47) returns an error, return that error (or set a named
err variable and break) so the caller can detect and surface the failure, and
update the outer caller/return path to return the error rather than always
returning success; reference the encoder.Encode(entry) calls and the surrounding
callback/handler to locate and update the code paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bcf44939-e9f5-4879-b35b-2ccb292cf83b

📥 Commits

Reviewing files that changed from the base of the PR and between c5fdce5 and 96c026c.

📒 Files selected for processing (3)
  • commands/logs.go
  • devices/android.go
  • devices/common.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • devices/common.go
  • devices/android.go

- Fix --process flag description to say "exact match"
- Split callback into matchesFilter and emit helpers
- Extract processNameFromPath and atoiOrZero to common.go
Supports key=value (include) and key!=value (exclude) syntax.
Multiple filters are ANDed. Valid keys: pid, process, tag, level,
subsystem, category, message. Invalid keys return a clear error.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@devices/simulator.go`:
- Around line 1072-1116: The code currently treats any json.Decoder errors as
normal end-of-stream and kills the child without reaping it; update the
log-stream handling so that failures from decoder.Token() or decoder.Decode()
that are not io.EOF are returned as errors and the child is reaped: after
calling cmd.Process.Kill() invoke cmd.Wait() and include its error in the
returned fmt.Errorf; when decoder.Decode() returns io.EOF treat it as normal
end-of-stream, but for other errors return them (again ensuring cmd.Wait() is
called and its error combined); finally, don't ignore the result of cmd.Wait()
at the end—return any non-nil wait error. Add the io import and reference
functions/values like cmd.Start, decoder.Token, decoder.Decode,
cmd.Process.Kill, cmd.Wait, simctlLogEntry, and onLog when making these fixes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d003d51-ab06-4dfe-8267-12fe09f41d2e

📥 Commits

Reviewing files that changed from the base of the PR and between 96c026c and 561a210.

📒 Files selected for processing (6)
  • README.md
  • cli/logs.go
  • commands/logs.go
  • devices/common.go
  • devices/ios.go
  • devices/simulator.go
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • cli/logs.go

Comment thread devices/simulator.go
Comment on lines +1072 to +1116
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start log stream: %w", err)
}

decoder := json.NewDecoder(stdout)

// read opening '[' of the JSON array
token, err := decoder.Token()
if err != nil {
_ = cmd.Process.Kill()
return fmt.Errorf("failed to read opening token: %w", err)
}
if delim, ok := token.(json.Delim); !ok || delim != '[' {
_ = cmd.Process.Kill()
return fmt.Errorf("expected '[', got %v", token)
}

// decode entries one at a time until the stream ends
for decoder.More() {
var raw simctlLogEntry
if err := decoder.Decode(&raw); err != nil {
// stream ended (process killed) — not an error
break
}

processName := processNameFromPath(raw.ProcessImagePath)

if !onLog(LogEntry{
Timestamp: raw.Timestamp,
Message: raw.EventMessage,
Level: raw.MessageType,
Subsystem: raw.Subsystem,
Category: raw.Category,
PID: raw.ProcessID,
Process: processName,
EventType: raw.EventType,
}) {
_ = cmd.Process.Kill()
break
}
}

// we killed it ourselves, or stream ended naturally
_ = cmd.Wait()
return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not turn xcrun failures into a clean end-of-stream.

After cmd.Start(), the token-read error paths kill the child but never reap it, and the loop treats every decoder.Decode failure as a normal stop. Combined with the ignored cmd.Wait() result, malformed JSON or a non-zero xcrun exit gets reported as success.

Proposed fix
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("failed to start log stream: %w", err)
	}
 
 	decoder := json.NewDecoder(stdout)
+	stoppedByCaller := false
 
 	// read opening '[' of the JSON array
 	token, err := decoder.Token()
 	if err != nil {
 		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
 		return fmt.Errorf("failed to read opening token: %w", err)
 	}
 	if delim, ok := token.(json.Delim); !ok || delim != '[' {
 		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
 		return fmt.Errorf("expected '[', got %v", token)
 	}
 
 	// decode entries one at a time until the stream ends
 	for decoder.More() {
 		var raw simctlLogEntry
 		if err := decoder.Decode(&raw); err != nil {
-			// stream ended (process killed) — not an error
-			break
+			if err == io.EOF {
+				break
+			}
+			_ = cmd.Process.Kill()
+			_ = cmd.Wait()
+			return fmt.Errorf("failed to decode log entry: %w", err)
 		}
 
 		processName := processNameFromPath(raw.ProcessImagePath)
 
 		if !onLog(LogEntry{
@@
 			EventType: raw.EventType,
 		}) {
+			stoppedByCaller = true
 			_ = cmd.Process.Kill()
 			break
 		}
 	}
 
-	// we killed it ourselves, or stream ended naturally
-	_ = cmd.Wait()
+	if err := cmd.Wait(); err != nil && !stoppedByCaller {
+		return fmt.Errorf("log stream exited unexpectedly: %w", err)
+	}
 	return nil

Also add the io import.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start log stream: %w", err)
}
decoder := json.NewDecoder(stdout)
// read opening '[' of the JSON array
token, err := decoder.Token()
if err != nil {
_ = cmd.Process.Kill()
return fmt.Errorf("failed to read opening token: %w", err)
}
if delim, ok := token.(json.Delim); !ok || delim != '[' {
_ = cmd.Process.Kill()
return fmt.Errorf("expected '[', got %v", token)
}
// decode entries one at a time until the stream ends
for decoder.More() {
var raw simctlLogEntry
if err := decoder.Decode(&raw); err != nil {
// stream ended (process killed) — not an error
break
}
processName := processNameFromPath(raw.ProcessImagePath)
if !onLog(LogEntry{
Timestamp: raw.Timestamp,
Message: raw.EventMessage,
Level: raw.MessageType,
Subsystem: raw.Subsystem,
Category: raw.Category,
PID: raw.ProcessID,
Process: processName,
EventType: raw.EventType,
}) {
_ = cmd.Process.Kill()
break
}
}
// we killed it ourselves, or stream ended naturally
_ = cmd.Wait()
return nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start log stream: %w", err)
}
decoder := json.NewDecoder(stdout)
stoppedByCaller := false
// read opening '[' of the JSON array
token, err := decoder.Token()
if err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("failed to read opening token: %w", err)
}
if delim, ok := token.(json.Delim); !ok || delim != '[' {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("expected '[', got %v", token)
}
// decode entries one at a time until the stream ends
for decoder.More() {
var raw simctlLogEntry
if err := decoder.Decode(&raw); err != nil {
if err == io.EOF {
break
}
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("failed to decode log entry: %w", err)
}
processName := processNameFromPath(raw.ProcessImagePath)
if !onLog(LogEntry{
Timestamp: raw.Timestamp,
Message: raw.EventMessage,
Level: raw.MessageType,
Subsystem: raw.Subsystem,
Category: raw.Category,
PID: raw.ProcessID,
Process: processName,
EventType: raw.EventType,
}) {
stoppedByCaller = true
_ = cmd.Process.Kill()
break
}
}
if err := cmd.Wait(); err != nil && !stoppedByCaller {
return fmt.Errorf("log stream exited unexpectedly: %w", err)
}
return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@devices/simulator.go` around lines 1072 - 1116, The code currently treats any
json.Decoder errors as normal end-of-stream and kills the child without reaping
it; update the log-stream handling so that failures from decoder.Token() or
decoder.Decode() that are not io.EOF are returned as errors and the child is
reaped: after calling cmd.Process.Kill() invoke cmd.Wait() and include its error
in the returned fmt.Errorf; when decoder.Decode() returns io.EOF treat it as
normal end-of-stream, but for other errors return them (again ensuring
cmd.Wait() is called and its error combined); finally, don't ignore the result
of cmd.Wait() at the end—return any non-nil wait error. Add the io import and
reference functions/values like cmd.Start, decoder.Token, decoder.Decode,
cmd.Process.Kill, cmd.Wait, simctlLogEntry, and onLog when making these fixes.

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