dry-logger 1.0.0.rc2 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5c096685f8199a216c865178cc4a5633975fa45e9b158e0a25254ec8edaa6b5
4
- data.tar.gz: 48e24dcd35809d47677964fe48df6d9e6c32bc0bda00cbfe3ee0e0dd76681660
3
+ metadata.gz: 730cb488952659e0a7499948d6f77e3ed61b7be38e462d7a000479d7e6a8c7f2
4
+ data.tar.gz: 81ddc5b02fc960cb3650f1305e19bfc33d21488722455c8bfdcb3e411cb07bf4
5
5
  SHA512:
6
- metadata.gz: f298b5262eb94c6312122519bfcc17ced78512dfe53fe1db097c95aa68aa6cf717e7da0bde929e74c6d5df9f58938ce22e4e66167d96031e9d1530baded90311
7
- data.tar.gz: e8413dddf934581b0fadaca47c108f53145ba8a5ad4603d9b02cfa0a6fbe89c1b29c933c51205a5e171619e69502a3e1f9658e6b19b763306e52f04768b4c0ce
6
+ metadata.gz: 02d8e2eb6e2029a3c29269db001e5e3f50eacf1605efa4cdd62ae01a1e8349b80073e0b591761ed3bc82f7acda5b139a2a03c120c32b3716c27c2d054eee0a64
7
+ data.tar.gz: 2f11fec53ac321f0157f3c620944d460a189fa4da112a5b2d617a21171533dcacf17370bf1288d2ef1e4574ab4f35518aca2e280a7063df669768d1c3dda73b6
data/CHANGELOG.md CHANGED
@@ -1,13 +1,27 @@
1
1
  <!--- DO NOT EDIT THIS FILE - IT'S AUTOMATICALLY GENERATED VIA DEVTOOLS --->
2
2
 
3
- ## 1.0.0.rc1 2022-11-07
3
+ ## 1.0.0
4
4
 
5
5
  This is a port of the original Hanami logger from hanami-utils extended with support for logging
6
- dispatchers that can log to different destinations.
6
+ dispatchers that can log to different destinations and plenty more.
7
7
 
8
8
 
9
9
  ### Added
10
10
 
