hubbado-log 1.2.0 → 1.3.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: 49c303fe323be43f901ed8ef3958b87c79296af3a15efb11c9efb8a9b57156aa
4
+ data.tar.gz: 97bc2f947b3a4fc0cfd1845c34d722d6608cf36c1daf853334d3b3b03c4171b5
5
5
  SHA512:
6
- metadata.gz: d1195dd74d23e42ab83a6b93daba36cdd53b5311ee2a1dd19399f1090fe9c2fba4a2b9fe660489a2ba8c85816a3995e495979a7ea3b01c83693a5a2a7dfa0292
7
- data.tar.gz: a4c8a344ae3c7297037890633fbec484f5d45114cbc2b1f76bdf15314d2b48a5b7b17ad35c6601a9f59720b6ea98c41b9c34676a1d01d5953bbba8ba5627271b
6
+ metadata.gz: fde7c3bf0995e34348f47a8441df1b95cf9505c909b08bac8fcc409f994ce8c8eb28cb05343e24b4066977a804434ced09cad3fb36e75908e375d3bddbc81304
7
+ data.tar.gz: 4f4cae84446c3d1bdf8a9ebb2b986f82a558c623af90a3278c62fa67ee8a23a55163129efc787758b5434faf101346e15187c9f112007333b7295cb26cf3648f
data/ChangeLog.md CHANGED
@@ -4,6 +4,48 @@ 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.3.0 - 2026-08-15]
8
+ ## Added
9
+ - Tags, a second filtering axis beside the level. A message names its concern
10
+ with `tag:` or `tags:`, and `LOG_TAGS` decides which tagged messages are
11
+ written. The syntax and semantics are Eventide's log gem's, so a string an
12
+ operator writes means the same thing in both codebases.
13
+ - `config.tags`, set in code the way `config.level` is, and a `tags:` keyword on
14
+ `Logger.new` so one logger can name its own list without the process being
15
+ turned up around it — the escape hatch the level already had.
16
+ - `Controls::LogHandler` records every message it is given in `messages`, answers
17
+ `logged?` with or without a severity, resets with `reset`, and builds a wired
18
+ logger with `.attach` or `.logger`. Five downstream projects had each
19
+ hand-rolled the same thing because the control kept only the most recent; they
20
+ can delete their copy whenever it suits them. The attributes still read the
21
+ most recent, derived from `messages` rather than assigned beside it.
22
+ - `.attach` and `.logger` name `_all` as well as `trace`, so neither the
23
+ configured level nor the configured tag list decides what a spec can read back.
24
+ `.attach` raises a named `ArgumentError` for a class carrying no logger rather
25
+ than failing on nil.
26
+
27
+ ## Changed
28
+ - The test suite sets `LOG_TAGS` rather than defaulting it, so a value left in a
29
+ developer's shell cannot decide which tests can write anything.
30
+
31
+ ## Compatibility
32
+ Nothing that exists breaks. A handler's arguments are unchanged — tags decide
33
+ whether it is called, and are not passed to it. Every handler across the
34
+ consuming repositories was checked and needs no edit.
35
+
36
+ One theoretical change: the severity methods and `Hubbado::Log.log` now name
37
+ `tag:` and `tags:`, so a call site passing its data as bare keywords —
38
+ `logger.info('x', record_id: 7)`, which worked because Ruby folded them into a
39
+ positional hash while the methods accepted none — raises instead. No such call
40
+ site exists in any consuming repository; pass the hash explicitly if one appears.
41
+
42
+ ## Not included
43
+ A tag declared once per component, and tags reaching a log handler. Neither has
44
+ an implementer: nothing tags a message yet, and no handler prints or routes on
45
+ one. Eventide does not pass tags to its own output either — they filter, and
46
+ stop there. Both are worth adding when something needs them, and neither has to
47
+ break a handler to arrive.
48
+
7
49
  # [1.2.0 - 2026-08-14]
8
50
  ## Added
9
51
  - 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,72 @@ 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
+
55
114
  ## Development
56
115
 
57
116
  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.3.0"
4
4
  s.summary = "Lightweight pluggable logging system"
5
5
 
6
6
  s.authors = ["Hubbado Devs"]
@@ -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 = 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, 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
@@ -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
 
@@ -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,4 +1,5 @@
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"
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
@@ -86,6 +86,7 @@ files:
86
86
  - lib/hubbado/log/log.rb
87
87
  - lib/hubbado/log/log_handler.rb
88
88
  - lib/hubbado/log/logger.rb
89
+ - lib/hubbado/log/tags.rb
89
90
  homepage: https://www.github.com/hubbado/hubbado-log
90
91
  licenses:
91
92
  - MIT