browserctl 0.11.0 → 0.13.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codes"
4
+
5
+ module Browserctl
6
+ class Error < StandardError
7
+ # Stable mapping from a canonical {Browserctl::Error::Codes} string to a
8
+ # process exit status. The CLI's top-level rescue uses this so AI agents
9
+ # and shell scripts can branch on `$?` deterministically without parsing
10
+ # stderr.
11
+ #
12
+ # Stability contract:
13
+ # - Exit code integers are part of the v0.12 stable surface and will not
14
+ # be renumbered without a major version bump.
15
+ # - Unknown / unmapped codes intentionally fall through to {GENERIC} (1)
16
+ # so an unfamiliar code never silently surfaces as a non-error (0).
17
+ #
18
+ # See docs/reference/exit-codes.md for the operator-facing table.
19
+ module ExitCodes
20
+ OK = 0
21
+ GENERIC = 1
22
+ DRIFT = 2
23
+ AUTH_REQUIRED = 3
24
+ DAEMON_UNREACHABLE = 4
25
+ PROTOCOL_MISMATCH = 5
26
+ SELECTOR_NOT_FOUND = 6
27
+ STATE_EXPIRED = 7
28
+
29
+ # Canonical Codes string → exit status integer. Codes without an entry
30
+ # (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
31
+ # GENERIC) collapse to {GENERIC} for now; they may earn dedicated exit
32
+ # codes in a future milestone.
33
+ #
34
+ # DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
35
+ # entry in this table — drift-related raises fall through to GENERIC
36
+ # until that code is introduced.
37
+ TABLE = {
38
+ Codes::AUTH_REQUIRED => AUTH_REQUIRED,
39
+ Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
40
+ Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
41
+ Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
42
+ Codes::STATE_EXPIRED => STATE_EXPIRED
43
+ }.freeze
44
+
45
+ # @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
46
+ # @return [Integer] mapped exit status; {GENERIC} for nil or unknown codes
47
+ def self.for(code)
48
+ return GENERIC if code.nil?
49
+
50
+ TABLE.fetch(code, GENERIC)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codes"
4
+
5
+ module Browserctl
6
+ class Error < StandardError
7
+ # Maps a stable error code (see {Browserctl::Error::Codes}) to a short
8
+ # imperative sentence telling the operator (or AI agent) what to try
9
+ # next. Codes without an explicit entry fall back to a generic pointer
10
+ # to the error reference doc (added in PR #11 of v0.12).
11
+ module SuggestedActions
12
+ DEFAULT = "See docs/reference/errors.md for guidance."
13
+
14
+ TABLE = {
15
+ Codes::AUTH_REQUIRED =>
16
+ "Run the suggested flow to refresh credentials, then retry.",
17
+ Codes::SELECTOR_NOT_FOUND =>
18
+ "Re-run snapshot to get fresh refs, then retry with a stable ref or selector.",
19
+ Codes::STATE_EXPIRED =>
20
+ "Re-save the state bundle (state save) or rotate it (state rotate).",
21
+ Codes::SECRET_RESOLUTION_FAILED =>
22
+ "Verify the secret resolver config and that the underlying secret exists.",
23
+ Codes::DAEMON_UNREACHABLE =>
24
+ "Start the daemon with 'browserctl daemon start', then retry.",
25
+ Codes::PROTOCOL_MISMATCH =>
26
+ "Upgrade browserctl to a version that supports this artifact's format version.",
27
+ Codes::DOMAIN_NOT_ALLOWED =>
28
+ "Add the domain to your policy allowlist or use an allowed URL.",
29
+ Codes::KEY_NOT_FOUND =>
30
+ "Verify the key was stored in this daemon session before fetching.",
31
+ Codes::GENERIC => DEFAULT
32
+ }.freeze
33
+
34
+ # @param code [String, nil] a SCREAMING_SNAKE code from {Codes}
35
+ # @return [String] suggested action sentence; never nil
36
+ def self.for(code)
37
+ TABLE.fetch(code, DEFAULT)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,28 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error/codes"
4
+ require_relative "error/exit_codes"
5
+ require_relative "error/suggested_actions"
6
+
3
7
  module Browserctl
4
8
  # Base error class for all browserctl daemon errors.
5
9
  # Subclasses carry a machine-readable `code` that appears in wire responses.
10
+ # The canonical enum of stable codes lives in {Browserctl::Error::Codes};
11
+ # the sweep that retrofits every raise to use those codes lands in a later
12
+ # v0.12 PR.
6
13
  # @attr_reader code [String] machine-readable error code
14
+ # @attr_reader context [Hash] free-form structured fields (selector, path, ...)
7
15
  class Error < StandardError
8
16
  def self.default_code = "error"
9
17
 
10
- attr_reader :code
18
+ attr_reader :code, :context
11
19
 
12
- def initialize(msg = nil, code: self.class.default_code)
13
- @code = code
20
+ def initialize(msg = nil, code: self.class.default_code, context: {})
21
+ @code = code
22
+ @context = context || {}
14
23
  super(msg)
