dry-logger 1.0.0.rc2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: