semantic_logger 4.18.0 → 5.0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +42 -81
  3. data/Rakefile +10 -3
  4. data/lib/semantic_logger/appender/async.rb +86 -173
  5. data/lib/semantic_logger/appender/cloudwatch_logs.rb +4 -4
  6. data/lib/semantic_logger/appender/elasticsearch.rb +6 -182
  7. data/lib/semantic_logger/appender/elasticsearch_base.rb +212 -0
  8. data/lib/semantic_logger/appender/elasticsearch_http.rb +2 -2
  9. data/lib/semantic_logger/appender/file.rb +20 -4
  10. data/lib/semantic_logger/appender/graylog.rb +2 -2
  11. data/lib/semantic_logger/appender/http.rb +27 -2
  12. data/lib/semantic_logger/appender/io.rb +8 -4
  13. data/lib/semantic_logger/appender/kafka.rb +2 -2
  14. data/lib/semantic_logger/appender/loki.rb +2 -4
  15. data/lib/semantic_logger/appender/mongodb.rb +3 -6
  16. data/lib/semantic_logger/appender/new_relic_logs.rb +2 -2
  17. data/lib/semantic_logger/appender/open_telemetry.rb +8 -6
  18. data/lib/semantic_logger/appender/opensearch.rb +35 -0
  19. data/lib/semantic_logger/appender/rabbitmq.rb +3 -3
  20. data/lib/semantic_logger/appender/sentry_ruby.rb +2 -2
  21. data/lib/semantic_logger/appender/splunk.rb +2 -2
  22. data/lib/semantic_logger/appender/splunk_http.rb +3 -3
  23. data/lib/semantic_logger/appender/syslog.rb +5 -4
  24. data/lib/semantic_logger/appender/tcp.rb +2 -2
  25. data/lib/semantic_logger/appender/udp.rb +2 -2
  26. data/lib/semantic_logger/appender/wrapper.rb +4 -4
  27. data/lib/semantic_logger/appender.rb +30 -19
  28. data/lib/semantic_logger/appenders.rb +26 -5
  29. data/lib/semantic_logger/base.rb +100 -21
  30. data/lib/semantic_logger/concerns/compatibility.rb +2 -2
  31. data/lib/semantic_logger/core_ext/process.rb +34 -0
  32. data/lib/semantic_logger/formatters/base.rb +46 -7
  33. data/lib/semantic_logger/formatters/color.rb +6 -3
  34. data/lib/semantic_logger/formatters/default.rb +6 -4
  35. data/lib/semantic_logger/formatters/ecs.rb +151 -0
  36. data/lib/semantic_logger/formatters/fluentd.rb +15 -4
  37. data/lib/semantic_logger/formatters/json.rb +6 -1
  38. data/lib/semantic_logger/formatters/logfmt.rb +2 -2
  39. data/lib/semantic_logger/formatters/loki.rb +4 -4
  40. data/lib/semantic_logger/formatters/open_telemetry.rb +15 -5
  41. data/lib/semantic_logger/formatters/pattern.rb +235 -0
  42. data/lib/semantic_logger/formatters/raw.rb +2 -2
  43. data/lib/semantic_logger/formatters/signalfx.rb +2 -2
  44. data/lib/semantic_logger/formatters/syslog.rb +14 -3
  45. data/lib/semantic_logger/formatters/syslog_cee.rb +1 -1
  46. data/lib/semantic_logger/formatters.rb +2 -0
  47. data/lib/semantic_logger/log.rb +18 -4
  48. data/lib/semantic_logger/logger.rb +2 -2
  49. data/lib/semantic_logger/metric/new_relic.rb +2 -2
  50. data/lib/semantic_logger/metric/signalfx.rb +2 -2
  51. data/lib/semantic_logger/metric/statsd.rb +2 -2
  52. data/lib/semantic_logger/processor.rb +21 -0
  53. data/lib/semantic_logger/queue_processor.rb +369 -0
  54. data/lib/semantic_logger/reporters/minitest.rb +4 -4
  55. data/lib/semantic_logger/semantic_logger.rb +103 -11
  56. data/lib/semantic_logger/subscriber.rb +15 -2
  57. data/lib/semantic_logger/sync_processor.rb +25 -3
  58. data/lib/semantic_logger/test/capture_log_events.rb +2 -2
  59. data/lib/semantic_logger/test/minitest.rb +8 -4
  60. data/lib/semantic_logger/test/rspec.rb +249 -0
  61. data/lib/semantic_logger/utils.rb +83 -4
  62. data/lib/semantic_logger/version.rb +1 -1
  63. data/lib/semantic_logger.rb +9 -0
  64. metadata +17 -8
  65. data/lib/semantic_logger/appender/async_batch.rb +0 -93
@@ -29,7 +29,11 @@ module SemanticLogger
29
29
  formatter: SemanticLogger::Formatters::OpenTelemetry.new,
30
30
  metrics: true,
31
31
  **args,
32
- &block)
32
+ &)
33
+ # Base#initialize (via Subscriber) overwrites @name with the class name,
34
+ # so call super first and then assign our own attributes.
35
+ super(formatter: formatter, metrics: metrics, **args, &)
36
+
33
37
  @name = name
34
38
  @version = version
35
39
  @provider = ::OpenTelemetry.logger_provider
@@ -38,8 +42,6 @@ module SemanticLogger
38
42
  # Capture the current Open Telemetry context when a log entry is captured.
39
43
  # Prevents duplicate subscribers as long as it is from a constant.
40
44
  SemanticLogger.on_log(CAPTURE_CONTEXT)
41
-
42
- super(formatter: formatter, metrics: metrics, **args, &block)
43
45
  end
44
46
 
45
47
  def log(log)
@@ -57,7 +59,7 @@ module SemanticLogger
57
59
  timestamp: time,
58
60
  body: body.transform_keys!(&:to_s),
59
61
  attributes: payload,
60
- context: log.context[:open_telemetry] || ::OpenTelemetry::Context.current
62
+ context: (log.context && log.context[:open_telemetry]) || ::OpenTelemetry::Context.current
61
63
  )
62
64
  true
63
65
  end
@@ -69,7 +71,7 @@ module SemanticLogger
69
71
  @provider.force_flush if @provider.respond_to?(:force_flush)
70
72
  rescue StandardError => e
71
73
  # Swallow to avoid noisy shutdown exceptions.
72
- SemanticLogger.logger.warn("Flush failed: #{e.class}: #{e.message}")
74
+ SemanticLogger::Processor.logger.warn("Flush failed: #{e.class}: #{e.message}")
73
75
  end
74
76
 
75
77
  # Close the appender and release resources.
@@ -78,7 +80,7 @@ module SemanticLogger
78
80
 
79
81
  @provider.shutdown if @provider.respond_to?(:shutdown)
80
82
  rescue StandardError => e
81
- SemanticLogger.logger.warn("Shutdown failed: #{e.class}: #{e.message}")
83
+ SemanticLogger::Processor.logger.warn("Shutdown failed: #{e.class}: #{e.message}")
82
84
  ensure
83
85
  @provider = nil
84
86
  end
@@ -0,0 +1,35 @@
1
+ begin
2
+ require "opensearch-ruby"
3
+ rescue LoadError
4
+ raise LoadError,
5
+ 'Gem opensearch-ruby is required for logging to OpenSearch. Please add the gem "opensearch-ruby" to your Gemfile.'
6
+ end
7
+
8
+ require "semantic_logger/appender/elasticsearch_base"
9
+
10
+ # Forward all log messages to OpenSearch (e.g. AWS OpenSearch).
11
+ #
12
+ # OpenSearch is a fork of Elasticsearch 7.10. The client API and bulk
13
+ # protocol are identical, so this appender reuses the same pipeline as the
14
+ # Elasticsearch appender; only the backing client gem differs. Use this
15
+ # appender (with the `opensearch-ruby` gem) instead of `:elasticsearch`
16
+ # when talking to an OpenSearch server, since modern `elasticsearch` gems
17
+ # reject non-Elasticsearch servers via a product check.
18
+ #
19
+ # Example:
20
+ #
21
+ # SemanticLogger.add_appender(
22
+ # appender: :opensearch,
23
+ # url: 'http://localhost:9200'
24
+ # )
25
+ module SemanticLogger
26
+ module Appender
27
+ class Opensearch < ElasticsearchBase
28
+ private
29
+
30
+ def client_class
31
+ ::OpenSearch::Client
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,7 +12,7 @@ end
12
12
  # appender: :rabbitmq,
13
13
  #
14
14
  # # Name of the queue in RabbitMQ where to publish the logs. This queue will be bound to "amqp.direct" exchange.
