Skip to content
Open
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
5 changes: 3 additions & 2 deletions cli/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ var deviceLogsCmd = &cobra.Command{
Long: `Streams real-time logs from a device. Press Ctrl+C to stop.

Filters use key=value (include) or key!=value (exclude) syntax.
Multiple --filter flags are ANDed together.
Multiple --filter flags are ANDed together. Matching is case-insensitive;
'message' matches as a substring, every other key matches the whole value.

Supported keys: pid, process, tag, level, subsystem, category, message

Examples:
mobilecli device logs --filter tag=ActivityManager
mobilecli device logs --filter process!=SpringBoard
mobilecli device logs --filter level=Error --filter process=backboardd`,
mobilecli device logs --filter level=error --filter message=timeout`,
RunE: func(cmd *cobra.Command, args []string) error {
if _, err := commands.ParseLogFilters(logsFilters); err != nil {
return err
Expand Down
14 changes: 13 additions & 1 deletion commands/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,22 @@ func getFieldValue(entry devices.LogEntry, key string) string {
}
}

// fieldMatches compares a log field against a filter value. Matching is
// case-insensitive everywhere so "level=error" matches "Error" (iOS and Android
// capitalize levels differently), and "message" matches as a substring so a free
// text filter like "message=timeout" finds the entries that contain it rather
// than demanding the whole line.
func fieldMatches(key, fieldValue, filterValue string) bool {
if key == "message" {
return strings.Contains(strings.ToLower(fieldValue), strings.ToLower(filterValue))
}
return strings.EqualFold(fieldValue, filterValue)
}

func matchesFilters(entry devices.LogEntry, filters []LogFilter) bool {
for _, f := range filters {
fieldValue := getFieldValue(entry, f.Key)
match := fieldValue == f.Value
match := fieldMatches(f.Key, fieldValue, f.Value)
if f.Negate {
match = !match
}
Expand Down
29 changes: 29 additions & 0 deletions commands/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ func TestMatchesFiltersRequiresEveryFilterToPass(t *testing.T) {
{"one filter of many fails", []string{"level=Error", "tag=Zygote"}, false},
{"numeric pid is compared as text", []string{"pid=42"}, true},
{"empty field never matches a value", []string{"subsystem=com.apple.UIKit"}, false},
{"level is case-insensitive", []string{"level=error"}, true},
{"level upper-case still matches", []string{"level=ERROR"}, true},
{"case-insensitive exclude fails", []string{"level!=error"}, false},
{"process is case-insensitive", []string{"process=springboard"}, true},
{"wrong level value still fails", []string{"level=warning"}, false},
}

for _, test := range tests {
Expand Down Expand Up @@ -171,3 +176,27 @@ func TestStreamLogsSucceedsWhenEveryEntryIsWritten(t *testing.T) {
t.Errorf("expected 3 log lines, got %d", lines)
}
}

func TestMatchesFiltersMessageIsCaseInsensitiveSubstring(t *testing.T) {
entry := devices.LogEntry{Message: "Connection timed out after 10s"}

cases := []struct {
filter string
want bool
}{
{"message=timed out", true},
{"message=TIMED OUT", true},
{"message=timeout", false},
{"message=connection", true},
{"message!=timed out", false},
}
for _, c := range cases {
filters, err := ParseLogFilters([]string{c.filter})
if err != nil {
t.Fatalf("unexpected error for %q: %v", c.filter, err)
}
if got := matchesFilters(entry, filters); got != c.want {
t.Errorf("matchesFilters(%q) = %v, want %v", c.filter, got, c.want)
}
}
}
Loading