hubbado-log 1.2.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 420feafe971a2416372391df56971288d2a8a6730d5b2a5b010370465fe0eecb
4
- data.tar.gz: 1755fdec20e188020cc6b55431c5a6ccc5edeaec13d952430663e3eb9397685c
3
+ metadata.gz: c548a1c52601a45c8ffb53337dcfe28805e63cbe868d90692f5bff54b8fc4f82
4
+ data.tar.gz: da676bfa5ff5ee43efa7660412ea53da1c307e4fdadf8570cc6bd1251178c60b
5
5
  SHA512:
6
- metadata.gz: d1195dd74d23e42ab83a6b93daba36cdd53b5311ee2a1dd19399f1090fe9c2fba4a2b9fe660489a2ba8c85816a3995e495979a7ea3b01c83693a5a2a7dfa0292
7
- data.tar.gz: a4c8a344ae3c7297037890633fbec484f5d45114cbc2b1f76bdf15314d2b48a5b7b17ad35c6601a9f59720b6ea98c41b9c34676a1d01d5953bbba8ba5627271b
6
+ metadata.gz: d7a795d9bc507c468e12232058d413c916f8f6e155f68793e6305314c50c7bf8e5528a690280c9b9b265824cf6e8cb6e428741aaa4df0abe6550dcf9cc0fb636
7
+ data.tar.gz: 308912bee3a3af9ca487628ba15cadbe74f33fdce80ee329585229a34b7bae25f832167c2d7250189fa50b6d30c0ad4d7a3e07fdb78e8e207f0032f8f31d44b0
data/ChangeLog.md CHANGED
@@ -4,6 +4,100 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ # [1.4.0 - 2026-08-16]
8
+ ## Added
9
+ - A substitute for a logger. `Controls::Logger.example` returns one, and a spec
10
+ assigns it where a class's logger goes:
11
+
12
+ ```ruby
13
+ instance.logger = Hubbado::Log::Controls::Logger.example
14
+
15
+ instance.()
16
+
17
+ assert instance.logger.logged?(:error)
18
+ ```
19
+
20
+ It records what it was told rather than writing, so no handler is involved and
21
+ neither the level nor the tag list decides what a spec can read back.
22
+
23
+ Three questions, each taking an optional severity, and each answering in the
24
+ terms its name promises:
25
+
26
+ | Call | Answers |
27
+ |---|---|
28
+ | `logged?(:warn)` | whether anything was written at that severity |
29
+ | `messages(:warn)` | what it said — the message strings |
30
+ | `logged(:warn)` | everything about what it said — `severity`, `message`, `data` |
31
+
32
+ `messages` and `logged?` are both derived from `logged`, so the three cannot
33
+ disagree about what counts as written at a severity.
34
+
35
+ A severity reaches a logger two ways — as the generated method, or as `#log`'s
36
+ first argument — and both answer the same question, compared as symbols.
37
+
38
+ - `evt-subst_attr` as a runtime dependency. The substitute is a mimic of `Logger`
39
+ extended with `Logger::Substitute`, so it answers `is_a?(Logger)` for a class
40
+ that checks, and gains any method `Logger` gains.
41
+
42
+ ## Deprecated
43
+ - `Controls::LogHandler` as the way a consumer reads back what a class logged.
44
+ It builds a real `Logger` and then passes `level: :trace` and `tags: Tags::ALL`
45
+ to switch off the filtering it just built — which is a substitute, reached the
46
+ long way round. Use `Controls::Logger.example`. The handler control stays for
47
+ what it is good at: specs where a handler receiving, or not receiving, a
48
+ message is the subject, as `'A message the filter left out'` is.
49
+
50
+ ## Changed
51
+ - `Controls::LogHandler` names `Log::Logger` where it said `Logger`. With
52
+ `Controls::Logger` defined, a bare `Logger` inside `Controls` resolves to the
53
+ control rather than to the class.
54
+
55
+ ## Compatibility
56
+ Nothing that exists breaks. `Controls::LogHandler` keeps `.attach`, `.logger`,
57
+ `messages`, `logged?`, `reset` and the attributes, unchanged.
58
+
59
+ # [1.3.0 - 2026-08-15]
60
+ ## Added
61
+ - Tags, a second filtering axis beside the level. A message names its concern
62
+ with `tag:` or `tags:`, and `LOG_TAGS` decides which tagged messages are
63
+ written. The syntax and semantics are Eventide's log gem's, so a string an
64
+ operator writes means the same thing in both codebases.
65
+ - `config.tags`, set in code the way `config.level` is, and a `tags:` keyword on
66
+ `Logger.new` so one logger can name its own list without the process being
67
+ turned up around it — the escape hatch the level already had.
68
+ - `Controls::LogHandler` records every message it is given in `messages`, answers
69
+ `logged?` with or without a severity, resets with `reset`, and builds a wired
70
+ logger with `.attach` or `.logger`. Five downstream projects had each
71
+ hand-rolled the same thing because the control kept only the most recent; they
72
+ can delete their copy whenever it suits them. The attributes still read the
73
+ most recent, derived from `messages` rather than assigned beside it.
74
+ - `.attach` and `.logger` name `_all` as well as `trace`, so neither the
75
+ configured level nor the configured tag list decides what a spec can read back.
76
+ `.attach` raises a named `ArgumentError` for a class carrying no logger rather
77
+ than failing on nil.
78
+
79
+ ## Changed
80
+ - The test suite sets `LOG_TAGS` rather than defaulting it, so a value left in a
81
+ developer's shell cannot decide which tests can write anything.
82
+
83
+ ## Compatibility
84
+ Nothing that exists breaks. A handler's arguments are unchanged — tags decide
85
+ whether it is called, and are not passed to it. Every handler across the
86
+ consuming repositories was checked and needs no edit.
87
+
88
+ One theoretical change: the severity methods and `Hubbado::Log.log` now name
89
+ `tag:` and `tags:`, so a call site passing its data as bare keywords —
90
+ `logger.info('x', record_id: 7)`, which worked because Ruby folded them into a
91
+ positional hash while the methods accepted none — raises instead. No such call
92
+ site exists in any consuming repository; pass the hash explicitly if one appears.
93
+
94
+ ## Not included
95
+ A tag declared once per component, and tags reaching a log handler. Neither has
96
+ an implementer: nothing tags a message yet, and no handler prints or routes on
97
+ one. Eventide does not pass tags to its own output either — they filter, and
98
+ stop there. Both are worth adding when something needs them, and neither has to
99
+ break a handler to arrive.
100
+
7
101
  # [1.2.0 - 2026-08-14]