15
- # queue: 'semantic_logger',
15
+ # queue_name: 'semantic_logger',
16
16
  #
17
17
  # # This host will be used for RabbitMQ connection.
18
18
  # # NOTE this is different than :host option which is used by the logger directly.
@@ -82,13 +82,13 @@ module SemanticLogger
82
82
  # more parameters supported by Bunny: http://rubybunny.info/articles/connecting.html
83
83
  def initialize(queue_name: "semantic_logger", rabbitmq_host: nil,
84
84
  level: nil, formatter: nil, filter: nil, application: nil, environment: nil, host: nil, metrics: true,
85
- **args, &block)
85
+ **args, &)
86
86
  @queue_name = queue_name
87
87
  @rabbitmq_args = args.dup
88
88
  @rabbitmq_args[:host] = rabbitmq_host
89
89
  @rabbitmq_args[:logger] = logger
90
90
 
91
- super(level: level, formatter: formatter, filter: filter, application: application, environment: environment, host: host, metrics: metrics, &block)
91
+ super(level: level, formatter: formatter, filter: filter, application: application, environment: environment, host: host, metrics: metrics, &)
92
92
  reopen
93
93
  end
94
94
 
@@ -133,10 +133,10 @@ module SemanticLogger
133
133
  #
134
134
  def extract_tags!(context)
135
135
  named_tags = context.delete(:named_tags) || {}
136
- named_tags = named_tags.map { |k, v| [k.to_s, v.to_s] }.to_h
136
+ named_tags = named_tags.to_h { |k, v| [k.to_s, v.to_s] }
137
137
  tags = context.delete(:tags)
138
138
  named_tags.merge!("tag" => tags.join(", ")) { |_, v1, v2| "#{v1}, #{v2}" } if tags
139
- named_tags.map { |k, v| [k[0...32], v[0...256]] }.to_h
139
+ named_tags.to_h { |k, v| [k[0...32], v[0...256]] }
140
140
  end
141
141
  end
142
142
  end
@@ -89,11 +89,11 @@ module SemanticLogger
89
89
  # regular expression. All other messages will be ignored.
90
90
  # Proc: Only include log messages where the supplied Proc returns true
91
91
  # The Proc must return true or false.
92
- def initialize(index: "main", source_type: nil, **args, &block)
92
+ def initialize(index: "main", source_type: nil, **args, &)
93
93
  @index = index
94
94
  @source_type = source_type
95
95
 
96
- super(**args, &block)
96
+ super(**args, &)
97
97
  reopen
98
98
  end
99
99
 
@@ -73,11 +73,11 @@ module SemanticLogger
73
73
  index: nil,
74
74
  compress: true,
75
75
  **args,
76
- &block)
76
+ &)
77
77
  @source_type = source_type
78
78
  @index = index
79
79
 
80
- super(compress: compress, **args, &block)
80
+ super(compress: compress, **args, &)
81
81
 
82
82
  # Put splunk auth token in the header of every HTTP post.
83
83
  @header["Authorization"] = "Splunk #{token}"
@@ -98,7 +98,7 @@ module SemanticLogger
98
98
  }
99
99
  message[:sourcetype] = source_type if source_type
100
100
  message[:index] = index if index
101
- message.to_json
101
+ Utils.to_json(message)
102
102
  end
103
103
  end
104
104
  end
@@ -130,7 +130,7 @@ module SemanticLogger
130
130
  options: ::Syslog::LOG_PID | ::Syslog::LOG_CONS,
131
131
  tcp_client: {},
132
132
  **args,
133
- &block)
133
+ &)
134
134
  @options = options
135
135
  @facility = facility
136
136
  @max_size = max_size
@@ -165,7 +165,7 @@ module SemanticLogger
165
165
  end
166
166
  end
167
167
 
168
- super(**args, &block)
168
+ super(**args, &)
169
169
  reopen
170
170
  end
171
171
 
@@ -213,8 +213,9 @@ module SemanticLogger
213
213
  # Returns [SemanticLogger::Formatters::Base] default formatter for this Appender depending on the protocal selected
214
214
  def default_formatter
215
215
  if protocol == :syslog
216
- # Format is text output without the time
217
- SemanticLogger::Formatters::Default.new(time_format: :notime)
216
+ # Format is text output without the time.
217
+ # Escape control characters so untrusted log data cannot forge syslog entries.
218
+ SemanticLogger::Formatters::Default.new(time_format: :notime, escape_control_chars: true)
218
219
  else
219
220
  SemanticLogger::Formatters::Syslog.new(facility: facility, level_map: level_map, max_size: max_size)
220
221
  end
@@ -184,7 +184,7 @@ module SemanticLogger
184
184
  # )
185
185
  def initialize(separator: "\n",
186
186
  level: nil, formatter: nil, filter: nil, application: nil, environment: nil, host: nil, metrics: false,
187
- **tcp_client_args, &block)
187
+ **tcp_client_args, &)
188
188
  @separator = separator
189
189
  @tcp_client_args = tcp_client_args
190
190
 
@@ -192,7 +192,7 @@ module SemanticLogger
192
192
  Net::TCPClient.logger = logger
193
193
  Net::TCPClient.logger.name = "Net::TCPClient"
194
194
 
195
- super(level: level, formatter: formatter, filter: filter, application: application, environment: environment, host: host, metrics: metrics, &block)
195
+ super(level: level, formatter: formatter, filter: filter, application: application, environment: environment, host: host, metrics: metrics, &)
196
196
  reopen
197
197
  end
198
198
 
@@ -61,11 +61,11 @@ module SemanticLogger
61
61
  # appender: :udp,
62
62
  # server: 'server:3300'
63
63
  # )
64
- def initialize(server:, udp_flags: 0, metrics: true, **args, &block)
64
+ def initialize(server:, udp_flags: 0, metrics: true, **args, &)
65
65
  @server = server
66
66
  @udp_flags = udp_flags
67
67
 
68
- super(metrics: metrics, **args, &block)
68
+ super(metrics: metrics, **args, &)
69
69
  reopen
70
70
  end
71
71
 
@@ -38,17 +38,17 @@ module SemanticLogger
38
38
  # logger.info('Hello World', some: :payload)
39
39
  #
40
40
  # Install the `rails_semantic_logger` gem to replace the Rails logger with Semantic Logger.
41
- def initialize(logger:, **args, &block)
41
+ def initialize(logger:, **args, &)
42
42
  @logger = logger
43
43
 
44
44
  # Check if the custom appender responds to all the log levels. For example Ruby ::Logger
45
- does_not_implement = LEVELS[1..-1].find { |i| !@logger.respond_to?(i) }
45
+ does_not_implement = LEVELS[1..].find { |i| !@logger.respond_to?(i) }
46
46
  if does_not_implement
47
47
  raise(ArgumentError,
48
- "Supplied logger does not implement:#{does_not_implement}. It must implement all of #{LEVELS[1..-1].inspect}")
48
+ "Supplied logger does not implement:#{does_not_implement}. It must implement all of #{LEVELS[1..].inspect}")
49
49
  end
50
50
 
51
- super(**args, &block)
51
+ super(**args, &)
52
52
  end
53
53
 
54
54
  # Pass log calls to the underlying Rails, log4j or Ruby logger
@@ -2,10 +2,10 @@ module SemanticLogger
2
2
  module Appender
3
3
  # @formatter:off
4
4
  autoload :Async, "semantic_logger/appender/async"
5
- autoload :AsyncBatch, "semantic_logger/appender/async_batch"
6
5
  autoload :Bugsnag, "semantic_logger/appender/bugsnag"
7
6
  autoload :CloudwatchLogs, "semantic_logger/appender/cloudwatch_logs"
8
7
  autoload :Elasticsearch, "semantic_logger/appender/elasticsearch"
8
+ autoload :ElasticsearchBase, "semantic_logger/appender/elasticsearch_base"
9
9
  autoload :ElasticsearchHttp, "semantic_logger/appender/elasticsearch_http"
10
10
  autoload :File, "semantic_logger/appender/file"
11
11
  autoload :Graylog, "semantic_logger/appender/graylog"
@@ -18,6 +18,7 @@ module SemanticLogger
18
18
  autoload :MongoDB, "semantic_logger/appender/mongodb"
19
19
  autoload :NewRelic, "semantic_logger/appender/new_relic"
20
20
  autoload :NewRelicLogs, "semantic_logger/appender/new_relic_logs"
21
+ autoload :Opensearch, "semantic_logger/appender/opensearch"
21
22
  autoload :OpenTelemetry, "semantic_logger/appender/open_telemetry"
