Module: Browserctl::Commands::Trace
- Defined in:
- lib/browserctl/commands/trace.rb
Overview
‘browserctl trace [<session>] [–no-redact]` — pretty timeline of structured log events across cli.log + daemon.log. Defaults to most recent session.
Loose categorisation by inspecting common keys (event/snapshot/request/ error). No schema is enforced — this command is tolerant of any JSONL produced by Browserctl::JsonlFormatter.
Redaction: ON by default. Secret values are sourced from current ENV patterns (‘*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values captured by `SecretResolverRegistry` during this process. Pass `–no-redact` to disable (local debugging only). Note: when replaying historical traces from a previous process, registry-captured values are gone — only current ENV patterns apply.
Constant Summary collapse
- USAGE =
"Usage: browserctl trace [<session>] [--no-redact]"- NO_REDACT_WARNING =
"[browserctl] traces include unredacted secret values; " \ "do not paste this output publicly."
- LEVEL_COLORS =
{ "DEBUG" => "\e[2;37m", # dim grey "INFO" => "\e[36m", # cyan "WARN" => "\e[33m", # yellow "ERROR" => "\e[31m" # red }.freeze
- RESET =
red
"\e[0m"- CATEGORY_ICONS =
{ error: "!", snapshot: "S", network: "N", event: "." }.freeze
- OMIT_KEYS =
%w[ts level component event msg].freeze
Class Method Summary collapse
- .build_redactor ⇒ Object
- .categorise(record) ⇒ Object
- .collect_records(log_dir, session_filter, out) ⇒ Object
- .colourise(line, level) ⇒ Object
-
.context_snippet(record) ⇒ Object
Compact “k=v k=v” snippet of remaining structured keys, capped to keep the timeline scannable.
- .emit_empty(message, out) ⇒ Object
- .emit_records(records, redactor, out) ⇒ Object
- .event_label(record) ⇒ Object
-
.filter_session(records, session_filter) ⇒ Object
Session resolution.
- .format_line(record, tty:, redactor: nil) ⇒ Object
- .format_ts(timestamp) ⇒ Object
- .format_value(value) ⇒ Object
- .load_records(log_dir) ⇒ Object
- .parse_line(line) ⇒ Object
- .redact_record(record, redactor) ⇒ Object
- .render(records, out:, redactor: nil) ⇒ Object
- .resolve_redactor(redact, err) ⇒ Object
- .run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr) ⇒ Object
- .warn_no_redact(err) ⇒ Object
Class Method Details
.build_redactor ⇒ Object
104 105 106 107 108 109 110 111 112 113 |
# File 'lib/browserctl/commands/trace.rb', line 104 def self.build_redactor extra = if defined?(Browserctl::SecretResolverRegistry) Browserctl::SecretResolverRegistry.resolved_values else [] end Browserctl::Redactor.from_env(extra: extra) rescue StandardError Browserctl::Redactor.new(secrets: []) end |
.categorise(record) ⇒ Object
179 180 181 182 183 184 185 |
# File 'lib/browserctl/commands/trace.rb', line 179 def self.categorise(record) return :error if record["level"] == "ERROR" || record["error"] return :snapshot if record["snapshot"] return :network if record["request"] || record["response"] || record["url"] :event end |
.collect_records(log_dir, session_filter, out) ⇒ Object
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/browserctl/commands/trace.rb', line 66 def self.collect_records(log_dir, session_filter, out) records = load_records(log_dir) if records.empty? emit_empty("No log entries found in #{log_dir}", out) return nil end records = filter_session(records, session_filter) if records.empty? emit_empty("No entries match session=#{session_filter}", out) return nil end records end |
.colourise(line, level) ⇒ Object
210 211 212 213 |
# File 'lib/browserctl/commands/trace.rb', line 210 def self.colourise(line, level) colour = LEVEL_COLORS[level] || "" "#{colour}#{line}#{RESET}" end |
.context_snippet(record) ⇒ Object
Compact “k=v k=v” snippet of remaining structured keys, capped to keep the timeline scannable. Skips fields already shown in fixed columns.
194 195 196 197 198 199 |
# File 'lib/browserctl/commands/trace.rb', line 194 def self.context_snippet(record) pairs = record.except(*OMIT_KEYS) return "" if pairs.empty? pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120) end |
.emit_empty(message, out) ⇒ Object
82 83 84 |
# File 'lib/browserctl/commands/trace.rb', line 82 def self.emit_empty(, out) OutputFormat.current.emit({ records: [], message: }, , io: out) end |
.emit_records(records, redactor, out) ⇒ Object
86 87 88 89 90 91 92 93 |
# File 'lib/browserctl/commands/trace.rb', line 86 def self.emit_records(records, redactor, out) fmt = OutputFormat.current if fmt.json? fmt.emit({ records: records.map { |r| redact_record(r, redactor) } }, io: out) elsif !fmt.silent? render(records, out: out, redactor: redactor) end end |
.event_label(record) ⇒ Object
187 188 189 190 |
# File 'lib/browserctl/commands/trace.rb', line 187 def self.event_label(record) (record["event"] || record["snapshot"] || record["request"] || record["msg"] || "-").to_s.slice(0, 22) end |
.filter_session(records, session_filter) ⇒ Object
Session resolution. When session_id is stamped on records (future PR), filter/select by it. Otherwise, treat the entire merged stream as one session — caller can scope by tailing/rotating logs. TODO: stamp session_id on every log line so this scopes correctly.
140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/browserctl/commands/trace.rb', line 140 def self.filter_session(records, session_filter) if session_filter records.select { |r| r["session_id"].to_s == session_filter } else ids = records.map { |r| r["session_id"] }.compact.uniq if ids.empty? records else recent = ids.last records.select { |r| r["session_id"] == recent } end end end |
.format_line(record, tty:, redactor: nil) ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/browserctl/commands/trace.rb', line 159 def self.format_line(record, tty:, redactor: nil) level = (record["level"] || "INFO").to_s line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s", ts: format_ts(record["ts"]), icon: CATEGORY_ICONS.fetch(categorise(record), "."), level: level, comp: (record["component"] || "?").to_s, label: event_label(record), ctx: context_snippet(record)).rstrip line = redactor.redact(line) if redactor tty ? colourise(line, level) : line end |
.format_ts(timestamp) ⇒ Object
173 174 175 176 177 |
# File 'lib/browserctl/commands/trace.rb', line 173 def self.format_ts() Time.iso8601(.to_s).strftime("%H:%M:%S.%L") rescue ArgumentError, TypeError "??:??:??.???" end |
.format_value(value) ⇒ Object
201 202 203 204 205 206 207 208 |
# File 'lib/browserctl/commands/trace.rb', line 201 def self.format_value(value) case value when String then value.length > 40 ? "#{value[0, 37]}..." : value when Array then "[#{value.length}]" when Hash then "{#{value.keys.length}}" else value.to_s end end |
.load_records(log_dir) ⇒ Object
119 120 121 122 123 124 125 |
# File 'lib/browserctl/commands/trace.rb', line 119 def self.load_records(log_dir) paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log")) records = paths.flat_map do |path| File.foreach(path).filter_map { |line| parse_line(line) } end records.sort_by { |r| r["ts"].to_s } end |
.parse_line(line) ⇒ Object
127 128 129 130 131 132 133 134 |
# File 'lib/browserctl/commands/trace.rb', line 127 def self.parse_line(line) line = line.strip return nil if line.empty? JSON.parse(line) rescue JSON::ParserError nil end |
.redact_record(record, redactor) ⇒ Object
95 96 97 98 99 100 101 102 |
# File 'lib/browserctl/commands/trace.rb', line 95 def self.redact_record(record, redactor) return record unless redactor line = JSON.generate(record) JSON.parse(redactor.redact(line)) rescue JSON::ParserError record end |
.render(records, out:, redactor: nil) ⇒ Object
154 155 156 157 |
# File 'lib/browserctl/commands/trace.rb', line 154 def self.render(records, out:, redactor: nil) tty = out.respond_to?(:tty?) && out.tty? records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) } end |
.resolve_redactor(redact, err) ⇒ Object
58 59 60 61 62 63 64 |
# File 'lib/browserctl/commands/trace.rb', line 58 def self.resolve_redactor(redact, err) return nil unless redact build_redactor ensure warn_no_redact(err) unless redact end |
.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr) ⇒ Object
48 49 50 51 52 53 54 55 56 |
# File 'lib/browserctl/commands/trace.rb', line 48 def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr) abort USAGE if args.include?("-h") || args.include?("--help") args = args.dup redact = !args.delete("--no-redact") session_filter = args.shift redactor = resolve_redactor(redact, err) records = collect_records(log_dir, session_filter, out) emit_records(records, redactor, out) if records end |
.warn_no_redact(err) ⇒ Object
115 116 117 |
# File 'lib/browserctl/commands/trace.rb', line 115 def self.warn_no_redact(err) err&.puts NO_REDACT_WARNING end |