11
+ - Support arbitrary logging backends through proxy (via #12) (@solnic)
12
+ - Support for conditional logging when using arbitrary logging backends (via #13) (@solnic)
13
+ - Support for registering templates via `Dry::Logger.register_template` (via #14) (@solnic)
14
+ - Support for payload keys as template tokens (via #14) (@solnic)
15
+ - Support for payload value formatter methods, ie if there's `:verb` token your formatter can implement `format_verb(value)` (via #14) (@solnic)
16
+ - Support block-based setup (via #16) (@solnic)
17
+ - Support for defining cherry-picked keys from the payload in string templates (via #17) (@solnic)
18
+ - Support for `%<payload>s` template token. It will be replaced by a formatted payload, excluding any key that you specified explicitly in the template (via #17) (@solnic)
19
+ - Support for colorized output using color tags in templates (via #18) (@solnic)
20
+ - Support for `colorize: true` logger option which enables severity coloring in string formatter (via #18) (@solnic)
21
+ - `:details` template: `"[%<progname>s] [%<severity>s] [%<time>s] %<message>s %<payload>s"` (@solnic)
22
+ - A new option `on_crash` for setting up a logger-crash handling proc (via #21) (@solnic)
23
+ - Handle logger crashes by default using a simple `$stdout` logger (via #21) (@solnic)
24
+ - Support for regular logger backends that don't support `log?` predicate (@solnic)
11
25
  - Support for providing a string template for log entries via `template` option (via #7) (@solnic)
12
26
  - `:rack` string log formatter which inlines request info and displays params at the end (@solnic)
13
27
  - Conditional log dispatch via `#log_if` backend's predicate (via #9) (@solnic)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/logger/constants"
4
+
5
+ module Dry
6
+ module Logger
7
+ module Backends
8
+ module Core
9
+ # Return a proc used by the log? predicate
10
+ #
11
+ # @since 1.0.0
12
+ # @api private
13
+ attr_reader :log_if
14
+
15
+ # Set a predicate proc that checks if an entry should be logged by a given backend
16
+ #
17
+ # The predicate will receive {Entry} as its argument and should return true/false
18
+ #
19
+ # @param [Proc, #to_proc] spec A proc-like object
20
+ # @since 1.0.0
21
+ # @api public
22
+ def log_if=(spec)
23
+ @log_if = spec&.to_proc
24
+ end
25
+
26
+ # @since 1.0.0
27
+ # @api private
28
+ def log?(entry)
29
+ if log_if
30
+ log_if.call(entry)
31
+ else
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ require "dry/logger/constants"
6
+ require "dry/logger/backends/core"
7
+
8
+ module Dry
9
+ module Logger
10
+ module Backends
11
+ # Logger proxy is used for regular loggers that don't work with log entries
12
+ #
13
+ # @since 1.0.0
14
+ # @api private
15
+ class Proxy < SimpleDelegator
16
+ include Core
17
+
18
+ # @since 0.1.0
19
+ # @api public
20
+ attr_accessor :log_if
21
+
22
+ LOG_METHODS.each do |method|
23
+ define_method(method) { |entry| __getobj__.public_send(method, entry.message) }
24
+ end
25
+
26
+ # @since 1.0.0
27
+ # @api private
28
+ def log?(entry)
29
+ if log_if
30
+ log_if.call(entry)
31
+ else
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -3,11 +3,14 @@
3
3
  require "logger"
4
4
 
5
5
  require "dry/logger/constants"
6
+ require "dry/logger/backends/core"
6
7
 
7
8
  module Dry
8
9
  module Logger
9
10
  module Backends
10
11
  class Stream < ::Logger
12
+ include Core
13
+
11
14
  # @since 0.1.0
12
15
  # @api private
13
16
  attr_reader :stream
@@ -16,10 +19,6 @@ module Dry
16
19
  # @api private
17
20
  attr_reader :level
18
21
 
19
- # @since 0.1.0
20
- # @api public
21
- attr_accessor :log_if
22
-
23
22
  # @since 0.1.0
24
23
  # @api private
25
24
  def initialize(stream:, formatter:, level: DEFAULT_LEVEL, progname: nil, log_if: nil)
@@ -33,13 +32,9 @@ module Dry
33
32
  end
34
33
 
35
34
  # @since 1.0.0
36
- # @api private
37
- def log?(entry)
38
- if log_if
39
- log_if.call(entry)
40
- else
41
- true
42
- end
35
+ # @api public
36
+ def inspect
37
+ %(#<#{self.class} stream=#{stream} level=#{level} log_if=#{log_if}>)
43
38
  end
44
39
  end
45
40
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Logger
5
+ # @since 1.0.0
6
+ # @api private
7
+ class Clock
8
+ # @since 1.0.0
9
+ # @api private
10
+ attr_reader :unit
11
+
12
+ # @since 1.0.0
13
+ # @api private
14
+ def initialize(unit: :nanosecond)
15
+ @unit = unit
16
+ end
17
+
18
+ # @since 1.0.0
19
+ # @api private
20
+ def now
21
+ Time.now
22
+ end
23
+
24
+ # @since 1.0.0
25
+ # @api private
26
+ def now_utc
27
+ now.getutc
28
+ end
29
+
30
+ # @since 1.0.0
31
+ # @api private
32
+ def measure
33
+ start = current
34
+ result = yield
35
+ [result, current - start]
36
+ end
37
+
38
+ private
39
+
40
+ # @since 1.0.0
41
+ # @api private
42
+ def current
43
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, unit)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -4,22 +4,67 @@ require "logger"
4
4
 
5
5
  module Dry
6
6
  module Logger
7
- LOG_METHODS = %i[debug error fatal info warn].freeze
7
+ # @since 1.0.0
8
+ # @api private
9
+ NEW_LINE = $/ # rubocop:disable Style/SpecialGlobalVars
10
+
11
+ # @since 1.0.0
12
+ # @api private
13
+ SEPARATOR = " "
14
+
15
+ # @since 1.0.0
16
+ # @api private
17
+ TAB = SEPARATOR * 2
18
+
19
+ # @since 1.0.0
20
+ # @api private
21
+ EMPTY_ARRAY = [].freeze
8
22
 
23
+ # @since 1.0.0
24
+ # @api private
25
+ EMPTY_HASH = {}.freeze
26
+
27
+ # @since 1.0.0
28
+ # @api private
29
+ LOG_METHODS = %i[debug info warn error fatal unknown].freeze
30
+
31
+ # @since 1.0.0
32
+ # @api private
9
33
  BACKEND_METHODS = %i[close].freeze
10
34
 
35
+ # @since 1.0.0
36
+ # @api private
11
37
  DEBUG = ::Logger::DEBUG
38
+
39
+ # @since 1.0.0
40
+ # @api private
12
41
  INFO = ::Logger::INFO
42
+
43
+ # @since 1.0.0
44
+ # @api private
13
45
  WARN = ::Logger::WARN
46
+
47
+ # @since 1.0.0
48
+ # @api private
14
49
  ERROR = ::Logger::ERROR
50
+
51
+ # @since 1.0.0
52
+ # @api private
15
53
  FATAL = ::Logger::FATAL
54
+
55
+ # @since 1.0.0
56
+ # @api private
16
57
  UNKNOWN = ::Logger::UNKNOWN
17
58
 
59
+ # @since 1.0.0
60
+ # @api private
18
61
  LEVEL_RANGE = (DEBUG..UNKNOWN).freeze
19
62
 
63
+ # @since 1.0.0
64
+ # @api private
20
65
  DEFAULT_LEVEL = INFO
21
66
 
22
- # @since 0.1.0
67
+ # @since 1.0.0
23
68
  # @api private
24
69
  LEVELS = Hash
25
70
  .new { |levels, key|
@@ -35,9 +80,16 @@ module Dry
35
80
  )
36
81
  .freeze
37
82
 
38
- DEFAULT_OPTS = {level: DEFAULT_LEVEL, formatter: nil, progname: nil}.freeze
83
+ # @since 1.0.0
84
+ # @api private
85
+ DEFAULT_OPTS = {level: DEFAULT_LEVEL, formatter: nil, progname: nil, log_if: nil}.freeze
39
86
 
87
+ # @since 1.0.0
88
+ # @api private
40
89
  BACKEND_OPT_KEYS = DEFAULT_OPTS.keys.freeze
90
+
91
+ # @since 1.0.0
92
+ # @api private
41
93
  FORMATTER_OPT_KEYS = %i[filter].freeze
42
94
  end
43
95
  end
@@ -4,6 +4,7 @@ require "logger"
4
4
  require "pathname"
5
5
 
6
6
  require "dry/logger/constants"
7
+ require "dry/logger/backends/proxy"
7
8
  require "dry/logger/entry"
8
9
 
9
10
  module Dry
@@ -37,21 +38,48 @@ module Dry
37
38
  # @api private
38
39
  attr_reader :options
39
40
 
41
+ # @since 1.0.0
42
+ # @api private
43
+ attr_reader :clock
44
+
45
+ # @since 1.0.0
46
+ # @api private
47
+ attr_reader :on_crash
48
+
40
49
  # @since 1.0.0
41
50
  # @api private
42
51
  attr_reader :mutex
43
52
 
53
+ # @since 1.0.0
54
+ # @api private
55
+ CRASH_LOGGER = ::Logger.new($stdout).tap { |logger|
56
+ logger.formatter = -> (_, _, _, message) { "#{message}#{NEW_LINE}" }
57
+ logger.level = FATAL
58
+ }.freeze
59
+
60
+ # @since 1.0.0
61
+ # @api private
62
+ ON_CRASH = -> (progname:, exception:, message:, payload:) {
63
+ CRASH_LOGGER.fatal(Logger.templates[:crash] % {
64
+ severity: "FATAL",
65
+ progname: progname,
66
+ time: Time.now,
67
+ log_entry: [message, payload].map(&:to_s).reject(&:empty?).join(SEPARATOR),
68
+ exception: exception.class,
69
+ message: exception.message,
70
+ backtrace: TAB + exception.backtrace.join(NEW_LINE + TAB)
71
+ })
72
+ }
73
+
44
74
  # Set up a dispatcher
45
75
  #
46
76
  # @since 1.0.0
47
- #
48
- # @param [String, Symbol] id The dispatcher id, can be used as progname in log entries
49
- # @param [Hash] options Options that can be used for both the backend and formatter
77
+ # @api private
50
78
  #
51
79
  # @return [Dispatcher]
52
- # @api public
53
80
  def self.setup(id, **options)
54
81
  dispatcher = new(id, **DEFAULT_OPTS, **options)
82
+ yield(dispatcher) if block_given?
55
83
  dispatcher.add_backend if dispatcher.backends.empty?
56
84
  dispatcher
57
85
  end
@@ -64,12 +92,17 @@ module Dry
64
92
 
65
93
  # @since 1.0.0
66
94
  # @api private
67
- def initialize(id, backends: [], context: self.class.default_context, **options)
95
+ def initialize(
96
+ id, backends: [], tags: [], context: self.class.default_context, **options
97
+ )
68
98
  @id = id
69
99
  @backends = backends
70
100
  @options = {**options, progname: id}
71
101
  @mutex = Mutex.new
72
102
  @context = context
103
+ @tags = tags
104
+ @clock = Clock.new(**(options[:clock] || EMPTY_HASH))
105
+ @on_crash = options[:on_crash] || ON_CRASH
73
106
  end
74
107
 
75
108
  # Log an entry with UNKNOWN severity
@@ -143,8 +176,25 @@ module Dry
143
176
 
144
177
  # Pass logging to all configured backends
145
178
  #
179
+ # @example logging a message
180
+ # logger.log(:info, "Hello World")
181
+ #
182
+ # @example logging payload
183
+ # logger.log(:info, verb: "GET", path: "/users")
184
+ #
185
+ # @example logging message and payload
186
+ # logger.log(:info, "User index request", verb: "GET", path: "/users")
187
+ #
188
+ # @example logging exception
189
+ # begin
190
+ # # things that may raise
191
+ # rescue => e
192
+ # logger.log(:error, e)
193
+ # raise e
194
+ # end
195
+ #
146
196
  # @param [Symbol] severity The log severity name
147
- # @param [String,Symbol,Array] message Optional message object
197
+ # @param [String] message Optional message
148
198
  # @param [Hash] payload Optional log entry payload
149
199
  #
150
200
  # @since 1.0.0
@@ -155,17 +205,24 @@ module Dry
155
205
  when Hash then log(severity, nil, **message)
156
206
  else
157
207
  entry = Entry.new(
208
+ clock: clock,
158
209
  progname: id,
159
210
  severity: severity,
211
+ tags: @tags,
160
212
  message: message,
161
213
  payload: {**context, **payload}
162
214
  )
163
215
 
164
216
  each_backend do |backend|
165
- backend.__send__(severity, entry) if !backend.respond_to?(:log?) || backend.log?(entry)
217
+ backend.__send__(severity, entry) if backend.log?(entry)
218
+ rescue StandardError => e
219
+ on_crash.(progname: id, exception: e, message: message, payload: payload)
166
220
  end
167
221
  end
168
222
 
223
+ true
224
+ rescue StandardError => e
225
+ on_crash.(progname: id, exception: e, message: message, payload: payload)
169
226
  true
170
227
  end
171
228
 
@@ -182,11 +239,11 @@ module Dry
182
239
  #
183
240
  # @since 1.0.0
184
241
  # @api public
185
- def tagged(tag)
186
- context[:tag] = tag
242
+ def tagged(*tags)
243
+ @tags.concat(tags)
187
244
  yield
188
245
  ensure
189
- context.delete(:tag)
246
+ @tags = []
190
247
  end
191
248
 
192
249
  # Add a new backend to an existing dispatcher
@@ -200,15 +257,27 @@ module Dry
200
257
  # @return [Dispatcher]
201
258
  # @api public
202
259
  def add_backend(instance = nil, **backend_options)
203
- backend = instance || Dry::Logger.new(**options, **backend_options)
260
+ backend =
261
+ case (instance ||= Dry::Logger.new(**options, **backend_options))
262
+ when Backends::Stream then instance
263
+ else Backends::Proxy.new(instance)
264
+ end
265
+
204
266
  yield(backend) if block_given?
267
+
205
268
  backends << backend
206
269
  self
207
270
  end
208
271
 
272
+ # @since 1.0.0
273
+ # @api public
274
+ def inspect
275
+ %(#<#{self.class} id=#{id} options=#{options} backends=#{backends}>)
276
+ end
277
+
209
278
  # @since 1.0.0
210
279
  # @api private
211
- def each_backend(*_args, &block)
280
+ def each_backend(&block)
212
281
  mutex.synchronize do
213
282
  backends.each(&block)
214
283
  end
@@ -10,14 +10,6 @@ module Dry
10
10
  class Entry
11
11
  include Enumerable
12
12
 
13
- # @since 1.0.0
14
- # @api private
15
- EMPTY_PAYLOAD = {}.freeze
16
-
17
- # @since 1.0.0
18
- # @api private
19
- EMPTY_BACKTRACE = [].freeze
20
-
21
13
  # @since 1.0.0
22
14
  # @api public
23
15
  attr_reader :progname
@@ -28,16 +20,19 @@ module Dry
28
20
 
29
21
  # @since 1.0.0
30
22
  # @api public
31
- attr_reader :level
23
+ attr_reader :tags
32
24
 
33
25
  # @since 1.0.0
34
26
  # @api public
35
- attr_reader :time
27
+ attr_reader :level
36
28
 
37
29
  # @since 1.0.0
38
30
  # @api public
39
31
  attr_reader :message
40
- alias_method :exception, :message
32
+
33
+ # @since 1.0.0
34
+ # @api public
35
+ attr_reader :exception
41
36
 
42
37
  # @since 1.0.0
43
38
  # @api public
@@ -45,14 +40,23 @@ module Dry
45
40
 
46
41
  # @since 1.0.0
47
42
  # @api private
48
- def initialize(progname:, severity:, time: Time.now, message: nil, payload: EMPTY_PAYLOAD)
43
+ attr_reader :clock
44
+
45
+ # @since 1.0.0
46
+ # @api private
47
+ # rubocop:disable Metrics/ParameterLists
48
+ def initialize(clock:, progname:, severity:, tags: EMPTY_ARRAY, message: nil,
49
+ payload: EMPTY_HASH)
50
+ @clock = clock
49
51
  @progname = progname
50
- @severity = severity.to_s.upcase # TODO: this doesn't feel right
52
+ @severity = severity.to_s
53
+ @tags = tags
51
54
  @level = LEVELS.fetch(severity.to_s)
52
- @time = time
53
- @message = message
55
+ @message = message unless message.is_a?(Exception)
56
+ @exception = message if message.is_a?(Exception)
54
57
  @payload = build_payload(payload)
55
58
  end
59
+ # rubocop:enable Metrics/ParameterLists
56
60
 
57
61
  # @since 1.0.0
58
62
  # @api public
@@ -99,7 +103,7 @@ module Dry
99
103
  # @since 1.0.0
100
104
  # @api public
101
105
  def exception?
102
- message.is_a?(Exception)
106
+ !exception.nil?
103
107
  end
104
108
 
105
109
  # @since 1.0.0
@@ -109,28 +113,21 @@ module Dry
109
113
  end
110
114
 
111
115
  # @since 1.0.0
112
- # @api private
113
- def to_h
114
- @to_h ||= meta.merge(payload)
116
+ # @api public
117
+ def tag?(value)
118
+ tags.include?(value)
115
119
  end
116
120
 
117
121
  # @since 1.0.0
118
122
  # @api private
119
123
  def meta
120
- @meta ||= {progname: progname, severity: severity, time: time}
124
+ @meta ||= {progname: progname, severity: severity, time: clock.now}
121
125
  end
122
126
 
123
127
  # @since 1.0.0
124
128
  # @api private
125
- def utc_time
126
- @utc_time ||= time.utc.iso8601
127
- end
128
-
129
- # @since 1.0.0
130
- # @api private
131
- def as_json
132
- # TODO: why are we enforcing UTC in JSON but not in String?
133
- @as_json ||= to_h.merge(message: message, time: utc_time).compact
129
+ def to_h
130
+ @to_h ||= meta.merge(message: message, **payload)
134
131
  end
135
132
 
136
133
  # @since 1.0.0
@@ -146,10 +143,7 @@ module Dry
146
143
  # @api private
147
144
  def build_payload(payload)
148
145
  if exception?
149
- {message: exception.message,
150
- backtrace: exception.backtrace || EMPTY_BACKTRACE,
151
- error: exception.class,
152
- **payload}
146
+ {exception: exception, **payload}
153
147
  else
154
148
  payload
155
149
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Logger
5
+ module Formatters
6
+ # Shell colorizer
7
+ #
8
+ # This was ported from hanami-utils
9
+ #
10
+ # @since 1.0.0
11
+ # @api private
12
+ class Colors
13
+ # Unknown color code error
14
+ #
15
+ # @since 1.0.0
16
+ class UnknownColorCodeError < StandardError
17
+ def initialize(code)
18
+ super("Unknown color code: `#{code.inspect}'")
19
+ end
20
+ end
21
+
22
+ # Escapes codes for terminals to output strings in colors
23
+ #
24
+ # @since 1.2.0
25
+ # @api private
26
+ COLORS = {black: 30,
27
+ red: 31,
28
+ green: 32,
29
+ yellow: 33,
30
+ blue: 34,
31
+ magenta: 35,
32
+ cyan: 36,
33
+ gray: 37}.freeze
34
+
35
+ # @api private
36
+ def self.evaluate(input)
37
+ COLORS.keys.reduce(input.dup) { |output, color|
38
+ output.gsub!("<#{color}>", start(color))
39
+ output.gsub!("</#{color}>", stop)
40
+ output
41
+ }
42
+ end
43
+
44
+ # Colorizes output
45
+ # 8 colors available: black, red, green, yellow, blue, magenta, cyan, and gray
46
+ #
47
+ # @param input [#to_s] the string to colorize
48
+ # @param color [Symbol] the color
49
+ #
50
+ # @raise [UnknownColorError] if the color code is unknown
51
+ #
52
+ # @return [String] the colorized string
53
+ #
54
+ # @since 1.0.0
55
+ # @api private
56
+ def self.call(color, input)
57
+ "#{start(color)}#{input}#{stop}"
58
+ end
59
+
60
+ # @since 1.0.0
61
+ # @api private
62
+ def self.start(color)
63
+ "\e[#{self[color]}m"
64
+ end
65
+
66
+ # @since 1.0.0
67
+ # @api private
68
+ def self.stop
69
+ "\e[0m"
70
+ end
71
+
72
+ # Helper method to translate between color names and terminal escape codes
73
+ #
74
+ # @since 1.0.0
75
+ # @api private
76
+ #
77
+ # @raise [UnknownColorError] if the color code is unknown
78
+ def self.[](code)
79
+ COLORS.fetch(code) { raise UnknownColorCodeError, code }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+
5
+ require "dry/logger/constants"
4
6
  require "dry/logger/formatters/structured"
5
7
 
6
8
  module Dry
@@ -16,7 +18,31 @@ module Dry
16
18
  # @since 0.1.0
17
19
  # @api private
18
20
  def format(entry)
19
- "#{::JSON.generate(entry.as_json)}#{NEW_LINE}"
21
+ hash = format_values(entry).compact
22
+ hash.update(hash.delete(:exception)) if entry.exception?
23
+ ::JSON.dump(hash)
24
+ end
25
+
26
+ # @since 0.1.0
27
+ # @api private
28
+ def format_severity(value)
29
+ value.upcase
30
+ end
31
+
32
+ # @since 0.1.0
33
+ # @api private
34
+ def format_exception(value)
35
+ {
36
+ exception: value.class,
37
+ message: value.message,
38
+ backtrace: value.backtrace || EMPTY_ARRAY
39
+ }
40
+ end
41
+
42
+ # @since 0.1.0
43
+ # @api private
44
+ def format_time(value)
45
+ value.getutc.iso8601
20
46
  end
21
47
  end
22
48
  end
@@ -12,10 +12,18 @@ module Dry
12
12
  #
13
13
  # @see String
14
14
  class Rack < String
15
+ # @see String#initialize
15
16
  # @since 1.0.0
16
17
  # @api private
17
- def format_entry(entry)
18
- [*entry.payload.except(:params).values, entry[:params]].compact.join(SEPARATOR)
18
+ def initialize(**options)
19
+ super
20
+ @template = Template[Logger.templates[:rack]]
21
+ end
22
+
23
+ # @api 1.0.0
24
+ # @api private
25
+ def format_params(value)
26
+ return value unless value.empty?
19
27
  end
20
28
  end
21
29
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "dry/logger/formatters/structured"
3
+ require "set"
4
+
5
+ require_relative "template"
6
+ require_relative "structured"
4
7
 
5
8
  module Dry
6
9
  module Logger
@@ -12,10 +15,6 @@ module Dry
12
15
  # @since 1.0.0
13
16
  # @api public
14
17
  class String < Structured
15
- # @since 1.0.0
16
- # @api private
17
- SEPARATOR = " "
18
-
19
18
  # @since 1.0.0
20
19
  # @api private
21
20
  HASH_SEPARATOR = ","
@@ -24,9 +23,16 @@ module Dry
24
23
  # @api private
25
24
  EXCEPTION_SEPARATOR = ": "
26
25
 
27
- # @since 1.0.0
26
+ # @since 1.2.0
28
27
  # @api private
29
- DEFAULT_TEMPLATE = "%<message>s"
28
+ DEFAULT_SEVERITY_COLORS = {
29
+ DEBUG => :cyan,
30
+ INFO => :magenta,
31
+ WARN => :yellow,
32
+ ERROR => :red,
33
+ FATAL => :red,
34
+ UNKNOWN => :blue
35
+ }.freeze
30
36
 
31
37
  # @since 1.0.0
32
38
  # @api private
@@ -34,47 +40,108 @@ module Dry
34
40
 
35
41
  # @since 1.0.0
36
42
  # @api private
37
- def initialize(template: DEFAULT_TEMPLATE, **options)
43
+ def initialize(template: Logger.templates[:default], **options)
38
44
  super(**options)
39
- @template = template
45
+ @template = Template[template]
46
+ end
47
+
48
+ # @since 1.0.0
49
+ # @api private
50
+ def colorize?
51
+ options[:colorize].equal?(true)
40
52
  end
41
53
 
42
54
  private
43
55
 
44
56
  # @since 1.0.0
45
57
  # @api private
46
- def format(entry)
47
- "#{template % entry.meta.merge(message: format_entry(entry))}#{NEW_LINE}"
58
+ def format_severity(value)
59
+ output = value.upcase
60
+
61
+ if colorize?
62
+ Colors.call(severity_colors[LEVELS[value]], output)
63
+ else
64
+ output
65
+ end
48
66
  end
49
67
 
50
68
  # @since 1.0.0
51
69
  # @api private
52
- def format_entry(entry)
70
+ def format(entry)
53
71
  if entry.exception?
54
- format_exception(entry)
55
- elsif entry.message
56
- if entry.payload.empty?
57
- entry.message
58
- else
59
- "#{entry.message}#{SEPARATOR}#{format_payload(entry)}"
60
- end
72
+ head = template % template_data(entry, exclude: %i[exception])
73
+ tail = format_exception(entry.exception)
74
+
75
+ "#{head}#{NEW_LINE}#{TAB}#{tail}"
61
76
  else
62
- format_payload(entry)
77
+ template % template_data(entry)
63
78
  end
64
79
  end
65
80
 
66
81
  # @since 1.0.0
67
82
  # @api private
68
- def format_exception(entry)
69
- hash = entry.payload
70
- message = hash.values_at(:error, :message).compact.join(EXCEPTION_SEPARATOR)
71
- "#{message}#{NEW_LINE}#{hash[:backtrace].map { |line| "from #{line}" }.join(NEW_LINE)}"
83
+ def format_tags(value)
84
+ Array(value)
85
+ .map { |tag|
86
+ case tag
87
+ when Hash then format_payload(tag)
88
+ else
89
+ tag.to_s
90
+ end
91
+ }
92
+ .join(SEPARATOR)
93
+ end
94
+
95
+ # @since 1.0.0
96
+ # @api private
97
+ def format_exception(value)
98
+ [
99
+ "#{value.message} (#{value.class})",
100
+ format_backtrace(value.backtrace || EMPTY_BACKTRACE)
101
+ ].join(NEW_LINE)
102
+ end
103
+
104
+ # @since 1.0.0
105
+ # @api private
106
+ def format_payload(payload)
107
+ payload.map { |key, value| "#{key}=#{value.inspect}" }.join(SEPARATOR)
108
+ end
109
+
110
+ # @since 1.0.0
111
+ # @api private
112
+ def format_backtrace(value)
113
+ value.map { |line| "#{TAB}#{line}" }.join(NEW_LINE)
114
+ end
115
+
116
+ # @since 1.0.0
117
+ # @api private
118
+ def template_data(entry, exclude: EMPTY_ARRAY)
119
+ data = format_values(entry)
120
+ payload = data.except(:message, *entry.meta.keys, *template.tokens, *exclude)
121
+ data[:payload] = format_payload(payload)
122
+
123
+ if template.include?(:tags)
124
+ data[:tags] = format_tags(entry.tags)
125
+ end
126
+
127
+ if data[:message]
128
+ data.except(*payload.keys)
129
+ elsif template.include?(:message)
130
+ data[:message] = data.delete(:payload)
131
+ data[:payload] = nil
132
+ data
133
+ else
134
+ data
135
+ end
72
136
  end
73
137
 
74
138
  # @since 1.0.0
75
139
  # @api private
76
- def format_payload(entry)
77
- entry.map { |key, value| "#{key}=#{value.inspect}" }.join(HASH_SEPARATOR)
140
+ def severity_colors
141
+ @severity_colors ||= DEFAULT_SEVERITY_COLORS.merge(
142
+ (options[:severity_colors] || EMPTY_HASH)
143
+ .to_h { |key, value| [LEVELS[key.to_s], value] }
144
+ )
78
145
  end
79
146
  end
80
147
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+
5
+ require "dry/logger/constants"
4
6
  require "dry/logger/filter"
5
7
 
6
8
  module Dry
@@ -8,6 +10,8 @@ module Dry
8
10
  module Formatters
9
11
  # Default structured formatter which receives {Logger::Entry} from the backends.
10
12
  #
13
+ # This class can be used as the base class for your custom formatters.
14
+ #
11
15
  # @see http://www.ruby-doc.org/stdlib/libdoc/logger/rdoc/Logger/Formatter.html
12
16
  #
13
17
  # @since 1.0.0
@@ -21,10 +25,6 @@ module Dry
21
25
  # @api private
22
26
  NOOP_FILTER = -> message { message }
23
27
 
24
- # @since 1.0.0
25
- # @api private
26
- NEW_LINE = $/ # rubocop:disable Style/SpecialGlobalVars
27
-
28
28
  # @since 1.0.0
29
29
  # @api private
30
30
  attr_reader :filter
@@ -52,7 +52,7 @@ module Dry
52
52
  # @return [String]
53
53
  # @api public
54
54
  def call(_severity, _time, _progname, entry)
55
- format(entry.filter(filter))
55
+ format(entry.filter(filter)) + NEW_LINE
56
56
  end
57
57
 
58
58
  # Format entry into a loggable object
@@ -63,7 +63,18 @@ module Dry
63
63
  # @return [Entry]
64
64
  # @api public
65
65
  def format(entry)
66
+ format_values(entry)
67
+ end
68
+
69
+ # @since 1.0.0
70
+ # @api private
71
+ def format_values(entry)
66
72
  entry
73
+ .to_h
74
+ .map { |key, value|
75
+ [key, respond_to?(meth = "format_#{key}", true) ? __send__(meth, value) : value]
76
+ }
77
+ .to_h
67
78
  end
68
79
  end
69
80
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "dry/logger/constants"
5
+ require_relative "colors"
6
+
7
+ module Dry
8
+ module Logger
9
+ module Formatters
10
+ # Basic string formatter.
11
+ #
12
+ # This formatter returns log entries in key=value format.
13
+ #
14
+ # @since 1.0.0
15
+ # @api public
16
+ class Template
17
+ # @since 1.0.0
18
+ # @api private
19
+ TOKEN_REGEXP = /%<(\w*)>s/.freeze
20
+
21
+ # @since 1.0.0
22
+ # @api private
23
+ MESSAGE_TOKEN = "%<message>s"
24
+
25
+ # @since 1.0.0
26
+ # @api private
27
+ attr_reader :value
28
+
29
+ # @since 1.0.0
30
+ # @api private
31
+ attr_reader :tokens
32
+
33
+ # @since 1.0.0
34
+ # @api private
35
+ def self.[](value)
36
+ cache.fetch(value) {
37
+ cache[value] = (colorized?(value) ? Template::Colorized : Template).new(value)
38
+ }
39
+ end
40
+
41
+ # @since 1.0.0
42
+ # @api private
43
+ private_class_method def self.colorized?(value)
44
+ Colors::COLORS.keys.any? { |color| value.include?("<#{color}>") }
45
+ end
46
+
47
+ # @since 1.0.0
48
+ # @api private
49
+ private_class_method def self.cache
50
+ @cache ||= {}
51
+ end
52
+
53
+ # @since 1.0.0
54
+ # @api private
55
+ class Colorized < Template
56
+ # @since 1.0.0
57
+ # @api private
58
+ def initialize(value)
59
+ super(Colors.evaluate(value))
60
+ end
61
+ end
62
+
63
+ # @since 1.0.0
64
+ # @api private
65
+ def initialize(value)
66
+ @value = value
67
+ @tokens = value.scan(TOKEN_REGEXP).flatten(1).map(&:to_sym).to_set
68
+ end
69
+
70
+ # @since 1.0.0
71
+ # @api private
72
+ def %(tokens)
73
+ output = value % tokens
74
+ output.strip!
75
+ output.split(NEW_LINE).map(&:rstrip).join(NEW_LINE)
76
+ end
77
+
78
+ # @since 1.0.0
79
+ # @api private
80
+ def colorize(color, input)
81
+ "\e[#{Colors[color.to_sym]}m#{input}\e[0m"
82
+ end
83
+
84
+ # @since 1.0.0
85
+ # @api private
86
+ def include?(token)
87
+ tokens.include?(token)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Dry
6
+ module Logger
7
+ # Global setup methods
8
+ #
9
+ # @api public
10
+ module Global
11
+ # Register a new formatter
12
+ #
13
+ # @example
14
+ # class MyFormatter < Dry::Logger::Formatters::Structured
15
+ # def format_message(value)
16
+ # "WOAH: #{message}"
17
+ # end
18
+ #
19
+ # def format_time(value)
20
+ # Time.now.strftime("%Y-%m-%d %H:%M:%S")
21
+ # end
22
+ # end
23
+ #
24
+ # Dry::Logger.register_formatter(:my_formatter, MyFormatter)
25
+ #
26
+ # logger = Dry.Logger(:app, formatter: :my_formatter, template: :details)
27
+ #
28
+ # logger.info "Hello World"
29
+ # # [test] [INFO] [2022-11-15 10:06:29] WOAH: Hello World
30
+ #
31
+ # @since 1.0.0
32
+ # @return [Hash]
33
+ # @api public
34
+ def register_formatter(name, formatter)
35
+ formatters[name] = formatter
36
+ formatters
37
+ end
38
+
39
+ # Register a new template
40
+ #
41
+ # @example basic template
42
+ # Dry::Logger.register_template(:request, "[%<severity>s] %<verb>s %<path>s")
43
+ #
44
+ # logger = Dry.Logger(:my_app, template: :request)
45
+ #
46
+ # logger.info(verb: "GET", path: "/users")
47
+ # # [INFO] GET /users
48
+ #
49
+ # @example template with colors
50
+ # Dry::Logger.register_template(
51
+ # :request, "[%<severity>s] <green>%<verb>s</green> <blue>%<path>s</blue>"
52
+ # )
53
+ #
54
+ # @since 1.0.0
55
+ # @return [Hash]
56
+ # @api public
57
+ def register_template(name, template)
58
+ templates[name] = template
59
+ templates
60
+ end
61
+
62
+ # Build a logging backend instance
63
+ #
64
+ # @since 1.0.0
65
+ # @return [Backends::Stream]
66
+ # @api private
67
+ def new(stream: $stdout, **options)
68
+ backend =
69
+ case stream
70
+ when IO, StringIO then Backends::IO
71
+ when String, Pathname then Backends::File
72
+ else
73
+ raise ArgumentError, "unsupported stream type #{stream.class}"
74
+ end
75
+
76
+ formatter_spec = options[:formatter]
77
+ template_spec = options[:template]
78
+
79
+ template =
80
+ case template_spec
81
+ when Symbol then templates.fetch(template_spec)
82
+ when String then template_spec
83
+ when nil then templates[:default]
84
+ else
85
+ raise ArgumentError,
86
+ ":template option must be a Symbol or a String (`#{template_spec}` given)"
87
+ end
88
+
89
+ formatter_options = {**options, template: template}
90
+
91
+ formatter =
92
+ case formatter_spec
93
+ when Symbol then formatters.fetch(formatter_spec).new(**formatter_options)
94
+ when Class then formatter_spec.new(**formatter_options)
95
+ when nil then formatters[:string].new(**formatter_options)
96
+ when ::Logger::Formatter then formatter_spec
97
+ else
98
+ raise ArgumentError, "Unsupported formatter option #{formatter_spec.inspect}"
99
+ end
100
+
101
+ backend_options = options.select { |key, _| BACKEND_OPT_KEYS.include?(key) }
102
+
103
+ backend.new(stream: stream, **backend_options, formatter: formatter)
104
+ end
105
+
106
+ # Internal formatters registry
107
+ #
108
+ # @since 1.0.0
109
+ # @api private
110
+ def formatters
111
+ @formatters ||= {}
112
+ end
113
+
114
+ # Internal templates registry
115
+ #
116
+ # @since 1.0.0
117
+ # @api private
118
+ def templates
119
+ @templates ||= {}
120
+ end
121
+ end
122
+ end
123
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Dry
4
4
  module Logger
5
- VERSION = "1.0.0.rc2"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
data/lib/dry/logger.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
-
3
+ require "dry/logger/global"
5
4
  require "dry/logger/constants"
5
+ require "dry/logger/clock"
6
6
  require "dry/logger/dispatcher"
7
7
 
8
8
  require "dry/logger/formatters/string"
@@ -42,74 +42,58 @@ module Dry
42
42
  # logger.info(Hello: "World!")
43
43
  # # {"progname":"my_app","severity":"INFO","time":"2022-11-06T10:11:29Z","Hello":"World!"}
44
44
  #
45
+ # @example Setting up multiple backends
46
+ # logger = Dry.Logger(:my_app)
47
+ # add_backend(formatter: :string, template: :details)
48
+ # add_backend(formatter: :string, template: :details)
49
+ #
50
+ # @example Setting up conditional logging
51
+ # logger = Dry.Logger(:my_app) { |setup|
52
+ # setup.add_backend(formatter: :string, template: :details) { |b| b.log_if = :error?.to_proc }
53
+ # }
54
+ #
55
+ # @param [String, Symbol] id The dispatcher id, can be used as progname in log entries
56
+ # @param [Hash] options Options for backends and formatters
57
+ # @option options [Symbol] :level (:info) The minimum level that should be logged,
58
+ # @option options [Symbol] :stream (optional) The output stream, default is $stdout
59
+ # @option options [Symbol, Class, #call] :formatter (:string) The default formatter or its id,
60
+ # @option options [String, Symbol] :template (:default) The default template that should be used
61
+ # @option options [Boolean] :colorize (false) Enable/disable colorized severity
62
+ # @option options [Hash<Symbol=>Symbol>] :severity_colors ({}) A severity=>color mapping
63
+ # @option options [#call] :on_crash (Dry::Logger::Dispatcher::ON_CRASH) A crash-handling proc.
64
+ # This is used whenever logging crashes.
65
+ #
45
66
  # @since 1.0.0
46
- # @return [Dispatcher]
47
67
  # @api public
48
- def self.Logger(id, **opts, &block)
49
- Logger::Dispatcher.setup(id, **opts, &block)
68
+ # @return [Dispatcher]
69
+ def self.Logger(id, **options, &block)
70
+ Logger::Dispatcher.setup(id, **options, &block)
50
71
  end
51
72
 
52
73
  module Logger
53
- # Register a new formatter
54
- #
55
- # @example
56
- # class MyFormatter < Dry::Logger::Formatters::Structured
57
- # def format(entry)
58
- # "WOAH: #{entry.message}"
59
- # end
60
- # end
61
- #
62
- # Dry::Logger.register_formatter(MyFormatter)
63
- #
64
- # @since 1.0.0
65
- # @return [Hash]
66
- # @api public
67
- def self.register_formatter(name, formatter)
68
- formatters[name] = formatter
69
- formatters
70
- end
71
-
72
- # Build a logging backend instance
73
- #
74
- # @since 1.0.0
75
- # @return [Backends::Stream]
76
- # @api private
77
- def self.new(stream: $stdout, **opts)
78
- backend =
79
- case stream
80
- when IO, StringIO then Backends::IO
81
- when String, Pathname then Backends::File
82
- else
83
- raise ArgumentError, "unsupported stream type #{stream.class}"
84
- end
74
+ extend Global
85
75
 
86
- formatter_opt = opts[:formatter]
87
-
88
- formatter =
89
- case formatter_opt
90
- when Symbol then formatters.fetch(formatter_opt).new(**opts)
91
- when Class then formatter_opt.new(**opts)
92
- when NilClass then formatters[:string].new(**opts)
93
- when ::Logger::Formatter then formatter_opt
94
- else
95
- raise ArgumentError, "unsupported formatter option #{formatter_opt.inspect}"
96
- end
76
+ # Built-in formatters
77
+ register_formatter(:string, Formatters::String)
78
+ register_formatter(:rack, Formatters::Rack)
79
+ register_formatter(:json, Formatters::JSON)
97
80
 
98
- backend_opts = opts.select { |key, _| BACKEND_OPT_KEYS.include?(key) }
81
+ # Built-in templates
82
+ register_template(:default, "%<message>s %<payload>s")
99
83
 
100
- backend.new(stream: stream, **backend_opts, formatter: formatter)
101
- end
84
+ register_template(:details, "[%<progname>s] [%<severity>s] [%<time>s] %<message>s %<payload>s")
102
85
 
103
- # Internal formatters registry
104
- #
105
- # @since 1.0.0
106
- # @api private
107
- def self.formatters
108
- @formatters ||= {}
109
- end
86
+ register_template(:crash, <<~STR)
87
+ [%<progname>s] [%<severity>s] [%<time>s] Logging crashed
88
+ %<log_entry>s
89
+ %<message>s (%<exception>s)
90
+ %<backtrace>s
91
+ STR
110
92
 
111
- register_formatter(:string, Formatters::String)
112
- register_formatter(:rack, Formatters::Rack)
113
- register_formatter(:json, Formatters::JSON)
93
+ register_template(:rack, <<~STR)
94
+ [%<progname>s] [%<severity>s] [%<time>s] \
95
+ %<verb>s %<status>s %<elapsed>s %<ip>s %<path>s %<length>s %<payload>s
96
+ %<params>s
97
+ STR
114
98
  end
115
99
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dry-logger
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.rc2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luca Guidi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-11-08 00:00:00.000000000 Z
11
+ date: 2022-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -36,17 +36,23 @@ files:
36
36
  - README.md
37
37
  - dry-logger.gemspec
38
38
  - lib/dry/logger.rb
39
+ - lib/dry/logger/backends/core.rb
39
40
  - lib/dry/logger/backends/file.rb
40
41
  - lib/dry/logger/backends/io.rb
42
+ - lib/dry/logger/backends/proxy.rb
41
43
  - lib/dry/logger/backends/stream.rb
44
+ - lib/dry/logger/clock.rb
42
45
  - lib/dry/logger/constants.rb
43
46
  - lib/dry/logger/dispatcher.rb
44
47
  - lib/dry/logger/entry.rb
45
48
  - lib/dry/logger/filter.rb
49
+ - lib/dry/logger/formatters/colors.rb
46
50
  - lib/dry/logger/formatters/json.rb
47
51
  - lib/dry/logger/formatters/rack.rb
48
52
  - lib/dry/logger/formatters/string.rb
49
53
  - lib/dry/logger/formatters/structured.rb
54
+ - lib/dry/logger/formatters/template.rb
55
+ - lib/dry/logger/global.rb
50
56
  - lib/dry/logger/version.rb
51
57
  homepage: https://dry-rb.org/gems/dry-logger
52
58
  licenses:
@@ -67,9 +73,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
73
  version: '3.0'
68
74
  required_rubygems_version: !ruby/object:Gem::Requirement
69
75
  requirements:
70
- - - ">"
76
+ - - ">="
71
77
  - !ruby/object:Gem::Version
72
- version: 1.3.1
78
+ version: '0'
73
79
  requirements: []
74
80
  rubygems_version: 3.1.6
75
81
  signing_key: