browserctl 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flow"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
# Discovers and loads flow files from project, user, and bundled stdlib
|
|
7
|
+
# locations. Project flows shadow user flows, which shadow stdlib flows.
|
|
8
|
+
#
|
|
9
|
+
# Files are plain Ruby; loading them is expected to invoke
|
|
10
|
+
# `Browserctl.flow(name) { ... }`, which registers the flow in the global
|
|
11
|
+
# registry. Filename should match the registered name (validated lazily —
|
|
12
|
+
# mismatches are allowed but discouraged).
|
|
13
|
+
class FlowRegistry
|
|
14
|
+
SAFE_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
15
|
+
|
|
16
|
+
# Search order: highest-precedence last so later registrations
|
|
17
|
+
# overwrite earlier ones in the global registry.
|
|
18
|
+
def self.bundled_dir = File.expand_path("flows/stdlib", __dir__)
|
|
19
|
+
def self.user_dir = File.expand_path("~/.browserctl/flows")
|
|
20
|
+
def self.project_dir = "./.browserctl/flows"
|
|
21
|
+
|
|
22
|
+
def self.search_paths
|
|
23
|
+
[bundled_dir, user_dir, project_dir]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Loads every flow file from every search path. Lower-precedence dirs
|
|
27
|
+
# run first; project files load last and win on name collisions.
|
|
28
|
+
def self.load_all
|
|
29
|
+
search_paths.each do |dir|
|
|
30
|
+
next unless Dir.exist?(dir)
|
|
31
|
+
|
|
32
|
+
Dir.glob(File.join(dir, "*.rb")).each { |f| load f }
|
|
33
|
+
end
|
|
34
|
+
Browserctl.flow_registry_snapshot
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves a name to a registered flow, loading from disk on demand.
|
|
38
|
+
# Searches in precedence order: project → user → stdlib. The first
|
|
39
|
+
# matching file is loaded and the flow returned via the global registry.
|
|
40
|
+
def self.resolve(name)
|
|
41
|
+
validate_name!(name)
|
|
42
|
+
existing = Browserctl.lookup_flow(name)
|
|
43
|
+
return existing if existing
|
|
44
|
+
|
|
45
|
+
search_paths.reverse_each do |dir|
|
|
46
|
+
candidate = File.join(dir, "#{name}.rb")
|
|
47
|
+
next unless File.exist?(candidate)
|
|
48
|
+
|
|
49
|
+
load candidate
|
|
50
|
+
flow = Browserctl.lookup_flow(name)
|
|
51
|
+
return flow if flow
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.list
|
|
57
|
+
load_all.map { |n, f| { name: n, desc: f.description, version: f.version_string } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.validate_name!(name)
|
|
61
|
+
return if SAFE_NAME.match?(name.to_s)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "invalid flow name: #{name.inspect} — use letters, digits, _ and - only"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../../flow"
|
|
5
|
+
|
|
6
|
+
# Authenticates with an HTTP Basic Auth-protected URL by navigating to it
|
|
7
|
+
# with credentials embedded in the URL. This avoids the native auth
|
|
8
|
+
# dialog entirely; Chromium ingests the userinfo and supplies it on the
|
|
9
|
+
# request without prompting.
|
|
10
|
+
#
|
|
11
|
+
# Use for sites where the auth challenge is real HTTP Basic. For form-
|
|
12
|
+
# based "username + password" logins, use a workflow with `page.fill`.
|
|
13
|
+
Browserctl.flow("basic_auth") do
|
|
14
|
+
version "1.0.0"
|
|
15
|
+
requires_browserctl "0.11.0"
|
|
16
|
+
desc "Navigate to an HTTP Basic Auth URL using credentials embedded in the URL."
|
|
17
|
+
|
|
18
|
+
param :url, required: true
|
|
19
|
+
param :username, required: true
|
|
20
|
+
param :password, required: true, secret: true
|
|
21
|
+
|
|
22
|
+
precondition("page proxy is present") { !page.nil? }
|
|
23
|
+
|
|
24
|
+
step("navigate with embedded credentials") do
|
|
25
|
+
parsed = URI.parse(url)
|
|
26
|
+
parsed.user = URI.encode_www_form_component(username)
|
|
27
|
+
parsed.password = URI.encode_www_form_component(password)
|
|
28
|
+
page.navigate(parsed.to_s)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../detectors"
|
|
4
|
+
require_relative "../../flow"
|
|
5
|
+
|
|
6
|
+
# Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
|
|
7
|
+
# moment...", interactive checkbox), then verifies the challenge cleared
|
|
8
|
+
# before returning. Optionally saves the post-solve session under a name
|
|
9
|
+
# you can reload later with `state load` or `session_load`.
|
|
10
|
+
#
|
|
11
|
+
# Reuses Browserctl::Detectors.cloudflare? — the server-side detector
|
|
12
|
+
# already shipped in v0.8 — by adapting the client-facing PageProxy to
|
|
13
|
+
# the duck-typed (current_url, body) interface the detector expects.
|
|
14
|
+
module Browserctl
|
|
15
|
+
module Flows
|
|
16
|
+
PageDetectorAdapter = Struct.new(:current_url, :body)
|
|
17
|
+
|
|
18
|
+
module CloudflareSolve
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def detect?(page_proxy)
|
|
22
|
+
body = page_proxy.evaluate("document.body && document.body.innerText || ''").to_s
|
|
23
|
+
adapter = PageDetectorAdapter.new(page_proxy.url, body)
|
|
24
|
+
Browserctl::Detectors.cloudflare?(adapter)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Browserctl.flow("cloudflare_solve") do
|
|
31
|
+
version "1.0.0"
|
|
32
|
+
requires_browserctl "0.11.0"
|
|
33
|
+
desc "Pause for a human to solve a Cloudflare challenge; verify it cleared; optionally capture state."
|
|
34
|
+
|
|
35
|
+
param :prompt,
|
|
36
|
+
default: "Cloudflare challenge detected. Solve it in the browser, then press Enter to continue."
|
|
37
|
+
param :state_name # optional — if set, session_save is called after the challenge clears
|
|
38
|
+
|
|
39
|
+
precondition("page proxy is present") { !page.nil? }
|
|
40
|
+
precondition("cloudflare challenge is present") do
|
|
41
|
+
Browserctl::Flows::CloudflareSolve.detect?(page)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
step("wait for human signal") do
|
|
45
|
+
warn "[browserctl] #{prompt}"
|
|
46
|
+
line = $stdin.gets
|
|
47
|
+
raise "stdin closed before user signaled" if line.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
step("verify challenge cleared") do
|
|
51
|
+
raise "cloudflare challenge still detected after human signal" if Browserctl::Flows::CloudflareSolve.detect?(page)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
produces_state do
|
|
55
|
+
next nil unless state_name && client
|
|
56
|
+
|
|
57
|
+
client.session_save(state_name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../flow"
|
|
4
|
+
|
|
5
|
+
# HITL flow for magic-link login: pauses, asks the human to paste the
|
|
6
|
+
# link they received via email, then navigates the page to it.
|
|
7
|
+
#
|
|
8
|
+
# Does NOT read the email mailbox; that would require provider-specific
|
|
9
|
+
# IMAP/Gmail integration which belongs in a vendor flow, not stdlib.
|
|
10
|
+
Browserctl.flow("magic_link_email") do
|
|
11
|
+
version "1.0.0"
|
|
12
|
+
requires_browserctl "0.11.0"
|
|
13
|
+
desc "Wait for the human to paste a magic link from their email, then navigate to it."
|
|
14
|
+
|
|
15
|
+
param :prompt, default: "Paste the magic link from your email:"
|
|
16
|
+
|
|
17
|
+
precondition("page proxy is present") { !page.nil? }
|
|
18
|
+
|
|
19
|
+
step("prompt and navigate") do
|
|
20
|
+
$stderr.print("[browserctl] #{prompt} ")
|
|
21
|
+
link = $stdin.gets&.strip
|
|
22
|
+
raise "no magic link provided" if link.nil? || link.empty?
|
|
23
|
+
|
|
24
|
+
raise "magic link must start with http:// or https:// (got #{link.inspect})" unless link.match?(%r{\Ahttps?://}i)
|
|
25
|
+
|
|
26
|
+
page.navigate(link)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../flow"
|
|
4
|
+
|
|
5
|
+
# Clicks the "Authorize <app>" button on a GitHub OAuth consent screen.
|
|
6
|
+
#
|
|
7
|
+
# Assumes the user is already signed in to GitHub and the page is parked
|
|
8
|
+
# on the consent URL — this flow does not handle the credential entry
|
|
9
|
+
# step. Use a separate workflow or flow to land on the consent page first.
|
|
10
|
+
Browserctl.flow("oauth_github") do
|
|
11
|
+
version "1.0.0"
|
|
12
|
+
requires_browserctl "0.11.0"
|
|
13
|
+
desc "Click the Authorize button on a GitHub OAuth consent screen."
|
|
14
|
+
|
|
15
|
+
# The default selector targets the green Authorize submit button on
|
|
16
|
+
# github.com/login/oauth/authorize. GitHub keeps name="authorize" stable
|
|
17
|
+
# across UI revisions; override only if you're testing against a forked
|
|
18
|
+
# GitHub Enterprise instance with a customised template.
|
|
19
|
+
param :authorize_selector, default: 'button[name="authorize"][value="1"]'
|
|
20
|
+
|
|
21
|
+
precondition("on a github oauth consent page") do
|
|
22
|
+
page.url.include?("/login/oauth/authorize")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
step("click authorize") do
|
|
26
|
+
page.click(authorize_selector)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../flow"
|
|
4
|
+
|
|
5
|
+
# Clicks the Continue / Allow button on a Google OAuth consent screen.
|
|
6
|
+
#
|
|
7
|
+
# Assumes the user is already signed in to Google and the page is parked
|
|
8
|
+
# on accounts.google.com showing the consent prompt. This flow does not
|
|
9
|
+
# pick an account from the chooser, enter a password, or solve 2FA —
|
|
10
|
+
# compose those before calling this flow.
|
|
11
|
+
#
|
|
12
|
+
# Google rotates consent UI more often than GitHub, so the default
|
|
13
|
+
# selector is a best-effort match against the modern Material 3 button.
|
|
14
|
+
# Override if your account or app version sees a different layout.
|
|
15
|
+
Browserctl.flow("oauth_google") do
|
|
16
|
+
version "1.0.0"
|
|
17
|
+
requires_browserctl "0.11.0"
|
|
18
|
+
desc "Click the Continue/Allow button on a Google OAuth consent screen."
|
|
19
|
+
|
|
20
|
+
param :continue_selector, default: 'button[jsname="LgbsSe"]'
|
|
21
|
+
|
|
22
|
+
precondition("on a google oauth consent page") do
|
|
23
|
+
url = page.url
|
|
24
|
+
url.include?("accounts.google.com") && (url.include?("/oauth") || url.include?("/signin/oauth"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
step("click continue") do
|
|
28
|
+
page.click(continue_selector)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "../../flow"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Flows
|
|
8
|
+
# RFC 6238 TOTP code generation from a base32 secret.
|
|
9
|
+
# Pure Ruby; no network and no external gem.
|
|
10
|
+
module TOTP
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def generate(secret, at: Time.now, digits: 6, period: 30, digest: "SHA1")
|
|
14
|
+
counter = (at.to_i / period).to_i
|
|
15
|
+
key = decode_base32(secret)
|
|
16
|
+
counter_b = [counter].pack("Q>") # 64-bit big-endian
|
|
17
|
+
hmac = OpenSSL::HMAC.digest(digest, key, counter_b)
|
|
18
|
+
offset = hmac[-1].ord & 0x0f
|
|
19
|
+
truncated = hmac[offset, 4].unpack1("N") & 0x7fffffff
|
|
20
|
+
truncated.to_s.rjust(digits, "0")[-digits..]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
24
|
+
|
|
25
|
+
def decode_base32(secret)
|
|
26
|
+
cleaned = secret.to_s.upcase.gsub(/[^A-Z2-7]/, "")
|
|
27
|
+
bits = cleaned.each_char.map { |c| char_to_bits(c) }.join
|
|
28
|
+
whole_bytes = bits[0, (bits.length / 8) * 8]
|
|
29
|
+
whole_bytes.scan(/.{8}/).map { |b| b.to_i(2).chr }.join
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def char_to_bits(char)
|
|
33
|
+
idx = BASE32_ALPHABET.index(char) or
|
|
34
|
+
raise ArgumentError, "invalid base32 char #{char.inspect}"
|
|
35
|
+
idx.to_s(2).rjust(5, "0")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Browserctl.flow("totp_2fa") do
|
|
42
|
+
version "1.0.0"
|
|
43
|
+
requires_browserctl "0.11.0"
|
|
44
|
+
desc "Generate an RFC 6238 TOTP code from a base32 secret and type it into the page."
|
|
45
|
+
|
|
46
|
+
param :secret, required: true, secret: true
|
|
47
|
+
param :selector, required: true
|
|
48
|
+
param :digits, default: 6
|
|
49
|
+
param :period, default: 30
|
|
50
|
+
|
|
51
|
+
precondition("page proxy is present") { !page.nil? }
|
|
52
|
+
|
|
53
|
+
step("compute and fill code") do
|
|
54
|
+
code = Browserctl::Flows::TOTP.generate(
|
|
55
|
+
secret,
|
|
56
|
+
digits: digits.to_i,
|
|
57
|
+
period: period.to_i
|
|
58
|
+
)
|
|
59
|
+
page.fill(selector, code)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
# Format-version header convention.
|
|
7
|
+
#
|
|
8
|
+
# Every persisted browserctl artifact declares its format version on the very
|
|
9
|
+
# first line as `version: <int>`. This module is the convention's single
|
|
10
|
+
# source of truth; per-format adoption (bundle, recording, workflow) lands in
|
|
11
|
+
# later WS-1 PRs. See `docs/reference/format-versions.md`.
|
|
12
|
+
module FormatVersion
|
|
13
|
+
HEADER_RE = /\Aversion:\s*(\d+)\s*\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Parse the version header from an IO or String. Returns the Integer
|
|
18
|
+
# version. Raises Browserctl::ProtocolMismatch if the header is missing or
|
|
19
|
+
# malformed.
|
|
20
|
+
def parse(io_or_string)
|
|
21
|
+
first_line = io_or_string.respond_to?(:gets) ? io_or_string.gets : io_or_string.to_s.each_line.first
|
|
22
|
+
raise ProtocolMismatch, "missing version header" if first_line.nil?
|
|
23
|
+
|
|
24
|
+
match = HEADER_RE.match(first_line.chomp)
|
|
25
|
+
raise ProtocolMismatch, "malformed version header: #{first_line.inspect}" unless match
|
|
26
|
+
|
|
27
|
+
Integer(match[1])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the canonical header string for a given integer version.
|
|
31
|
+
def stamp(version:)
|
|
32
|
+
raise ArgumentError, "version must be a non-negative Integer" unless version.is_a?(Integer) && version >= 0
|
|
33
|
+
|
|
34
|
+
"version: #{version}\n"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
5
7
|
|
|
6
8
|
module Browserctl
|
|
7
9
|
LEVEL_MAP = {
|
|
@@ -11,6 +13,16 @@ module Browserctl
|
|
|
11
13
|
"error" => ::Logger::ERROR
|
|
12
14
|
}.freeze
|
|
13
15
|
|
|
16
|
+
# JSONL rotation policy. Stdlib `Logger` rotates by size when given an
|
|
17
|
+
# integer `shift_age` and `shift_size`.
|
|
18
|
+
LOG_SHIFT_AGE = 10 # keep last 10 rotated files
|
|
19
|
+
LOG_SHIFT_SIZE = 10 * 1024 * 1024 # rotate at 10MB
|
|
20
|
+
|
|
21
|
+
# Resolved at call time so tests can override BROWSERCTL_DIR via stub_const.
|
|
22
|
+
def self.log_dir
|
|
23
|
+
File.join(BROWSERCTL_DIR, "logs")
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
class MultiLogger
|
|
15
27
|
def initialize(*loggers)
|
|
16
28
|
@loggers = loggers
|
|
@@ -30,6 +42,37 @@ module Browserctl
|
|
|
30
42
|
end
|
|
31
43
|
end
|
|
32
44
|
|
|
45
|
+
# Formats every log line as a single JSON object: {ts, level, component, msg, ...}.
|
|
46
|
+
# If the message is a Hash, its keys are merged so callers can attach
|
|
47
|
+
# structured context, e.g. `logger.info(event: "x", session: id)`.
|
|
48
|
+
class JsonlFormatter
|
|
49
|
+
def initialize(component:)
|
|
50
|
+
@component = component
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(severity, time, _progname, msg)
|
|
54
|
+
record = {
|
|
55
|
+
ts: time.utc.iso8601(3),
|
|
56
|
+
level: severity,
|
|
57
|
+
component: @component
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case msg
|
|
61
|
+
when Hash
|
|
62
|
+
explicit = msg[:msg] || msg["msg"]
|
|
63
|
+
record[:msg] = explicit if explicit
|
|
64
|
+
record.merge!(msg.reject { |k, _| k.to_s == "msg" })
|
|
65
|
+
when Exception
|
|
66
|
+
record[:msg] = "#{msg.class}: #{msg.message}"
|
|
67
|
+
record[:backtrace] = Array(msg.backtrace).first(10)
|
|
68
|
+
else
|
|
69
|
+
record[:msg] = msg.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
"#{JSON.generate(record)}\n"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
33
76
|
def self.logger
|
|
34
77
|
@logger ||= build_logger("info")
|
|
35
78
|
end
|
|
@@ -38,19 +81,69 @@ module Browserctl
|
|
|
38
81
|
@logger = instance
|
|
39
82
|
end
|
|
40
83
|
|
|
41
|
-
|
|
84
|
+
# Build a logger that writes:
|
|
85
|
+
# - human-readable lines to stderr (unchanged behaviour)
|
|
86
|
+
# - human-readable lines to log_path: when given (the daemon tail file)
|
|
87
|
+
# - structured JSONL lines to ~/.browserctl/logs/<component>.log (rotating
|
|
88
|
+
# 10 files x 10MB) when jsonl: is true
|
|
89
|
+
#
|
|
90
|
+
# JSONL output is purely additive — existing stderr/stdout behaviour is
|
|
91
|
+
# preserved so scripted callers see no change.
|
|
92
|
+
def self.build_logger(level_name, log_path: nil, component: "daemon", jsonl: true)
|
|
42
93
|
level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
|
|
43
|
-
|
|
94
|
+
text_formatter = proc do |sev, t, prog, msg|
|
|
95
|
+
"#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{format_text_msg(msg)}\n"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
loggers = [make_logger($stderr, level, text_formatter)]
|
|
44
99
|
|
|
45
|
-
|
|
46
|
-
|
|
100
|
+
if log_path
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
|
|
102
|
+
FileUtils.touch(log_path)
|
|
103
|
+
File.chmod(0o600, log_path)
|
|
104
|
+
loggers << make_logger(log_path, level, text_formatter)
|
|
105
|
+
end
|
|
47
106
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
107
|
+
if jsonl
|
|
108
|
+
jsonl_logger = build_jsonl_logger(level, component)
|
|
109
|
+
loggers << jsonl_logger if jsonl_logger
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
loggers.length == 1 ? loggers.first : MultiLogger.new(*loggers)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns a stdlib Logger writing JSON-Lines records to
|
|
116
|
+
# ~/.browserctl/logs/<component>.log with size-based rotation. Returns nil
|
|
117
|
+
# (and stays silent) if the directory cannot be created so logging never
|
|
118
|
+
# crashes the daemon.
|
|
119
|
+
# LogDevice that suppresses stdlib's "# Logfile created on ..." header so
|
|
120
|
+
# the resulting file is pure JSON Lines.
|
|
121
|
+
class HeaderlessLogDevice < ::Logger::LogDevice
|
|
122
|
+
def add_log_header(_file); end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.build_jsonl_logger(level, component)
|
|
126
|
+
dir = log_dir
|
|
127
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
128
|
+
path = File.join(dir, "#{component}.log")
|
|
129
|
+
device = HeaderlessLogDevice.new(path, shift_age: LOG_SHIFT_AGE, shift_size: LOG_SHIFT_SIZE)
|
|
130
|
+
log = ::Logger.new(device)
|
|
131
|
+
log.level = level
|
|
132
|
+
log.progname = component
|
|
133
|
+
log.formatter = JsonlFormatter.new(component: component)
|
|
134
|
+
log
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.format_text_msg(msg)
|
|
140
|
+
case msg
|
|
141
|
+
when Hash then (msg[:msg] || msg["msg"] || msg.inspect).to_s
|
|
142
|
+
when Exception then "#{msg.class}: #{msg.message}"
|
|
143
|
+
else msg.to_s
|
|
144
|
+
end
|
|
53
145
|
end
|
|
146
|
+
private_class_method :format_text_msg
|
|
54
147
|
|
|
55
148
|
def self.make_logger(device, level, formatter)
|
|
56
149
|
log = ::Logger.new(device)
|