8
102
  ## Added
9
103
  - A `trace` level, below `debug`. Program flow — a line per iteration of a loop —
data/README.md CHANGED
@@ -20,7 +20,7 @@ Or install it yourself as:
20
20
 
21
21
  ## Level
22
22
 
23
- A line below the level reaches no handler.
23
+ A message below the level reaches no handler.
24
24
 
25
25
  ```ruby
26
26
  Hubbado::Log.configuration do |config|
@@ -45,13 +45,133 @@ Levels, lowest first:
45
45
  | `warn` | Unexpected state that is not an error, or is recoverable, and that a developer or operator should examine |
46
46
  | `error` | Message logged just prior to raising an error |
47
47
  | `fatal` | Message recorded, when possible, as the process is terminating due to an error |
48
- A single logger can
49
- name its own with `Hubbado::Log::Logger.new(subject, handlers, level: :debug)`.
48
+
49
+ A single logger can name its own with
50
+ `Hubbado::Log::Logger.new(subject, handlers, level: :debug)`.
50
51
 
51
52
  `LOG_LEVEL` is shared with Eventide's log gem, which writes names this gem does not
52
53
  know. A name that is not one of `debug`, `info`, `warn`, `error`, `fatal` or
53
54
  `unknown` leaves the level at `info` rather than raising.
54
55
 
56
+ ## Tags
57
+
58
+ A level says what kind of thing happened. A tag says which concern it belongs to, so a
59
+ completion that is simply frequent can be filtered out without being demoted to `debug`.
60
+
61
+ Name the concern where the message is written. `tag:` and `tags:` are both accepted, and a
62
+ message can use either or both:
63
+
64
+ ```ruby
65
+ logger.info('Invoice raised', tags: [:invoicing, :billing])
66
+ logger.trace('Row read', tag: :data)
67
+ ```
68
+
69
+ ### `LOG_TAGS`
70
+
71
+ Which tagged messages are written is decided by `LOG_TAGS`, a comma-separated list:
72
+
73
+ $ LOG_TAGS='_untagged,-data,billing,invoicing' ./my-command
74
+
75
+ | Entry | Meaning |
76
+ |---|---|
77
+ | `name` | Write messages carrying this tag |
78
+ | `-name` | Do not write messages carrying this tag, even if another entry includes them |
79
+ | `_untagged` | Write messages carrying no tag at all |
80
+ | `_all` | Write every message, whatever it carries |
81
+
82
+ A message can also tag itself `:*`, which writes it whatever the list says.
83
+
84
+ **Write the list with no spaces.** It is split on commas and nothing else, so `http, cache`
85
+ asks for a tag named `http` and another named `⎵cache`, which nothing carries. This is
86
+ Eventide's behaviour, kept deliberately so one `LOG_TAGS` means the same thing to both gems.
87
+
88
+ Tags compose with the level rather than replacing it: both filters have to pass, so a tag
89
+ cannot raise a message above the level and the level cannot rescue one the list leaves out.
90
+
91
+ A single logger can name its own list, as it can name its own level, so one component can be
92
+ read without turning up everything around it:
93
+
94
+ ```ruby
95
+ Hubbado::Log::Logger.new(subject, handlers, level: :trace, tags: '_all')
96
+ ```
97
+
98
+ The syntax and its behaviour are Eventide's log gem, copied deliberately so that a string an
99
+ operator writes means the same thing in both codebases. Two consequences of that are worth
100
+ knowing before adopting tags:
101
+
102
+ - **`LOG_TAGS` is an allow-list.** A tagged message is written only if the list names it. Adding
103
+ a tag to a call site therefore *silences* that message everywhere `LOG_TAGS` has not been
104
+ updated — cron, CI and production included. Ship the variable with the tag.
105
+ - **There is no way to mute one concern and keep the rest.** `-name` subtracts only from
106
+ messages an include has already matched, and `_all` is answered before any exclusion, so
107
+ `_all,-data` still writes `data` messages. Keeping everything except one concern means naming
108
+ the others.
109
+
110
+ `LOG_TAGS` is shared with Eventide's log gem, as `LOG_LEVEL` is. In a process running both, one
111
+ list decides for both, and an application whose messages are all untagged goes silent unless the
112
+ list contains `_untagged`.
113
+
114
+ ## Reading back what a class logged
115
+
116
+ A spec assigns a substitute where the class's logger goes, and then asks what the class said:
117
+
118
+ ```ruby
119
+ require 'hubbado/log/controls'
120
+
121
+ instance.logger = Hubbado::Log::Controls::Logger.example
122
+
123
+ instance.()
124
+
125
+ assert instance.logger.logged?(:error)
126
+ ```
127
+
128
+ For a class handed a logger rather than carrying one — the shape a CLI usually takes — it is the
129
+ same object, passed in:
130
+
131
+ ```ruby
132
+ logger = Hubbado::Log::Controls::Logger.example
133
+
134
+ CLI.run(argv, logger: logger)
135
+
136
+ assert logger.logged?(:error)
137
+ ```
138
+
139
+ It records what it was told rather than writing, so no handler is involved and neither the
140
+ configured level nor `LOG_TAGS` decides what can be read back.
141
+
142
+ Three questions, each taking an optional severity:
143
+
144
+ | Call | Answers |
145
+ |---|---|
146
+ | `logged?` / `logged?(:warn)` | whether anything was written, at all or at that severity |
147
+ | `messages` / `messages(:warn)` | what it said — the message strings, in order |
148
+ | `logged` / `logged(:warn)` | everything about what it said |
149
+
150
+ `logged` answers with entries carrying `severity`, `message` and `data`, for the assertion that
151
+ needs more than the text:
152
+
153
+ ```ruby
154
+ assert logger.logged(:error).first.data.equal?(exception)
155
+ ```
156
+
157
+ `messages` and `logged?` are both derived from `logged`, so the three cannot disagree about what
158
+ counts as written at a severity.
159
+
160
+ A severity reaches a logger two ways — `logger.warn('…')` names it as the method,
161
+ `logger.log(:warn, '…')` as an argument — and both answer the same question, compared as symbols.
162
+
163
+ The substitute is a mimic of `Logger`, so it answers `is_a?(Hubbado::Log::Logger)` for a class
164
+ that checks, and gains any method `Logger` gains.
165
+
166
+ Prefer `logged?` to reaching into `messages` where it will do. That a failure was reported is
167
+ usually the contract; the wording of the line usually is not.
168
+
169
+ ### Testing a handler
170
+
171
+ `Controls::LogHandler` is for specs where a handler receiving — or not receiving — a message is
172
+ itself the subject, which in practice means this gem's own tests of level and tag filtering. A
173
+ consumer asserting that its class logged something wants the substitute above.
174
+
55
175
  ## Development
56
176
 
57
177
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/hubbado-log.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "hubbado-log"
3
- s.version = "1.2.0"
3
+ s.version = "1.4.0"
4
4
  s.summary = "Lightweight pluggable logging system"
5
5
 
6
6
  s.authors = ["Hubbado Devs"]
@@ -25,6 +25,7 @@ Gem::Specification.new do |s|
25
25
  s.required_ruby_version = '>= 3.2'
26
26
 
27
27
  s.add_runtime_dependency 'evt-dependency'
28
+ s.add_runtime_dependency 'evt-subst_attr'
28
29
 
29
30
  s.add_development_dependency "debug"
30
31
  s.add_development_dependency "hubbado-style"
@@ -1,16 +1,24 @@
1
1
  module Hubbado
2
2
  class Log
3
3
  class Configuration
4
- # Tracing is off until somebody asks for it, which is what lets a debug line be written
4
+ # Tracing is off until somebody asks for it, which is what lets a debug message be written
5
5
  # for a human watching one run without it also reaching every unattended log.
6
6
  DEFAULT_LEVEL = :info
7
7
 
8
8
  attr_accessor :loggers
9
9
  attr_reader :level
10
+ attr_reader :tags
10
11
 
11
- def initialize(level: nil)
12
+ def initialize(level: nil, tags: nil)
12
13
  @loggers = []
13
14
  self.level = level || DEFAULT_LEVEL
15
+ self.tags = tags
16
+ end
17
+
18
+ # Takes the LOG_TAGS string as readily as a list, so nothing upstream has to know the
19
+ # syntax in order to hand a value in.
20
+ def tags=(value)
21
+ @tags = Tags.parse(value)
14
22
  end
15
23
 
16
24
  # A name rather than a severity is answered with the default instead of an exception.
@@ -2,18 +2,63 @@ module Hubbado
2
2
  class Log
3
3
  module Controls
4
4
  class LogHandler < Hubbado::Log::LogHandler
5
- attr_accessor :subject
6
- attr_accessor :severity
7
- attr_accessor :message
8
- attr_accessor :data
9
- attr_accessor :stacktrace
5
+ # Everything a class logged, in order, because a run that reports two failures needs
6
+ # both of them. The attributes below read the most recent.
7
+ def messages
8
+ @messages ||= []
9
+ end
10
+
11
+ # Replaces a class's logger with one writing here, keeping its subject. Neither the
12
+ # configured level nor the configured tags decide what a spec attaching one of these can
13
+ # read: it is asking what the class said, and the process's filters are not its subject.
14
+ def self.attach(instance, level: :trace, tags: Tags::ALL)
15
+ logger = instance.logger
16
+
17
+ if logger.nil?
18
+ raise ArgumentError, "#{instance.class} carries no logger to attach to. " \
19
+ "Use .logger for a class that is handed one instead."
20
+ end
21
+
22
+ new.tap do |handler|
23
+ instance.logger = Log::Logger.new(logger.subject, [handler], level: level, tags: tags)
24
+ end
25
+ end
26
+
27
+ # For a class handed a logger rather than carrying one, and for a spec that wants both.
28
+ def self.logger(subject = Subject.example, level: :trace, tags: Tags::ALL)
29
+ handler = new
30
+
31
+ [handler, Log::Logger.new(subject, [handler], level: level, tags: tags)]
32
+ end
10
33
 
11
34
  def log(subject, severity, message, data = nil, stacktrace = nil)
12
- self.subject = subject
13
- self.severity = severity
14
- self.message = message
15
- self.data = data
16
- self.stacktrace = stacktrace
35
+ messages << {
36
+ subject: subject,
37
+ severity: severity,
38
+ message: message,
39
+ data: data,
40
+ stacktrace: stacktrace
41
+ }
42
+ end
43
+
44
+ # Named without a severity, this answers whether anything was written at all — which a
45
+ # spec would otherwise have to infer from an attribute never having been set. A severity
46
+ # is compared as a symbol, because #log takes a String as readily and passes on what it
47
+ # was given.
48
+ def logged?(severity = nil)
49
+ return !messages.empty? if severity.nil?
50
+
51
+ messages.any? { |written| written.fetch(:severity).to_s.to_sym == severity.to_s.to_sym }
52
+ end
53
+
54
+ def reset
55
+ @messages = []
56
+ end
57
+
58
+ # The most recent message. Derived rather than assigned alongside `messages`, so the two
59
+ # cannot disagree about which message is the latest.
60
+ %i[subject severity message data stacktrace].each do |field|
61
+ define_method(field) { messages.last&.fetch(field) }
17
62
  end
18
63
  end
19
64
  end
@@ -0,0 +1,16 @@
1
+ require 'subst_attr'
2
+
3
+ module Hubbado
4
+ class Log
5
+ module Controls
6
+ module Logger
7
+ # A logger a spec assigns in place of a class's own, and then asks what the class said.
8
+ # Named here because the substitute is reached by building a mimic of Log::Logger, which
9
+ # is machinery a spec should not have to name.
10
+ def self.example
11
+ SubstAttr::Substitute.build(Log::Logger)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -4,3 +4,4 @@ require_relative 'controls/message'
4
4
  require_relative 'controls/subject'
5
5
 
6
6
  require_relative 'controls/log_handler'
7
+ require_relative 'controls/logger'
@@ -2,17 +2,18 @@ module Hubbado
2
2
  class Log
3
3
  include Dependency
4
4
 
5
- # Ordered, because the level compares against them. `trace` is program flow, a line per
5
+ # Ordered, because the level compares against them. `trace` is program flow, a message per
6
6
  # iteration; `debug` is the completion of a secondary operation, or a detail worth keeping;
7
7
  # `info` is the completion of the principal operation of a class or utility.
8
8
  SEVERITIES = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5, unknown: 6 }.freeze
9
9
  STACKTRACE_SEVERITIES = %i[warn error fatal unknown].freeze
10
10
 
11
11
  LEVEL_VARIABLE = "LOG_LEVEL".freeze
12
+ TAGS_VARIABLE = "LOG_TAGS".freeze
12
13
 
13
- # The one place the environment is read. A command names its level here or not at all,
14
- # and everything downstream is handed the value rather than the variable.
15
- @config = Configuration.new(level: ENV[LEVEL_VARIABLE])
14
+ # The one place the environment is read. A command names its level and tags here or not at
15
+ # all, and everything downstream is handed the value rather than the variable.
16
+ @config = Configuration.new(level: ENV[LEVEL_VARIABLE], tags: ENV[TAGS_VARIABLE])
16
17
 
17
18
  class << self
18
19
  attr_reader :config
@@ -31,8 +32,11 @@ module Hubbado
31
32
  @logger = Logger.new('', loggers)
32
33
  end
33
34
 
34
- def log(*args)
35
- logger.log(*args)
35
+ # The keywords are named rather than swept up with the positionals: a method taking only
36
+ # `*args` accepts no keywords, so Ruby would fold them into a trailing hash and they would
37
+ # arrive as the message's data — which a handler hands to Rollbar as the exception.
38
+ def log(*args, tag: nil, tags: nil)
39
+ logger.log(*args, tag: tag, tags: tags)
36
40
  end
37
41
  end
38
42
 
@@ -0,0 +1,50 @@
1
+ module Hubbado
2
+ class Log
3
+ class Logger
4
+ # What a logger was told rather than what it wrote. Extended onto a mimic of Logger, so a
5
+ # class under test is handed something that answers as a logger and keeps what it was given.
6
+ module Substitute
7
+ Entry = Data.define(:severity, :message, :data)
8
+
9
+ # Everything about what a class said, in order. Named with a severity, only what it said
10
+ # at that one.
11
+ #
12
+ # A severity reaches a logger two ways: as the method, from the generated severity
13
+ # methods, or as #log's first argument. Both are compared as symbols, because #log takes
14
+ # a String as readily and passes on what it was given.
15
+ def logged(severity = nil)
16
+ entries = invocations.map { |invocation| entry(invocation) }
17
+
18
+ return entries if severity.nil?
19
+
20
+ entries.select { |entry| entry.severity == severity.to_s.to_sym }
21
+ end
22
+
23
+ # What a class said, where #logged is everything about it.
24
+ def messages(severity = nil) = logged(severity).map(&:message)
25
+
26
+ # Whether, where #logged and #messages ask what. Without a severity, whether anything was
27
+ # written at all.
28
+ def logged?(severity = nil) = !logged(severity).empty?
29
+
30
+ private
31
+
32
+ def entry(invocation)
33
+ arguments = invocation.arguments
34
+
35
+ Entry.new(
36
+ severity: severity(invocation).to_s.to_sym,
37
+ message: arguments[:msg],
38
+ data: arguments[:data]
39
+ )
40
+ end
41
+
42
+ def severity(invocation)
43
+ return invocation.arguments.fetch(:severity) if invocation.method_name == :log
44
+
45
+ invocation.method_name
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -4,18 +4,23 @@ module Hubbado
4
4
  attr_accessor :log_handlers
5
5
  attr_accessor :subject
6
6
 
7
- def initialize(subject, log_handlers = [], level: nil)
7
+ def initialize(subject, log_handlers = [], level: nil, tags: nil)
8
8
  self.subject = subject
9
9
  self.log_handlers = Array(log_handlers)
10
10
  @level = level
11
+ @tags = tags
11
12
  end
12
13
 
13
14
  # A logger built without one follows the configuration, which is every logger the gem
14
- # builds itself: `Log.configure` names no level, so a class using the Dependency module
15
+ # builds itself: `Log.configure` names neither, so a class using the Dependency module
15
16
  # takes whatever the process was configured for.
16
17
  def level = @level || Log.config.level
17
18
 
18
- def log(severity, msg, data = nil)
19
+ # Named per logger as well as per process, as the level is and as Eventide's log gem has
20
+ # it, so one component can be read without turning up everything around it.
21
+ def tags = @tags.nil? ? Log.config.tags : Tags.parse(@tags)
22
+
23
+ def log(severity, msg, data = nil, tag: nil, tags: nil)
19
24
  unless SEVERITIES.keys.include? severity.to_sym
20
25
  raise ArgumentError, "Unknown serverity #{severity}"
21
26
  end
@@ -24,6 +29,19 @@ module Hubbado
24
29
  # not what may be said, so quietening a logger must not turn a typo into silence.
25
30
  return if SEVERITIES.fetch(severity.to_sym) < SEVERITIES.fetch(level)
26
31
 
32
+ # Singular and plural are both accepted and both kept, following Eventide's log gem,
33
+ # where real call sites use either and occasionally hand an array to the singular one.
34
+ #
35
+ # Named as symbols, because that is what LOG_TAGS is parsed into: a String here would
36
+ # match nothing and its message would go missing with nothing said about it.
37
+ message_tags = (Array(tags) + Array(tag)).map { |name| name.to_s.to_sym }
38
+
39
+ # Both filters have to pass. A tag cannot raise a message above the level, and the level
40
+ # cannot rescue one the list leaves out.
41
+ #
42
+ # `self.` because the `tags:` keyword above shadows the reader.
43
+ return unless self.tags.write?(message_tags)
44
+
27
45
  stacktrace = if data.is_a?(Exception)
28
46
  data.full_message
29
47
  elsif STACKTRACE_SEVERITIES.include?(severity)
@@ -36,8 +54,8 @@ module Hubbado
36
54
  end
37
55
 
38
56
  SEVERITIES.each_key do |severity|
39
- define_method severity do |msg, data = nil|
40
- log severity, msg, data
57
+ define_method severity do |msg, data = nil, tag: nil, tags: nil|
58
+ log severity, msg, data, tag: tag, tags: tags
41
59
  end
42
60
  end
43
61
 
@@ -0,0 +1,82 @@
1
+ module Hubbado
2
+ class Log
3
+ # The operator's list, from LOG_TAGS: an allow-list a message's own tags have to intersect
4
+ # before it is written.
5
+ class Tags
6
+ # Every tag is written, whatever else the list says.
7
+ ALL = :_all
8
+
9
+ # Messages carrying no tag at all are written. Without it, naming any tag silences them.
10
+ UNTAGGED = :_untagged
11
+
12
+ # A message marks itself as written whatever the operator asked for.
13
+ EVERY_MESSAGE = :*
14
+
15
+ EXCLUSION = "-".freeze
16
+
17
+ # Split on commas and nothing else, which is all Eventide's log gem does. Trimming the
18
+ # spaces would be kinder, and would make one LOG_TAGS mean different things in the two gems
19
+ # that share the variable — the divergence this gem cannot afford.
20
+ def self.parse(value)
21
+ return value if value.is_a?(self)
22
+ return new if value.nil?
23
+ return new(value) if value.is_a?(Array)
24
+
25
+ new(value.to_s.split(","))
26
+ end
27
+
28
+ attr_reader :tags
29
+
30
+ def initialize(tags = [])
31
+ @tags = Array(tags).map { |tag| tag.to_s.to_sym }
32
+ end
33
+
34
+ # An exclusion-only list still counts as the operator having named tags, which is what
35
+ # makes `-data` on its own silence everything rather than subtract from everything.
36
+ def any?
37
+ !tags.empty?
38
+ end
39
+
40
+ # Branch for branch from Eventide's log gem, so that a string an operator writes means the
41
+ # same thing in both codebases. That includes the awkward part: `_all` is answered before
42
+ # any exclusion, so `_all,-data` writes `data` messages regardless.
43
+ def write?(message_tags)
44
+ message_tags = Array(message_tags)
45
+
46
+ return true if message_tags.empty? && !any?
47
+ return true if message_tags.include?(EVERY_MESSAGE)
48
+ return true if named?(ALL)
49
+ return true if message_tags.empty? && named?(UNTAGGED)
50
+ return true if !message_tags.empty? && any? && intersect?(message_tags)
51
+
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def named?(tag)
58
+ tags.include?(tag)
59
+ end
60
+
61
+ def intersect?(message_tags)
62
+ return false if message_tags.intersect?(excluded)
63
+
64
+ included.intersect?(message_tags)
65
+ end
66
+
67
+ def included
68
+ @included ||= tags.reject { |tag| excluded?(tag) }
69
+ end
70
+
71
+ def excluded
72
+ @excluded ||= tags
73
+ .select { |tag| excluded?(tag) }
74
+ .map { |tag| tag.to_s.delete_prefix(EXCLUSION).to_sym }
75
+ end
76
+
77
+ def excluded?(tag)
78
+ tag.to_s.start_with?(EXCLUSION)
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/hubbado/log.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  require 'dependency'
2
+ require "hubbado/log/tags"
2
3
  require "hubbado/log/configuration"
3
4
  require "hubbado/log/log"
4
5
  require "hubbado/log/logger"
6
+ require "hubbado/log/logger/substitute"
5
7
  require "hubbado/log/log_handler"
6
8
 
7
9
  module Hubbado
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hubbado-log
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: evt-subst_attr
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: debug
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -81,11 +95,14 @@ files:
81
95
  - lib/hubbado/log/controls/data.rb
82
96
  - lib/hubbado/log/controls/exception.rb
83
97
  - lib/hubbado/log/controls/log_handler.rb
98
+ - lib/hubbado/log/controls/logger.rb
84
99
  - lib/hubbado/log/controls/message.rb
85
100
  - lib/hubbado/log/controls/subject.rb
86
101
  - lib/hubbado/log/log.rb
87
102
  - lib/hubbado/log/log_handler.rb
88
103
  - lib/hubbado/log/logger.rb
104
+ - lib/hubbado/log/logger/substitute.rb
105
+ - lib/hubbado/log/tags.rb
89
106
  homepage: https://www.github.com/hubbado/hubbado-log
90
107
  licenses:
91
108
  - MIT