22
23
  autoload :Rabbitmq, "semantic_logger/appender/rabbitmq"
23
24
  autoload :Splunk, "semantic_logger/appender/splunk"
@@ -34,27 +35,37 @@ module SemanticLogger
34
35
  def self.factory(async: false, batch: nil,
35
36
  max_queue_size: 10_000, lag_check_interval: 1_000, lag_threshold_s: 30,
36
37
  batch_size: 300, batch_seconds: 5,
38
+ non_blocking: false, dropped_message_report_seconds: 30,
39
+ async_max_retries: 100,
37
40
  **args,
38
- &block)
39
- appender = build(**args, &block)
41
+ &)
42
+ appender = build(**args, &)
40
43
 
41
- # If appender implements #batch, then it should use the batch proxy by default.
42
- batch = true if batch.nil? && appender.respond_to?(:batch)
44
+ # If appender implements #batch, then it should use the batch proxy by default,
45
+ # unless the appender opts out of batching by default (e.g. the HTTP appender).
46
+ batch = true if batch.nil? && appender.respond_to?(:batch) && appender.batch_by_default?
43
47
 
44
48
  if batch == true
45
- Appender::AsyncBatch.new(
46
- appender: appender,
47
- max_queue_size: max_queue_size,
48
- lag_threshold_s: lag_threshold_s,
49
- batch_size: batch_size,
50
- batch_seconds: batch_seconds
49
+ Appender::Async.new(
50
+ appender: appender,
51
+ batch: true,
52
+ max_queue_size: max_queue_size,
53
+ lag_threshold_s: lag_threshold_s,
54
+ batch_size: batch_size,
55
+ batch_seconds: batch_seconds,
56
+ non_blocking: non_blocking,
57
+ dropped_message_report_seconds: dropped_message_report_seconds,
58
+ async_max_retries: async_max_retries
51
59
  )
52
60
  elsif async == true
53
61
  Appender::Async.new(
54
- appender: appender,
55
- max_queue_size: max_queue_size,
56
- lag_check_interval: lag_check_interval,
57
- lag_threshold_s: lag_threshold_s
62
+ appender: appender,
63
+ max_queue_size: max_queue_size,
64
+ lag_check_interval: lag_check_interval,
65
+ lag_threshold_s: lag_threshold_s,
66
+ non_blocking: non_blocking,
67
+ dropped_message_report_seconds: dropped_message_report_seconds,
68
+ async_max_retries: async_max_retries
58
69
  )
59
70
  else
60
71
  appender
@@ -62,13 +73,13 @@ module SemanticLogger
62
73
  end
63
74
 
64
75
  # Returns [Subscriber] instance from the supplied options.
65
- def self.build(io: nil, file_name: nil, appender: nil, metric: nil, logger: nil, **args, &block)
76
+ def self.build(io: nil, file_name: nil, appender: nil, metric: nil, logger: nil, **args, &)
66
77
  if file_name
67
- SemanticLogger::Appender::File.new(file_name, **args, &block)
78
+ SemanticLogger::Appender::File.new(file_name, **args, &)
68
79
  elsif io
69
- SemanticLogger::Appender::IO.new(io, **args, &block)
80
+ SemanticLogger::Appender::IO.new(io, **args, &)
70
81
  elsif logger
71
- SemanticLogger::Appender::Wrapper.new(logger: logger, **args, &block)
82
+ SemanticLogger::Appender::Wrapper.new(logger: logger, **args, &)
72
83
  elsif appender
73
84
  if appender.is_a?(Symbol)
74
85
  SemanticLogger::Utils.constantize_symbol(appender).new(**args)
@@ -9,11 +9,12 @@ module SemanticLogger
9
9
  super()
10
10
  end
11
11
 
12
- def add(**args, &block)
13
- appender = SemanticLogger::Appender.factory(**args, &block)
12
+ def add(**args, &)
13
+ appender = SemanticLogger::Appender.factory(**args, &)
14
14
 
15
- if appender.respond_to?(:console_output?) && appender.console_output? && console_output?
16
- logger.warn "Ignoring attempt to add a second console appender: #{appender.class.name} since it would result in duplicate console output."
15
+ stream = appender.respond_to?(:console_stream) && appender.console_stream
16
+ if stream && console_streams.include?(stream)
17
+ logger.warn "Ignoring attempt to add a second #{stream} console appender since it would result in duplicate console output."
17
18
  return
18
19
  end
19
20
 
@@ -21,10 +22,30 @@ module SemanticLogger
21
22
  appender
22
23
  end
23
24
 
25
+ # The console streams (:stdout and/or :stderr) already being written to by the existing appenders.
26
+ def console_streams
27
+ filter_map { |appender| appender.console_stream if appender.respond_to?(:console_stream) }
28
+ end
29
+
24
30
  # Whether any of the existing appenders already output to the console?
25
31
  # I.e. Writes to stdout or stderr.
26
32
  def console_output?
27
- any? { |appender| appender.respond_to?(:console_output?) && appender.console_output? }
33
+ console_streams.any?
34
+ end
35
+
36
+ # Returns [Array<Hash>] operational statistics for each appender.
37
+ #
38
+ # Appenders that run asynchronously (see SemanticLogger::Appender::Async#stats) report
39
+ # their queue size and processed/dropped counts. Appenders that log inline on the
40
+ # processor thread report only their name with `async: false`.
41
+ def stats
42
+ map do |appender|
43
+ if appender.respond_to?(:stats)
44
+ appender.stats
45
+ else
46
+ {name: appender.name, async: false}
47
+ end
48
+ end
28
49
  end
29
50
 
30
51
  def log(log)
@@ -198,7 +198,18 @@ module SemanticLogger
198
198
  # to:
199
199
  # `logger.tagged('first', 'more', 'other')`
200
200
  # - For better performance with clean tags, see `SemanticLogger.tagged`.
201
+ #
202
+ # Child logger:
203
+ # - When called *without* a block, returns a new logger instance that permanently
204
+ # carries the supplied tags ("instance tags"). Those tags are added to every log
205
+ # entry emitted by the returned logger, and _only_ that logger, even across threads.
206
+ # Unlike the block form they are not pushed onto the thread, so other loggers are
207
+ # unaffected. This is the recommended way to bind a logger to an object's identity:
208
+ # logger = SemanticLogger['Cart'].tagged(cart_id: cart.id)
201
209
  def tagged(*tags)
210
+ # No block: build a child logger that carries these tags on every entry.
211
+ return tagged_child(tags) unless block_given?
212
+
202
213
  block = -> { yield(self) }
203
214
  # Allow named tags to be passed into the logger
204
215
  # Rails::Rack::Logger passes logs as an array with a single argument
@@ -226,6 +237,10 @@ module SemanticLogger
226
237
  SemanticLogger.named_tags
227
238
  end
228
239
 
240
+ # Tags permanently bound to this logger instance via `#tagged` (without a block).
241
+ # Empty for a regular (non-child) logger.
242
+ attr_reader :instance_tags, :instance_named_tags
243
+
229
244
  # Returns the list of tags pushed after flattening them out and removing blanks
230
245
  #
231
246
  # Note:
@@ -244,13 +259,13 @@ module SemanticLogger
244
259
  end
245
260
 
246
261
  # :nodoc:
247
- def silence(new_level = :error, &block)
248
- SemanticLogger.silence(new_level, &block)
262
+ def silence(new_level = :error, &)
263
+ SemanticLogger.silence(new_level, &)
249
264
  end
250
265
 
251
266
  # :nodoc:
252
- def fast_tag(tag, &block)
253
- SemanticLogger.fast_tag(tag, &block)
267
+ def fast_tag(tag, &)
268
+ SemanticLogger.fast_tag(tag, &)
254
269
  end
255
270
 
256
271
  # Write log data to underlying data storage
@@ -263,6 +278,11 @@ module SemanticLogger
263
278
  meets_log_level?(log) && !filtered?(log)
264
279
  end
265
280
 
281
+ protected
282
+
283
+ # Only assigned to when building a child logger via `#tagged` (see #tagged_child).
284
+ attr_writer :instance_tags, :instance_named_tags
285
+
266
286
  private
267
287
 
268
288
  # Initializer for Abstract Class SemanticLogger::Base
@@ -294,8 +314,13 @@ module SemanticLogger
294
314
  raise ":filter must be a Regexp, Proc, or implement :call"
295
315
  end
296
316
 
297
- @filter = filter.is_a?(Regexp) ? filter.freeze : filter
298
- @name = klass.is_a?(String) ? klass : klass.name
317
+ @filter = filter.is_a?(Regexp) ? filter.freeze : filter
318
+ @name = klass.is_a?(String) ? klass : klass.name
319
+ # `[].freeze` / `{}.freeze` compile to `opt_ary_freeze` / `opt_hash_freeze`, which return
320
+ # a shared frozen singleton. Every logger points at the same two objects, so these default
321
+ # (empty) instance tags allocate no garbage. Child loggers replace them via #tagged_child.
322
+ @instance_tags = [].freeze
323
+ @instance_named_tags = {}.freeze
299
324
  if level.nil?
300
325
  # Allow the global default level to determine this loggers log level
301
326
  @level_index = nil
@@ -324,13 +349,59 @@ module SemanticLogger
324
349
  (level_index <= (log.level_index || 0))
325
350
  end
326
351
 
352
+ # Build a new Log entry, layering this logger's instance tags on top of the
353
+ # current thread context.
354
+ def new_log(level, index = nil)
355
+ apply_instance_tags(Log.new(name, level, index))
356
+ end
357
+
358
+ # Merge this logger's instance tags into the supplied Log entry.
359
+ #
360
+ # Composition (mirrors the per-logger merge rule proposed in PR #301):
361
+ # - Positional: thread tags first, then this logger's instance tags appended.
362
+ # - Named: instance named tags merged on top of the thread named tags, so on a
363
+ # key conflict the logger's own identity wins (it is the more specific value).
364
+ # Only this logger's entries are affected; the thread context is left untouched.
365
+ def apply_instance_tags(log)
366
+ unless @instance_tags.empty?
367
+ log.tags = log.tags.empty? ? @instance_tags.dup : log.tags + @instance_tags
368
+ end
369
+ unless @instance_named_tags.empty?
370
+ log.named_tags = log.named_tags.empty? ? @instance_named_tags.dup : log.named_tags.merge(@instance_named_tags)
371
+ end
372
+ log
373
+ end
374
+
375
+ # Returns a copy of this logger that permanently carries the supplied tags.
376
+ # Positional (string) and named (Hash) tags may be mixed; they are merged on
377
+ # top of any instance tags this logger already carries.
378
+ def tagged_child(tags)
379
+ positional = @instance_tags.dup
380
+ named = @instance_named_tags.dup
381
+ tags.flatten.each do |tag|
382
+ case tag
383
+ when Hash
384
+ named = named.merge(tag)
385
+ when nil, ""
386
+ # Ignore blanks for Rails compatibility, matching the block form.
387
+ else
388
+ positional << tag.to_s
389
+ end
390
+ end
391
+
392
+ child = clone
393
+ child.instance_tags = positional.freeze
394
+ child.instance_named_tags = named.freeze
395
+ child
396
+ end
397
+
327
398
  # Log message at the specified level
328
- def log_internal(level, index, message = nil, payload = nil, exception = nil)
399
+ def log_internal(level, index, message = nil, payload = nil, exception = nil, &block)
329
400
  # Handle variable number of arguments by detecting exception object and payload hash.
330
401
  if exception.nil? && payload.nil? && message.respond_to?(:backtrace) && message.respond_to?(:message)
331
402
  exception = message
332
403
  message = nil
333
- elsif exception.nil? && payload && payload.respond_to?(:backtrace) && payload.respond_to?(:message)
404
+ elsif exception.nil? && payload.respond_to?(:backtrace) && payload.respond_to?(:message)
334
405
  exception = payload
335
406
  payload = nil
336
407
  elsif payload && !payload.is_a?(Hash)
@@ -338,12 +409,12 @@ module SemanticLogger
338
409
  payload = nil
339
410
  end
340
411
 
341
- log = Log.new(name, level, index)
412
+ log = new_log(level, index)
342
413
  should_log =
343
414
  if exception.nil? && payload.nil? && message.is_a?(Hash)
344
415
  # All arguments as a hash in the message.
345
416
  log.assign(**log.extract_arguments(message))
346
- elsif exception.nil? && message && payload && payload.is_a?(Hash)
417
+ elsif exception.nil? && message && payload.is_a?(Hash)
347
418
  # Message supplied along with a hash with the remaining arguments.
348
419
  log.assign(**log.extract_arguments(payload, message))
349
420
  else
@@ -352,20 +423,28 @@ module SemanticLogger
352
423
  end
353
424
 
354
425
  # Add result of block to message or payload if not nil
355
- if block_given?
356
- result = yield(log)
357
- case result
358
- when String
359
- log.message = log.message.nil? ? result : "#{log.message} -- #{result}"
360
- when Hash
361
- log.assign_hash(result)
362
- end
363
- end
426
+ apply_block_result(log, &block) if block
364
427
 
365
428
  # Log level may change during assign due to :on_exception_level
366
429
  self.log(log) if should_log && should_log?(log)
367
430
  end
368
431
 
432
+ # Merge the result of a logging block into the log entry.
433
+ #
434
+ # Zero-arity blocks (e.g. `logger.info { "msg" }` or a zero-arity lambda) are called without
435
+ # arguments. Lambdas enforce their arity, so yielding the log to a zero-arity lambda would
436
+ # raise ArgumentError. Blocks that accept an argument still receive the log so they can read
437
+ # or mutate it.
438
+ def apply_block_result(log, &block)
439
+ result = block.arity.zero? ? block.call : block.call(log)
440
+ case result
441
+ when String
442
+ log.message = log.message.nil? ? result : "#{log.message} -- #{result}"
443
+ when Hash
444
+ log.assign_hash(result)
445
+ end
446
+ end
447
+
369
448
  # Measure the supplied block and log the message
370
449
  def measure_internal(level, index, message, params)
371
450
  exception = nil
@@ -389,7 +468,7 @@ module SemanticLogger
389
468
  exception = e
390
469
  ensure
391
470
  # Must use ensure block otherwise a `return` in the yield above will skip the log entry
392
- log = Log.new(name, level, index)
471
+ log = new_log(level, index)
393
472
  exception ||= params[:exception]
394
473
  message = params[:message] if params[:message]
395
474
  duration =
@@ -437,7 +516,7 @@ module SemanticLogger
437
516
  rescue Exception => e
438
517
  exception = e
439
518
  ensure
440
- log = Log.new(name, level, index)
519
+ log = new_log(level, index)
441
520
  # May return false due to elastic logging
442
521
  should_log = log.assign(
443
522
  message: message,
@@ -37,11 +37,11 @@ module SemanticLogger
37
37
  end
38
38
 
39
39
  # :nodoc:
40
- def add(severity, message = nil, progname = nil, &block)
40
+ def add(severity, message = nil, progname = nil, &)
41
41
  index = Levels.index(severity)
42
42
  if level_index <= index
43
43
  level = Levels.level(index)
44
- log_internal(level, index, message, progname, &block)
44
+ log_internal(level, index, message, progname, &)
45
45
  true
46
46
  else
47
47
  false
@@ -0,0 +1,34 @@
1
+ module SemanticLogger
2
+ module CoreExt
3
+ # Reopen all appenders in the child process after a fork.
4
+ #
5
+ # Prepended onto `Process.singleton_class` when Semantic Logger is loaded.
6
+ # Enabled by default; opt out with `SemanticLogger.reopen_on_fork = false`.
7
+ #
8
+ # `Process._fork` (Ruby 3.1+) is the single method that every fork path routes
9
+ # through (`Kernel#fork`, `Process.fork`, `IO.popen`, `Kernel#system`, and
10
+ # backticks), so overriding it covers them all with one hook.
11
+ #
12
+ # Note: both `_fork` and `daemon` must be overridden to reliably restart the
13
+ # appender thread, since `Process.daemon` does not route through `_fork`
14
+ # (https://bugs.ruby-lang.org/issues/18911). Do not collapse this down to only
15
+ # `_fork`.
16
+ module Process
17
+ # `_fork` runs in both the parent and the child. It returns 0 in the child
18
+ # and the child's pid in the parent, so only reopen in the child. Reopening
19
+ # in the parent would needlessly recreate its queue and worker thread,
20
+ # risking the loss of messages still on the queue.
21
+ def _fork
22
+ child_pid = super
23
+ SemanticLogger.reopen if child_pid.zero? && SemanticLogger.reopen_on_fork?
24
+ child_pid
25
+ end
26
+
27
+ # `Process.daemon` does not route through `_fork`, so reopen explicitly. Once
28
+ # it returns, the caller is running as the daemon (child) process.
29
+ def daemon(...)
30
+ super.tap { SemanticLogger.reopen if SemanticLogger.reopen_on_fork? }
31
+ end
32
+ end
33
+ end
34
+ end