Class: Everywhere::Commands::Logs

Inherits:
Dry::CLI::Command
  • Object
show all
Defined in:
lib/everywhere/commands/logs.rb

Overview

Tail the native shell's logs. Two platforms, one shape: the iOS Simulator's unified log scoped to the shell process, or Android's logcat scoped to the shell's process id. Watches by default; --last replays recent history and exits.

Constant Summary collapse

PREDICATE =

The template contract fixes the product name (App.app), so the shell's process on the Simulator is always "App" — one predicate covers every Everywhere app without knowing which one is installed. Scoping to the process alone still yields ~100k lines/half hour of Apple-framework chatter (WebKit resource loads, activities); the default predicate keeps what a shell developer logs — NSLog (Foundation sender, no subsystem), Hotwire Native's subsystem, UIKit warnings — and drops the rest. --verbose falls back to the whole process.

'process == "App" AND eventType == logEvent AND NOT subsystem BEGINSWITH "com.apple"'
VERBOSE_PREDICATE =
'process == "App"'
DEFAULT_LEVEL =

Android has no predicate language, so the same job is split in two: --pid= fixes the process (see #android_logs) and a filterspec fixes the priority floor.

Inside our own process the analogue of iOS's Apple-framework chatter is the platform's own: ART's GC bookkeeping, binder transactions, chromium's internal tracing. Those sit at VERBOSE, so the default floor is DEBUG — which keeps Hotwire Native's logging (Hotwire-Core and Hotwire-Navigation both log at debug), keeps every WebView console message (chromium reports them at INFO), and drops the runtime's housekeeping. --verbose lowers the floor to VERBOSE.

Deliberately NOT a tag list: filtering on Hotwire-Core/Hotwire-Navigation would be the obvious way to cut noise and would hide the two things a shell developer most wants — their own Log calls and their page's console output. Scoping by process keeps everything that is ours and nothing that isn't, which is the same trade the iOS predicate makes.

"*:D"
VERBOSE_LEVEL =
"*:V"
DEFAULT_FORMAT =

-v time is the compact-style analogue: with the process already fixed, repeating its pid on every line is noise. --verbose widens to threadtime, which adds the thread id you need once you're reading runtime internals.

"time"
VERBOSE_FORMAT =
"threadtime"
LOGCAT_TIME =

logcat's -t takes a line count or a timestamp; it has no notion of "5m". So a bare number passes straight through as a count, and a duration in the iOS --last spelling is converted to the timestamp logcat wants.

That timestamp is compared against the device's clock. An emulator inherits the host's clock and timezone so it lines up exactly; a physical device whose clock has drifted will return a little more or less than asked. A line count has no such ambiguity, which is why both spellings stay.

"%m-%d %H:%M:%S.000"
DURATION_UNITS =
{ "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze

Instance Method Summary collapse

Instance Method Details

#android_argv(serial, pid, last: nil, verbose: false) ⇒ Object

-d dumps what's buffered and exits — the log show half of the iOS split — and -t bounds that dump. Without -d, logcat follows.



130
131
132
133
134
135
# File 'lib/everywhere/commands/logs.rb', line 130

def android_argv(serial, pid, last: nil, verbose: false)
  argv = [Everywhere::AndroidSdk.adb!, "-s", serial, "logcat", "--pid=#{pid}"]
  argv += ["-d", "-t", logcat_window(last)] if last
  argv + ["-v", verbose ? VERBOSE_FORMAT : DEFAULT_FORMAT,
          verbose ? VERBOSE_LEVEL : DEFAULT_LEVEL]
end

#android_logs(serial: nil, app_id: nil, last: nil, verbose: false) ⇒ Object

logcat carries every process on the device, so the pid lookup is the whole filter — and it's also why "the app isn't running" is a distinct, extremely common state that has to be said plainly rather than shown as an empty stream. Unlike iOS, the process name isn't fixed by the template (it's the applicationId), so the config supplies it.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/everywhere/commands/logs.rb', line 111

def android_logs(serial: nil, app_id: nil, last: nil, verbose: false)
  serial ||= Everywhere::Emulator.booted_serial
  UI.die!("no Android emulator or device — start one with `every dev --android`") unless serial

  application_id = app_id || Everywhere::Config.load(Dir.pwd).bundle_id(target: "android-arm64")
  pid = Everywhere::Emulator.pid_of(serial, application_id)
  unless pid
    UI.die!("#{application_id} isn't running on #{serial} — start it with `every dev --android` " \
            "(or pass --app-id if that isn't this app's applicationId)")
  end

  UI.step(last ? "Android logs from the last #{UI.bold(window_label(last))}"
               : "watching Android logs for #{UI.bold(application_id)} #{UI.dim("(Ctrl-C to stop)")}")
  $stdout.flush # see ios_logs — exec discards a buffered header
  exec(*android_argv(serial, pid, last: last, verbose: verbose))
end

#argv(udid, last: nil, verbose: false) ⇒ Object

simctl spawn runs the log tool inside the Simulator runtime — the supported way to read a Simulator's unified log from the host. log show spells verbosity --info/--debug where log stream takes --level.



94
95
96
97
98
99
100
101
102
# File 'lib/everywhere/commands/logs.rb', line 94

def argv(udid, last: nil, verbose: false)
  argv = ["xcrun", "simctl", "spawn", udid, "log"]
  argv += if last
            ["show", "--last", last, *(verbose ? ["--info", "--debug"] : [])]
          else
            ["stream", *(verbose ? ["--level", "debug"] : [])]
          end
  argv + ["--style", "compact", "--predicate", verbose ? VERBOSE_PREDICATE : PREDICATE]
end

#call(ios: false, android: false, last: nil, udid: nil, serial: nil, app_id: nil, verbose: false) ⇒ Object



64
65
66
67
68
69
70
71
72
73
# File 'lib/everywhere/commands/logs.rb', line 64

def call(ios: false, android: false, last: nil, udid: nil, serial: nil, app_id: nil, verbose: false, **)
  UI.die!("pick a platform — run: every logs --ios, or every logs --android") unless ios || android
  UI.die!("--ios and --android are two separate log streams — pick one") if ios && android

  if android
    android_logs(serial: serial, app_id: app_id, last: last, verbose: verbose)
  else
    ios_logs(udid: udid, last: last, verbose: verbose)
  end
end

#ios_logs(udid: nil, last: nil, verbose: false) ⇒ Object

--- iOS ------------------------------------------------------------------



77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/everywhere/commands/logs.rb', line 77

def ios_logs(udid: nil, last: nil, verbose: false)
  udid ||= Everywhere::Simulator.booted_udid
  UI.die!("no booted iOS Simulator — start one with `every dev --ios`") unless udid

  UI.step(last ? "iOS Simulator logs from the last #{UI.bold(last)}"
               : "watching iOS Simulator logs #{UI.dim("(Ctrl-C to stop)")}")
  # exec replaces this process, so anything still sitting in Ruby's stdout
  # buffer dies with it — and stdout IS buffered whenever it's a pipe,
  # which is exactly how `every dev`'s log toggle runs us. Without this
  # the header above silently vanishes in the one context that spawns us.
  $stdout.flush
  exec(*argv(udid, last: last, verbose: verbose))
end

#logcat_window(last) ⇒ Object



149
150
151
152
153
154
155
156
157
158
159
# File 'lib/everywhere/commands/logs.rb', line 149

def logcat_window(last)
  text = last.to_s.strip
  return text if text.match?(/\A\d+\z/)

  if (match = text.match(/\A(\d+)([smhd])\z/i))
    seconds = match[1].to_i * DURATION_UNITS.fetch(match[2].downcase)
    return (Time.now - seconds).strftime(LOGCAT_TIME)
  end

  UI.die!(%(--last takes a line count ("200") or a duration ("30s", "5m", "2h") — got #{last.inspect}))
end

#window_label(last) ⇒ Object

"200" reads as a count, everything else as a duration.



162
# File 'lib/everywhere/commands/logs.rb', line 162

def window_label(last) = last.to_s.match?(/\A\d+\z/) ? "#{last} lines" : last.to_s