15
24
  end
25
+
26
+ # Returns the canonical structured payload emitted on the daemon wire and
27
+ # on CLI stderr. Shape is stable across releases — agents branch on `code`
28
+ # without parsing prose.
29
+ # @return [Hash{Symbol => Object}]
30
+ def to_payload
31
+ {
32
+ code: code,
33
+ message: message,
34
+ context: context,
35
+ suggested_action: SuggestedActions.for(code)
36
+ }
37
+ end
16
38
  end
17
39
 
18
- class PageNotFound < Error; def self.default_code = "page_not_found" end
19
- class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
- class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
- class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
- class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
- class TimeoutError < Error; def self.default_code = "timeout" end
24
- class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
- class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
40
+ class PageNotFound < Error; def self.default_code = "page_not_found" end
41
+ class SelectorNotFound < Error; def self.default_code = Codes::SELECTOR_NOT_FOUND end
42
+ class RefNotFound < Error; def self.default_code = "ref_not_found" end
43
+ class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
44
+ class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
45
+ class TimeoutError < Error; def self.default_code = "timeout" end
46
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
47
+ class DaemonUnavailableError < Error; def self.default_code = Codes::DAEMON_UNREACHABLE end
26
48
  class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
27
49
 
28
50
  # Raised when the daemon detects that the current page needs authentication —
@@ -31,7 +53,7 @@ module Browserctl
31
53
  # suggested flow (from the bundle manifest) so callers can recover without
32
54
  # additional lookups. The CLI maps this code to exit status 7.
33
55
  class AuthRequiredError < Error
34
- def self.default_code = "AUTH_REQUIRED"
56
+ def self.default_code = Codes::AUTH_REQUIRED
35
57
 
36
58
  AUTH_REQUIRED_EXIT_CODE = 7
37
59
 
@@ -50,17 +72,25 @@ module Browserctl
50
72
  code: self.class.default_code,
51
73
  state: state,
52
74
  suggested_flow: suggested_flow,
53
- reason: reason
75
+ reason: reason,
76
+ context: { state: state, suggested_flow: suggested_flow, reason: reason }.compact,
77
+ suggested_action: SuggestedActions.for(self.class.default_code)
54
78
  }.compact
55
79
  end
56
80
  end
57
81
 
58
82
  class WorkflowError < Error; def self.default_code = "workflow_error" end
59
- class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
83
+ class SecretResolverError < WorkflowError; def self.default_code = Codes::SECRET_RESOLUTION_FAILED end
60
84
 
61
85
  class FlowError < WorkflowError; def self.default_code = "flow_error" end
62
86
  class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
63
87
  class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
64
88
  class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
65
89
  class FlowPostconditionError < FlowError; def self.default_code = "flow_postcondition_failed" end
90
+
91
+ # Raised when a persisted artifact (bundle, recording, workflow, etc.) has a
92
+ # `version:` header that this build does not know how to read. The full error
93
+ # code taxonomy lands in WS-2 (PR #7); this class is a forward-reference stub
94
+ # so WS-1 PRs can already raise the canonical code.
95
+ class ProtocolMismatch < Error; def self.default_code = Codes::PROTOCOL_MISMATCH end
66
96
  end
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
3
+ require_relative "callable_definition"
4
4
  require_relative "errors"
5
- require_relative "secret_resolvers"
6
5
 
7
6
  module Browserctl
8
- FlowParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
9
- FlowStepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
10
7
  FlowConditionDef = Struct.new(:kind, :label, :block, keyword_init: true)
11
8
 
9
+ # Back-compat aliases — flow_wrapper specs reference these directly.
10
+ FlowParamDef = CallableDefinition::ParamDef
11
+ FlowStepDef = CallableDefinition::StepDef
12
+
12
13
  class FlowContext
13
14
  attr_reader :page, :client, :params
14
15
 
@@ -29,31 +30,28 @@ module Browserctl
29
30
  end
30
31
  end
31
32
 
32
- class Flow
33
+ class Flow < CallableDefinition
33
34
  SEMVER_RE = /\A\d+\.\d+\.\d+\z/
34
35
 
35
- attr_reader :name,
36
- :version_string,
37
- :description,
38
- :param_defs,
39
- :steps,
36
+ attr_reader :version_string,
40
37
  :preconditions,
41
38
  :postconditions,
42
39
  :produces_state_block,
43
40
  :min_browserctl_version
44
41
 
45
42
  def initialize(name)
46
- @name = name.to_s
43
+ super
47
44
  @version_string = "0.0.0"
48
- @description = nil
49
- @param_defs = {}
50
- @steps = []
51
45
  @preconditions = []
52
46
  @postconditions = []
53
47
  @produces_state_block = nil
54
48
  @min_browserctl_version = nil
55
49
  end
56
50
 
51
+ def callable_kind
52
+ :flow
53
+ end
54
+
57
55
  def version(value)
58
56
  validate_semver!(value, label: "version")
