tty-logger 0.0.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Logger
5
+ class DataFilter
6
+ FILTERED = "[FILTERED]"
7
+ DOT = "."
8
+
9
+ attr_reader :filters, :compiled_filters, :mask
10
+
11
+ # Create a data filter instance with filters.
12
+ #
13
+ # @example
14
+ # TTY::Logger::DataFilter.new(%w[foo], mask: "<SECRET>")
15
+ #
16
+ # @param [String] mask
17
+ # the mask to replace object with. Defaults to `"[FILTERED]"`
18
+ #
19
+ # @api private
20
+ def initialize(filters = [], mask: nil)
21
+ @mask = mask || FILTERED
22
+ @filters = filters
23
+ @compiled_filters = compile(filters)
24
+ end
25
+
26
+ # Filter object for keys matching provided filters.
27
+ #
28
+ # @example
29
+ # data_filter = TTY::Logger::DataFilter.new(%w[foo])
30
+ # data_filter.filter({"foo" => "bar"})
31
+ # # => {"foo" => "[FILTERED]"}
32
+ #
33
+ # @param [Object] obj
34
+ # the object to filter
35
+ #
36
+ # @api public
37
+ def filter(obj)
38
+ return obj if filters.empty?
39
+
40
+ obj.each_with_object({}) do |(k, v), acc|
41
+ acc[k] = filter_val(k, v)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def compile(filters)
48
+ compiled = {
49
+ regexps: [],
50
+ nested_regexps: [],
51
+ blocks: []
52
+ }
53
+ strings = []
54
+ nested_strings = []
55
+
56
+ filters.each do |filter|
57
+ case filter
58
+ when Proc
59
+ compiled[:blocks] << filter
60
+ when Regexp
61
+ if filter.to_s.include?(DOT)
62
+ compiled[:nested_regexps] << filter
63
+ else
64
+ compiled[:regexps] << filter
65
+ end
66
+ else
67
+ exp = Regexp.escape(filter)
68
+ if exp.include?(DOT)
69
+ nested_strings << exp
70
+ else
71
+ strings << exp
72
+ end
73
+ end
74
+ end
75
+
76
+ if !strings.empty?
77
+ compiled[:regexps] << /^(#{strings.join("|")})$/
78
+ end
79
+
80
+ if !nested_strings.empty?
81
+ compiled[:nested_regexps] << /^(#{nested_strings.join("|")})$/
82
+ end
83
+
84
+ compiled
85
+ end
86
+
87
+ def filter_val(key, val, composite = [])
88
+ return mask if filtered?(key, composite)
89
+
90
+ case val
91
+ when Hash then filter_obj(key, val, composite << key)
92
+ when Array then filter_arr(key, val, composite)
93
+ else val
94
+ end
95
+ end
96
+
97
+ def filtered?(key, composite)
98
+ composite_key = composite + [key]
99
+ joined_key = composite_key.join(DOT)
100
+ @compiled_filters[:regexps].any? { |reg| !!reg.match(key.to_s) } ||
101
+ @compiled_filters[:nested_regexps].any? { |reg| !!reg.match(joined_key) } ||
102
+ @compiled_filters[:blocks].any? { |block| block.(composite_key.dup) }
103
+ end
104
+
105
+ def filter_obj(_key, obj, composite)
106
+ obj.each_with_object({}) do |(k, v), acc|
107
+ acc[k] = filter_val(k, v, composite)
108
+ end
109
+ end
110
+
111
+ def filter_arr(key, obj, composite)
112
+ obj.reduce([]) do |acc, v|
113
+ acc << filter_val(key, v, composite)
114
+ end
115
+ end
116
+ end # DataFilter
117
+ end # Logger
118
+ end # TTY
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Logger
5
+ class Event
6
+ attr_reader :message
7
+
8
+ attr_reader :fields
9
+
10
+ attr_reader :metadata
11
+
12
+ attr_reader :backtrace
13
+
14
+ def initialize(message, fields = {}, metadata = {})
15
+ @message = message
16
+ @fields = fields
17
+ @metadata = metadata
18
+ @backtrace = []
19
+
20
+ evaluate_message
21
+ end
22
+
23
+ private
24
+
25
+ # Extract backtrace information if message contains exception
26
+ #
27
+ # @api private
28
+ def evaluate_message
29
+ @message.each do |msg|
30
+ case msg
31
+ when Exception
32
+ @backtrace = msg.backtrace if msg.backtrace
33
+ else
34
+ msg
35
+ end
36
+ end
37
+ end
38
+ end # Event
39
+ end # Logger
40
+ end # TTY
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TTY
6
+ class Logger
7
+ module Formatters
8
+ # Format data suitable for data exchange
9
+ class JSON
10
+ ELLIPSIS = "..."
11
+
12
+ # Dump data into a JSON formatted string
13
+ #
14
+ # @param [Hash] obj
15
+ # the object to serialize as JSON
16
+ #
17
+ # @return [String]
18
+ #
19
+ # @api public
20
+ def dump(obj, max_bytes: 2**12, max_depth: 3)
21
+ bytesize = 0
22
+
23
+ hash = obj.each_with_object({}) do |(k, v), acc|
24
+ str = (k.to_json + v.to_json)
25
+ items = acc.keys.size - 1
26
+
27
+ if bytesize + str.bytesize + items + ELLIPSIS.bytesize > max_bytes
28
+ acc[k] = ELLIPSIS
29
+ break acc
30
+ else
31
+ bytesize += str.bytesize
32
+ acc[k] = dump_val(v, max_depth)
33
+ end
34
+ end
35
+ ::JSON.generate(hash)
36
+ end
37
+
38
+ private
39
+
40
+ def dump_val(val, depth)
41
+ case val
42
+ when Hash then enc_obj(val, depth - 1)
43
+ when Array then enc_arr(val, depth - 1)
44
+ else
45
+ val
46
+ end
47
+ end
48
+
49
+ def enc_obj(obj, depth)
50
+ return ELLIPSIS if depth.zero?
51
+
52
+ obj.each_with_object({}) { |(k, v), acc| acc[k] = dump_val(v, depth) }
53
+ end
54
+
55
+ def enc_arr(obj, depth)
56
+ return ELLIPSIS if depth.zero?
57
+
58
+ obj.each_with_object([]) { |v, acc| acc << dump_val(v, depth) }
59
+ end
60
+ end # JSON
61
+ end # Formatters
62
+ end # Logger
63
+ end # TTY
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Logger
5
+ module Formatters
6
+ # Format data suitable for text reading
7
+ class Text
8
+ SPACE = " "
9
+ LPAREN = "("
10
+ RPAREN = ")"
11
+ LBRACE = "{"
12
+ RBRACE = "}"
13
+ LBRACKET = "["
14
+ RBRACKET = "]"
15
+ ELLIPSIS = "..."
16
+ LITERAL_TRUE = "true"
17
+ LITERAL_FALSE = "false"
18
+ LITERAL_NIL = "nil"
19
+ SINGLE_QUOTE_REGEX = /'/.freeze
20
+ ESCAPE_DOUBLE_QUOTE = "\""
21
+ ESCAPE_STR_REGEX = /[ ="|{}()\[\]^$+*?.-]/.freeze
22
+ NUM_REGEX = /^-?\d*(?:\.\d+)?\d+$/.freeze
23
+
24
+ # Dump data in a single formatted line
25
+ #
26
+ # @param [Hash] obj
27
+ # the object to serialize as text
28
+ #
29
+ # @return [String]
30
+ #
31
+ # @api public
32
+ def dump(obj, max_bytes: 2**12, max_depth: 3)
33
+ bytesize = 0
34
+
35
+ line = obj.each_with_object([]) do |(k, v), acc|
36
+ str = "#{dump_key(k)}=#{dump_val(v, max_depth)}"
37
+ items = acc.size - 1
38
+
39
+ if bytesize + str.bytesize + items > max_bytes
40
+ if bytesize + items +
41
+ (acc[-1].bytesize - ELLIPSIS.bytesize) > max_bytes
42
+ acc.pop
43
+ end
44
+ acc << ELLIPSIS
45
+ break acc
46
+ else
47
+ bytesize += str.bytesize
48
+ acc << str
49
+ end
50
+ end
51
+ line.join(SPACE)
52
+ end
53
+
54
+ private
55
+
56
+ def dump_key(key)
57
+ key = key.to_s
58
+ case key
59
+ when SINGLE_QUOTE_REGEX
60
+ key.inspect
61
+ when ESCAPE_STR_REGEX
62
+ ESCAPE_DOUBLE_QUOTE + key.inspect[1..-2] + ESCAPE_DOUBLE_QUOTE
63
+ else
64
+ key
65
+ end
66
+ end
67
+
68
+ def dump_val(val, depth)
69
+ case val
70
+ when Hash then enc_obj(val, depth - 1)
71
+ when Array then enc_arr(val, depth - 1)
72
+ when String, Symbol then enc_str(val)
73
+ when Complex then enc_cpx(val)
74
+ when Float then enc_float(val)
75
+ when Numeric then enc_num(val)
76
+ when Time then enc_time(val)
77
+ when TrueClass then LITERAL_TRUE
78
+ when FalseClass then LITERAL_FALSE
79
+ when NilClass then LITERAL_NIL
80
+ else
81
+ val
82
+ end
83
+ end
84
+
85
+ def enc_obj(obj, depth)
86
+ return LBRACE + ELLIPSIS + RBRACE if depth.zero?
87
+
88
+ LBRACE +
89
+ obj.map { |k, v| "#{dump_key(k)}=#{dump_val(v, depth)}" }
90
+ .join(SPACE) + RBRACE
91
+ end
92
+
93
+ def enc_arr(array, depth)
94
+ return LBRACKET + ELLIPSIS + RBRACKET if depth.zero?
95
+
96
+ LBRACKET + array.map { |v| dump_val(v, depth) }.join(SPACE) + RBRACKET
97
+ end
98
+
99
+ def enc_cpx(val)
100
+ LPAREN + val.to_s + RPAREN
101
+ end
102
+
103
+ def enc_float(val)
104
+ ("%f" % val).sub(/0*?$/, "")
105
+ end
106
+
107
+ def enc_num(val)
108
+ val
109
+ end
110
+
111
+ def enc_str(str)
112
+ str = str.to_s
113
+ case str
114
+ when SINGLE_QUOTE_REGEX
115
+ str.inspect
116
+ when ESCAPE_STR_REGEX, LITERAL_TRUE, LITERAL_FALSE,
117
+ LITERAL_NIL, NUM_REGEX
118
+ ESCAPE_DOUBLE_QUOTE + str.inspect[1..-2] + ESCAPE_DOUBLE_QUOTE
119
+ else
120
+ str
121
+ end
122
+ end
123
+
124
+ def enc_time(time)
125
+ time.strftime("%FT%T%:z")
126
+ end
127
+ end # Text
128
+ end # Formatters
129
+ end # Logger
130
+ end # TTY
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ class Logger
5
+ module Handlers
6
+ module Base
7
+ # Change current log level for the duration of the block
8
+ #
9
+ # @param [String] tmp_level
10
+ # the temporary log level
11
+ #
12
+ # @api public
13
+ def log_at(tmp_level)
14
+ old_level, @level = level, tmp_level
15
+ yield
16
+ ensure
17
+ @level = old_level
18
+ end
19
+
20
+ # Coerce formatter name into constant
21
+ #
22
+ # @api private
23
+ def coerce_formatter(name)
24
+ case name
25
+ when String, Symbol
26
+ const_name = if Formatters.const_defined?(name.upcase)
27
+ name.upcase
28
+ else
29
+ name.capitalize
30
+ end
31
+ Formatters.const_get(const_name)
32
+ when Class
33
+ name
34
+ else
35
+ raise_formatter_error(name)
36
+ end
37
+ rescue NameError
38
+ raise_formatter_error(name)
39
+ end
40
+
41
+ # Raise error when unknown formatter name
42
+ #
43
+ # @api private
44
+ def raise_formatter_error(name)
45
+ raise Error, "Unrecognized formatter name '#{name.inspect}'"
46
+ end
47
+
48
+ # Metadata for the log event
49
+ #
50
+ # @return [Array[Symbol]]
51
+ #
52
+ # @api private
53
+ def metadata
54
+ if config.metadata.include?(:all)
55
+ [:pid, :date, :time, :file]
56
+ else
57
+ config.metadata
58
+ end
59
+ end
60
+
61
+ # Format path from event metadata
62
+ #
63
+ # @return [String]
64
+ #
65
+ # @api private
66
+ def format_filepath(event)
67
+ "%s:%d:in`%s`" % [event.metadata[:path], event.metadata[:lineno],
68
+ event.metadata[:method]]
69
+ end
70
+ end # Base
71
+ end # Handlers
72
+ end # Logger
73
+ end # TTY
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ require_relative "base"
6
+
7
+ module TTY
8
+ class Logger
9
+ module Handlers
10
+ class Console
11
+ include Base
12
+
13
+ ARROW = "›"
14
+
15
+ STYLES = {
16
+ debug: {
17
+ label: "debug",
18
+ symbol: "•",
19
+ color: :cyan,
20
+ levelpad: 2
21
+ },
22
+ info: {
23
+ label: "info",
24
+ symbol: "ℹ",
25
+ color: :green,
26
+ levelpad: 3
27
+ },
28
+ warn: {
29
+ label: "warning",
30
+ symbol: "⚠",
31
+ color: :yellow,
32
+ levelpad: 0
33
+ },
34
+ error: {
35
+ label: "error",
36
+ symbol: "⨯",
37
+ color: :red,
38
+ levelpad: 2
39
+ },
40
+ fatal: {
41
+ label: "fatal",
42
+ symbol: "!",
43
+ color: :red,
44
+ levelpad: 2
45
+ },
46
+ success: {
47
+ label: "success",
48
+ symbol: "✔",
49
+ color: :green,
50
+ levelpad: 0
51
+ },
52
+ wait: {
53
+ label: "waiting",
54
+ symbol: "…",
55
+ color: :cyan,
56
+ levelpad: 0
57
+ }
58
+ }.freeze
59
+
60
+ TEXT_REGEXP = /([{}()\[\]])?(["']?)(\S+?)(["']?=)/.freeze
61
+ JSON_REGEXP = /\"([^,]+?)\"(?=:)/.freeze
62
+
63
+ COLOR_PATTERNS = {
64
+ text: [TEXT_REGEXP, ->(c) { "\\1\\2" + c.("\\3") + "\\4" }],
65
+ json: [JSON_REGEXP, ->(c) { "\"" + c.("\\1") + "\"" }]
66
+ }.freeze
67
+
68
+ # The output stream
69
+ # @api private
70
+ attr_reader :output
71
+
72
+ # The configuration options
73
+ # @api private
74
+ attr_reader :config
75
+
76
+ # The logging level
77
+ # @api private
78
+ attr_reader :level
79
+
80
+ # The format for the message
81
+ # @api private
82
+ attr_reader :message_format
83
+
84
+ def initialize(output: $stderr, formatter: nil, config: nil, level: nil,
85
+ styles: {}, message_format: "%-25s")
86
+ @output = Array[output].flatten
87
+ @formatter = coerce_formatter(formatter || config.formatter).new
88
+ @formatter_name = @formatter.class.name.split("::").last.downcase
89
+ @color_pattern = COLOR_PATTERNS[@formatter_name.to_sym]
90
+ @config = config
91
+ @styles = styles
92
+ @level = level || @config.level
93
+ @mutex = Mutex.new
94
+ @pastel = Pastel.new
95
+ @message_format = message_format
96
+ end
97
+
98
+ # Handle log event output in format
99
+ #
100
+ # @param [Event] event
101
+ # the current event logged
102
+ #
103
+ # @api public
104
+ def call(event)
105
+ @mutex.lock
106
+
107
+ style = configure_styles(event)
108
+ color = configure_color(style)
109
+
110
+ fmt = []
111
+ metadata.each do |meta|
112
+ case meta
113
+ when :date
114
+ fmt << @pastel.white("[" + event.metadata[:time].
115
+ strftime(config.date_format) + "]")
116
+ when :time
117
+ fmt << @pastel.white("[" + event.metadata[:time].
118
+ strftime(config.time_format) + "]")
119
+ when :file
120
+ fmt << @pastel.white("[#{format_filepath(event)}]")
121
+ when :pid
122
+ fmt << @pastel.white("[%d]" % event.metadata[:pid])
123
+ else
124
+ raise "Unknown metadata `#{meta}`"
125
+ end
126
+ end
127
+ fmt << ARROW unless config.metadata.empty?
128
+ unless style.empty?
129
+ fmt << color.(style[:symbol])
130
+ fmt << color.(style[:label]) + (" " * style[:levelpad])
131
+ end
132
+ fmt << message_format % event.message.join(" ")
133
+ unless event.fields.empty?
134
+ pattern, replacement = *@color_pattern
135
+ fmt << @formatter.dump(event.fields, max_bytes: config.max_bytes,
136
+ max_depth: config.max_depth)
137
+ .gsub(pattern, replacement.(color))
138
+ end
139
+ unless event.backtrace.empty?
140
+ fmt << "\n" + format_backtrace(event)
141
+ end
142
+
143
+ output.each { |out| out.puts fmt.join(" ") }
144
+ ensure
145
+ @mutex.unlock
146
+ end
147
+
148
+ private
149
+
150
+ def format_backtrace(event)
151
+ indent = " " * 4
152
+ event.backtrace.map do |bktrace|
153
+ indent + bktrace.to_s
154
+ end.join("\n")
155
+ end
156
+
157
+ # Merge default styles with custom style overrides
158
+ #
159
+ # @return [Hash[String]]
160
+ # the style matching log type
161
+ #
162
+ # @api private
163
+ def configure_styles(event)
164
+ return {} if event.metadata[:name].nil?
165
+
166
+ STYLES.fetch(event.metadata[:name].to_sym, {})
167
+ .dup
168
+ .merge!(@styles[event.metadata[:name].to_sym] || {})
169
+ end
170
+
171
+ def configure_color(style)
172
+ color = style.fetch(:color) { :cyan }
173
+ @pastel.send(color).detach
174
+ end
175
+ end # Console
176
+ end # Handlers
177
+ end # Logger
178
+ end # TTY