File: context_logger.rb

package info (click to toggle)
ruby-lumberjack 2.0.4-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 956 kB
  • sloc: ruby: 7,957; makefile: 2
file content (621 lines) | stat: -rw-r--r-- 22,417 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
# frozen_string_literal: true

require_relative "context_locals"
require_relative "io_compatibility"
require_relative "severity"

module Lumberjack
  # ContextLogger provides a logging interface with support for contextual attributes,
  # level management, and program name scoping. This module is included by Logger
  # and ForkedLogger to provide a common API for structured logging.
  #
  # Key features include:
  # - Context-aware attribute management with tag/untag methods
  # - Scoped logging levels and program names
  # - Compatibility with Ruby's standard Logger API
  # - Support for forking isolated logger contexts
  #
  # @see Lumberjack::Logger
  # @see Lumberjack::ForkedLogger
  # @see Lumberjack::Context
  module ContextLogger
    # Constant used for setting trace log level.
    TRACE = Severity::TRACE

    LEADING_OR_TRAILING_WHITESPACE = /(?:\A\s)|(?:\s\z)/

    class << self
      def included(base)
        base.include(ContextLocals) unless base.include?(ContextLocals)
        base.include(IOCompatibility) unless base.include?(IOCompatibility)
      end
    end

    # Get the level of severity of entries that are logged. Entries with a lower
    # severity level will be ignored.
    #
    # @return [Integer] The severity level.
    def level
      current_context&.level || default_context&.level
    end

    alias_method :sev_threshold, :level

    # Set the log level using either an integer level like Logger::INFO or a label like
    # :info or "info"
    #
    # @param value [Integer, Symbol, String] The severity level.
    # @return [void]
    def level=(value)
      value = Severity.coerce(value) unless value.nil?

      ctx = current_context
      ctx.level = value if ctx
    end

    alias_method :sev_threshold=, :level=

    # Adjust the log level during the block execution for the current Fiber only.
    #
    # @param severity [Integer, Symbol, String] The severity level.
    # @return [Object] The result of the block.
    def with_level(severity, &block)
      context do |ctx|
        ctx.level = severity
        block.call(ctx)
      end
    end

    # Set the logger progname for the current context. This is the name of the program that is logging.
    #
    # @param value [String, nil]
    # @return [void]
    def progname=(value)
      value = value&.to_s&.freeze

      ctx = current_context
      ctx.progname = value if ctx
    end

    # Get the current progname.
    #
    # @return [String, nil]
    def progname
      current_context&.progname || default_context&.progname
    end

    # Set the logger progname for the duration of the block.
    #
    # @yield [Object] The block to execute with the program name set.
    # @param value [String] The program name to use.
    # @return [Object] The result of the block.
    def with_progname(value, &block)
      context do |ctx|
        ctx.progname = value
        block.call(ctx)
      end
    end

    # Get the default severity used when writing log messages directly to a stream.
    #
    # @return [Integer] The default severity level.
    def default_severity
      current_context&.default_severity || default_context&.default_severity || Logger::UNKNOWN
    end

    # Set the default severity used when writing log messages directly to a stream
    # for the current context.
    #
    # @param value [Integer, Symbol, String] The default severity level.
    # @return [void]
    def default_severity=(value)
      ctx = current_context
      ctx.default_severity = value if ctx
    end

    # ::Logger compatible method to add a log entry.
    #
    # @param severity [Integer, Symbol, String] The severity of the message.
    # @param message_or_progname_or_attributes [Object] The message to log, progname, or attributes.
    # @param progname_or_attributes [String, Hash] The name of the program or attributes.
    # @return [true]
    def add(severity, message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      # This convoluted logic is to have API compatibility with ::Logger#add.
      severity ||= Logger::UNKNOWN
      if message_or_progname_or_attributes.nil? && !progname_or_attributes.is_a?(Hash)
        message_or_progname_or_attributes = progname_or_attributes
        progname_or_attributes = nil
      end
      call_add_entry(severity, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    alias_method :log, :add

    # Log a +FATAL+ message. The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def fatal(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::FATAL, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +FATAL+ messages are being logged.
    #
    # @return [Boolean]
    def fatal?
      level <= Logger::FATAL
    end

    # Set the log level to fatal.
    #
    # @return [void]
    def fatal!
      self.level = Logger::FATAL
    end

    # Log an +ERROR+ message. The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def error(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::ERROR, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +ERROR+ messages are being logged.
    #
    # @return [Boolean]
    def error?
      level <= Logger::ERROR
    end

    # Set the log level to error.
    #
    # @return [void]
    def error!
      self.level = Logger::ERROR
    end

    # Log a +WARN+ message. The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def warn(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::WARN, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +WARN+ messages are being logged.
    #
    # @return [Boolean]
    def warn?
      level <= Logger::WARN
    end

    # Set the log level to warn.
    #
    # @return [void]
    def warn!
      self.level = Logger::WARN
    end

    # Log an +INFO+ message. The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def info(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::INFO, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +INFO+ messages are being logged.
    #
    # @return [Boolean]
    def info?
      level <= Logger::INFO
    end

    # Set the log level to info.
    #
    # @return [void]
    def info!
      self.level = Logger::INFO
    end

    # Log a +DEBUG+ message. The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def debug(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::DEBUG, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +DEBUG+ messages are being logged.
    #
    # @return [Boolean]
    def debug?
      level <= Logger::DEBUG
    end

    # Set the log level to debug.
    #
    # @return [void]
    def debug!
      self.level = Logger::DEBUG
    end

    # Log a +TRACE+ message. The message can be passed in either the +message+ argument or in a block.
    # Trace logs are a level lower than debug and are generally used to log code execution paths for
    # low level debugging.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [true]
    def trace(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(TRACE, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Return +true+ if +TRACE+ messages are being logged.
    #
    # @return [Boolean]
    def trace?
      level <= TRACE
    end

    # Set the log level to trace.
    #
    # @return [void]
    def trace!
      self.level = TRACE
    end

    # Log a message when the severity is not known. Unknown messages will always appear in the log.
    # The message can be passed in either the +message+ argument or in a block.
    #
    # @param message_or_progname_or_attributes [Object] The message to log or progname
    #   if the message is passed in a block.
    # @param progname_or_attributes [String, Hash] The name of the program that is logging the message or attributes
    #   if the message is passed in a block.
    # @return [void]
    def unknown(message_or_progname_or_attributes = nil, progname_or_attributes = nil, &block)
      call_add_entry(Logger::UNKNOWN, message_or_progname_or_attributes, progname_or_attributes, &block)
    end

    # Add a message when the severity is not known.
    #
    # @param msg [Object] The message to log.
    # @return [void]
    def <<(msg)
      add_entry(default_severity, msg)
    end

    # Tag the logger with a set of attributes. If a block is given, the attributes will only be set
    # for the duration of the block. Otherwise the attributes will be applied on the current
    # logger context for the duration of the current context. If there is no current context,
    # then a new logger object will be returned with those attributes set on it.
    #
    # @param attributes [Hash] The attributes to set.
    # @return [Object, Lumberjack::ContextLogger] If a block is given then the result of the block is returned.
    #   Otherwise it returns the logger itself so you can chain methods.
    #
    # @example
    #   # Only applies the attributes inside the block
    #   logger.tag(foo: "bar") do
    #     logger.info("message")
    #   end
    #
    # @example
    #   # Only applies the attributes inside the context block
    #   logger.context do
    #     logger.tag(foo: "bar")
    #     logger.info("message")
    #   end
    def tag(attributes, &block)
      if block
        context do |ctx|
          ctx.assign_attributes(attributes)
          block.call(ctx)
        end
      else
        local_context&.assign_attributes(attributes)
        self
      end
    end

    # Tags the logger with a set of persistent attributes. These attributes will be included on every log
    # entry and are not tied to a context block. If the logger does not have a default context, then
    # these will be ignored.
    #
    # @param attributes [Hash] The attributes to set persistently on the logger.
    # @return [nil]
    # @example
    #   logger.tag!(version: "1.2.3", environment: "production")
    #   logger.info("Server started") # Will include version and environment attributes
    def tag!(attributes)
      default_context&.assign_attributes(attributes)
      nil
    end

    # Tags the outermost context with a set of attributes. If there is no outermost context, then
    # nothing will happen. This method can be used to bubble attributes up to the top level context.
    # It can be used in situations where you want to ensure a set of attributes are set for the rest
    # of the request or operation defined by the outmermost context.
    #
    # @param attributes [Hash] The attributes to set on the outermost context.
    # @return [nil]
    #
    # @example
    #   logger.tag(request_id: "12345") do
    #     logger.tag(action: "login") do
    #       # Add the user_id attribute to the outermost context along with request_id so that
    #       # it doesn't fall out of scope after this tag block ends.
    #       logger.tag_all_contexts(user_id: "67890")
    #     end
    #   end
    def tag_all_contexts(attributes)
      parent_context = local_context
      while parent_context
        parent_context.assign_attributes(attributes)
        parent_context = parent_context.parent
      end
      nil
    end

    # Append a value to an attribute. This method can be used to add "tags" to a logger by appending
    # values to the same attribute. The tag values will be appended to any value that is already
    # in the attribute. If a block is passed, then a new context will be opened as well. If no
    # block is passed, then the values will be appended to the attribute in the current context.
    # If there is no current context, then nothing will happen.
    #
    # @param attribute_name [String, Symbol] The name of the attribute to append values to.
    # @param tags [Array<String, Symbol, Hash>] The tags to add.
    # @return [Object, Lumberjack::Logger] If a block is passed then returns the result of the block.
    #   Otherwise returns self so that calls can be chained.
    def append_to(attribute_name, *tags, &block)
      return self unless block || in_context?

      current_tags = attribute_value(attribute_name) || []
      current_tags = [current_tags] unless current_tags.is_a?(Array)
      new_tags = current_tags + tags.flatten

      tag(attribute_name => new_tags, &block)
    end

    # Set up a context block for the logger. All attributes added within the block will be cleared when
    # the block exits.
    #
    # @param block [Proc] The block to execute with the context.
    # @return [Object] The result of the block.
    # @yield [Context]
    def context(&block)
      unless block_given?
        raise ArgumentError, "A block must be provided to the context method"
      end

      new_context = Context.new(current_context)
      new_context.parent = local_context
      new_context_locals do |locals|
        locals.context = new_context
        block.call(new_context)
      end
    end

    # Ensure that the block of code is wrapped by a context. If there is not already
    # a context in scope for this logger, one will be created.
    #
    # @return [Object] The result of the block.
    def ensure_context(&block)
      if in_context?
        yield
      else
        context(&block)
      end
    end

    # Forks a new logger with a new context that will send output through this logger.
    # The new logger will inherit the level, progname, and attributes of the current logger
    # context. Any changes to those values, though, will be isolated to just the forked logger.
    # Any calls to log messages will be forwarded to the parent logger for output to the
    # logging device.
    #
    # @param level [Integer, String, Symbol, nil] The level to set on the new logger. If this
    #   is not specified, then the level on the parent logger will be used.
    # @param progname [String, nil] The progname to set on the new logger. If this is not specified,
    #   then the progname on the parent logger will be used.
    # @param attributes [Hash, nil] The attributes to set on the new logger. The forked logger will
    #   inherit all attributes from the current logging context.
    # @return [ForkedLogger]
    #
    # @example Creating a forked logger
    #   child_logger = logger.fork(level: :debug, progname: "Child")
    #   child_logger.debug("This goes to the parent logger's device")
    def fork(level: nil, progname: nil, attributes: nil)
      logger = ForkedLogger.new(self)
      logger.level = level if level
      logger.progname = progname if progname
      logger.tag!(attributes) if attributes
      logger.isolation_level = isolation_level
      logger
    end

    # Remove attributes from the current context block.
    #
    # @param attribute_names [Array<String, Symbol>] The attributes to remove.
    # @return [void]
    def untag(*attribute_names)
      attributes = local_context&.attributes
      AttributesHelper.new(attributes).delete(*attribute_names) if attributes
      nil
    end

    # Remove attributes from the default context for the logger.
    #
    # @param attribute_names [Array<String, Symbol>] The attributes to remove.
    # @return [void]
    def untag!(*attribute_names)
      attributes = default_context&.attributes
      AttributesHelper.new(attributes).delete(*attribute_names) if attributes
      nil
    end

    # Return all attributes in scope on the logger including global attributes set on the Lumberjack
    # context, attributes set on the logger, and attributes set on the current block for the logger.
    #
    # @return [Hash]
    def attributes
      merge_all_attributes || {}
    end

    # Get the value of an attribute by name from the current context.
    #
    # @param name [String, Symbol] The name of the attribute to get.
    # @return [Object, nil] The value of the attribute or nil if the attribute does not exist.
    def attribute_value(name)
      name = name.join(".") if name.is_a?(Array)
      AttributesHelper.new(attributes)[name]
    end

    # Remove all attributes on the current logger and logging context within a block.
    # You can still set new block scoped attributes within the block and provide
    # attributes on individual log methods.
    #
    # @return [void]
    def clear_attributes(&block)
      new_context_locals do |locals|
        locals.cleared = true
        context do |ctx|
          ctx.clear_attributes
          block.call
        end
      end
    end

    # Return true if the thread is currently in a context block with a local context.
    #
    # @return [Boolean]
    def in_context?
      !!local_context
    end

    # Add an entry to the log. This method must be implemented by the class that includes this module.
    #
    # @param severity [Integer, Symbol, String] The severity of the message.
    # @param message [Object] The message to log.
    # @param progname [String] The name of the program that is logging the message.
    # @param attributes [Hash] The attributes to add to the log entry.
    # @return [void]
    # @api private
    def add_entry(severity, message, progname = nil, attributes = nil)
      raise NotImplementedError
    end

    private

    def current_context
      local_context || default_context
    end

    def local_context
      current_context_locals&.context
    end

    def default_context
      nil
    end

    # Write a log entry to the logging device.
    #
    # @param entry [Lumberjack::LogEntry] The log entry to write.
    # @return [void]
    # @api private
    def write_to_device(entry)
      raise NotImplementedError
    end

    # Dereference arguments to log calls so we can have methods with compatibility with ::Logger
    def call_add_entry(severity, message_or_progname_or_attributes, progname_or_attributes, &block) # :nodoc:
      severity = Severity.coerce(severity) unless severity.is_a?(Integer)
      return true unless level.nil? || severity >= level

      message = nil
      progname = nil
      attributes = nil
      if block
        message = block
        if message_or_progname_or_attributes.is_a?(Hash)
          attributes = message_or_progname_or_attributes
          progname = progname_or_attributes
        else
          progname = message_or_progname_or_attributes
          attributes = progname_or_attributes if progname_or_attributes.is_a?(Hash)
        end
      else
        message = message_or_progname_or_attributes
        if progname_or_attributes.is_a?(Hash)
          attributes = progname_or_attributes
        else
          progname = progname_or_attributes
        end
      end

      message = message.call if message.is_a?(Proc)
      message = message.strip if message.is_a?(String) && message.match?(LEADING_OR_TRAILING_WHITESPACE)
      return if (message.nil? || message == "") && (attributes.nil? || attributes.empty?)

      add_entry(severity, message, progname, attributes)

      true
    end

    # Merge a attributes hash into an existing attributes hash.
    def merge_attributes(current_attributes, attributes)
      if current_attributes.nil? || current_attributes.empty?
        attributes
      elsif attributes.nil?
        current_attributes
      else
        current_attributes.merge(attributes)
      end
    end

    def merge_all_attributes
      attributes = nil

      unless current_context_locals&.cleared
        global_context_attributes = Lumberjack.context_attributes
        if global_context_attributes && !global_context_attributes.empty?
          attributes ||= {}
          attributes.merge!(global_context_attributes)
        end

        default_attributes = default_context&.attributes
        if default_attributes && !default_attributes.empty?
          attributes ||= {}
          attributes.merge!(default_attributes)
        end
      end

      context_attributes = current_context&.attributes
      if context_attributes && !context_attributes.empty?
        attributes ||= {}
        attributes.merge!(context_attributes)
      end

      attributes
    end
  end
end