59
57
  @version_string = value.to_s
@@ -64,27 +62,6 @@ module Browserctl
64
62
  @min_browserctl_version = value.to_s
65
63
  end
66
64
 
67
- def desc(text)
68
- @description = text.to_s
69
- end
70
-
71
- def param(name, required: false, secret: false, default: nil, secret_ref: nil)
72
- secret = true if secret_ref
73
- @param_defs[name] = FlowParamDef.new(
74
- name: name,
75
- required: required,
76
- secret: secret,
77
- default: default,
78
- secret_ref: secret_ref
79
- )
80
- end
81
-
82
- def step(label, retry_count: 0, timeout: nil, &block)
83
- raise ArgumentError, "flow step '#{label}' requires a block" unless block
84
-
85
- @steps << FlowStepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
86
- end
87
-
88
65
  def precondition(label = "precondition", &block)
89
66
  raise ArgumentError, "precondition '#{label}' requires a block" unless block
90
67
 
@@ -103,6 +80,24 @@ module Browserctl
103
80
  @produces_state_block = block
104
81
  end
105
82
 
83
+ # Definition-time guard against cross-type composition. A flow may only
84
+ # compose other flows; pulling steps from a workflow would smuggle
85
+ # `store`/`fetch` into a flow context that has no daemon-backed
86
+ # persistence.
87
+ def compose(target_name)
88
+ name = target_name.to_s
89
+ if Browserctl.respond_to?(:lookup_workflow) && Browserctl.lookup_workflow(name)
90
+ raise ArgumentError,
91
+ "flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
92
+ "workflows share state — composition across kinds is not supported"
93
+ end
94
+
95
+ source = Browserctl.lookup_flow(name)
96
+ raise ArgumentError, "flow '#{name}' not found for composition" unless source
97
+
98
+ @steps.concat(source.steps)
99
+ end
100
+
106
101
  def run(page: nil, client: nil, **params)
107
102
  ctx = FlowContext.new(page: page, client: client, params: resolve_params(params))
108
103
 
@@ -121,20 +116,12 @@ module Browserctl
121
116
  raise ArgumentError, "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})"
122
117
  end
123
118
 
124
- def resolve_params(provided)
125
- @param_defs.each_with_object({}) do |(name, defn), out|
126
- val = if defn.secret_ref
127
- SecretResolverRegistry.resolve(defn.secret_ref)
128
- elsif provided.key?(name)
129
- provided[name]
130
- else
131
- defn.default
132
- end
133
-
134
- raise FlowParamError, "flow '#{@name}' requires param '#{name}'" if defn.required && val.nil?
119
+ def missing_param_error(name)
120
+ FlowParamError.new("flow '#{@name}' requires param '#{name}'")
121
+ end
135
122
 
136
- out[name] = val
137
- end
123
+ def step_timeout_error(defn)
124
+ FlowStepError.new("flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s")
138
125
  end
139
126
 
140
127
  def run_conditions(ctx, conditions, error_class:)
@@ -168,17 +155,6 @@ module Browserctl
168
155
  "flow '#{@name}' step '#{defn.label}' failed: #{last_error.message}"
169
156
  end
170
157
 
171
- def execute_step_block(ctx, defn)
172
- if defn.timeout
173
- ::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
174
- else
175
- ctx.instance_exec(&defn.block)
176
- end
177
- rescue ::Timeout::Error
178
- raise FlowStepError,
179
- "flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s"
180
- end
181
-
182
158
  def produce_state(ctx)
183
159
  return nil unless @produces_state_block
184
160
 
@@ -5,8 +5,8 @@ require_relative "../../flow"
5
5
 
6
6
  # Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
7
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`.
8
+ # before returning. Optionally saves the post-solve state under a name
9
+ # you can reload later with `state load`.
10
10
  #
11
11
  # Reuses Browserctl::Detectors.cloudflare? — the server-side detector
12
12
  # already shipped in v0.8 — by adapting the client-facing PageProxy to
@@ -34,7 +34,7 @@ Browserctl.flow("cloudflare_solve") do
34
34
 
35
35
  param :prompt,
36
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
37
+ param :state_name # optional — if set, state_save is called after the challenge clears
38
38
 
39
39
  precondition("page proxy is present") { !page.nil? }
40
40
  precondition("cloudflare challenge is present") do
@@ -54,6 +54,6 @@ Browserctl.flow("cloudflare_solve") do
54
54
  produces_state do
55
55
  next nil unless state_name && client
56
56
 
57
- client.session_save(state_name)
57
+ client.state_save(state_name)
58
58
  end
59
59
  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
@@ -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
- def self.build_logger(level_name, log_path: nil)
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
- formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
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
- stderr_log = make_logger($stderr, level, formatter)
46
- return stderr_log unless log_path
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
- FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
49
- FileUtils.touch(log_path)
50
- File.chmod(0o600, log_path)
51
- file_log = make_logger(log_path, level, formatter)
52
- MultiLogger.new(stderr_log, file_log)
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)