semantic_logger 4.18.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +42 -81
- data/Rakefile +10 -3
- data/lib/semantic_logger/appender/async.rb +86 -173
- data/lib/semantic_logger/appender/cloudwatch_logs.rb +4 -4
- data/lib/semantic_logger/appender/elasticsearch.rb +6 -182
- data/lib/semantic_logger/appender/elasticsearch_base.rb +212 -0
- data/lib/semantic_logger/appender/elasticsearch_http.rb +2 -2
- data/lib/semantic_logger/appender/file.rb +20 -4
- data/lib/semantic_logger/appender/graylog.rb +2 -2
- data/lib/semantic_logger/appender/http.rb +27 -2
- data/lib/semantic_logger/appender/io.rb +8 -4
- data/lib/semantic_logger/appender/kafka.rb +2 -2
- data/lib/semantic_logger/appender/loki.rb +2 -4
- data/lib/semantic_logger/appender/mongodb.rb +3 -6
- data/lib/semantic_logger/appender/new_relic_logs.rb +2 -2
- data/lib/semantic_logger/appender/open_telemetry.rb +8 -6
- data/lib/semantic_logger/appender/opensearch.rb +35 -0
- data/lib/semantic_logger/appender/rabbitmq.rb +3 -3
- data/lib/semantic_logger/appender/sentry_ruby.rb +2 -2
- data/lib/semantic_logger/appender/splunk.rb +2 -2
- data/lib/semantic_logger/appender/splunk_http.rb +3 -3
- data/lib/semantic_logger/appender/syslog.rb +5 -4
- data/lib/semantic_logger/appender/tcp.rb +2 -2
- data/lib/semantic_logger/appender/udp.rb +2 -2
- data/lib/semantic_logger/appender/wrapper.rb +4 -4
- data/lib/semantic_logger/appender.rb +30 -19
- data/lib/semantic_logger/appenders.rb +26 -5
- data/lib/semantic_logger/base.rb +100 -21
- data/lib/semantic_logger/concerns/compatibility.rb +2 -2
- data/lib/semantic_logger/core_ext/process.rb +34 -0
- data/lib/semantic_logger/formatters/base.rb +46 -7
- data/lib/semantic_logger/formatters/color.rb +6 -3
- data/lib/semantic_logger/formatters/default.rb +6 -4
- data/lib/semantic_logger/formatters/ecs.rb +151 -0
- data/lib/semantic_logger/formatters/fluentd.rb +15 -4
- data/lib/semantic_logger/formatters/json.rb +6 -1
- data/lib/semantic_logger/formatters/logfmt.rb +2 -2
- data/lib/semantic_logger/formatters/loki.rb +4 -4
- data/lib/semantic_logger/formatters/open_telemetry.rb +15 -5
- data/lib/semantic_logger/formatters/pattern.rb +235 -0
- data/lib/semantic_logger/formatters/raw.rb +2 -2
- data/lib/semantic_logger/formatters/signalfx.rb +2 -2
- data/lib/semantic_logger/formatters/syslog.rb +14 -3
- data/lib/semantic_logger/formatters/syslog_cee.rb +1 -1
- data/lib/semantic_logger/formatters.rb +2 -0
- data/lib/semantic_logger/log.rb +18 -4
- data/lib/semantic_logger/logger.rb +2 -2
- data/lib/semantic_logger/metric/new_relic.rb +2 -2
- data/lib/semantic_logger/metric/signalfx.rb +2 -2
- data/lib/semantic_logger/metric/statsd.rb +2 -2
- data/lib/semantic_logger/processor.rb +21 -0
- data/lib/semantic_logger/queue_processor.rb +369 -0
- data/lib/semantic_logger/reporters/minitest.rb +4 -4
- data/lib/semantic_logger/semantic_logger.rb +103 -11
- data/lib/semantic_logger/subscriber.rb +15 -2
- data/lib/semantic_logger/sync_processor.rb +25 -3
- data/lib/semantic_logger/test/capture_log_events.rb +2 -2
- data/lib/semantic_logger/test/minitest.rb +8 -4
- data/lib/semantic_logger/test/rspec.rb +249 -0
- data/lib/semantic_logger/utils.rb +83 -4
- data/lib/semantic_logger/version.rb +1 -1
- data/lib/semantic_logger.rb +9 -0
- metadata +17 -8
- data/lib/semantic_logger/appender/async_batch.rb +0 -93
|
@@ -2,7 +2,21 @@ require "time"
|
|
|
2
2
|
module SemanticLogger
|
|
3
3
|
module Formatters
|
|
4
4
|
class Base
|
|
5
|
-
attr_accessor :log, :logger, :time_format, :log_host, :log_application, :log_environment, :precision
|
|
5
|
+
attr_accessor :log, :logger, :time_format, :log_host, :log_application, :log_environment, :precision,
|
|
6
|
+
:escape_control_chars
|
|
7
|
+
|
|
8
|
+
# Printable escapes for the most common control characters. Any other
|
|
9
|
+
# control character is escaped to its hexadecimal `\xHH` form by
|
|
10
|
+
# #escape_control_characters.
|
|
11
|
+
CONTROL_CHAR_ESCAPES = {
|
|
12
|
+
"\n" => "\\n",
|
|
13
|
+
"\r" => "\\r",
|
|
14
|
+
"\t" => "\\t",
|
|
15
|
+
"\e" => "\\e"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Matches C0 control characters (including newlines and the ANSI escape) and DEL.
|
|
19
|
+
CONTROL_CHARS = /[\x00-\x1f\x7f]/
|
|
6
20
|
|
|
7
21
|
# Time precision varies by Ruby interpreter
|
|
8
22
|
# JRuby 9.1.8.0 supports microseconds
|
|
@@ -34,16 +48,27 @@ module SemanticLogger
|
|
|
34
48
|
# precision: [Integer]
|
|
35
49
|
# How many fractional digits to log times with.
|
|
36
50
|
# Default: PRECISION (6, except on older JRuby, where 3)
|
|
51
|
+
# escape_control_chars: [Boolean]
|
|
52
|
+
# Replace control characters (newlines, the ANSI escape, etc.) in
|
|
53
|
+
# untrusted log data (message, tags, named tags, and exception
|
|
54
|
+
# message) with a printable escaped form, e.g. "\n".
|
|
55
|
+
# This prevents log forging and terminal escape-sequence injection
|
|
56
|
+
# when logging data that may contain attacker-controlled content.
|
|
57
|
+
# Note: Has no effect on structured formatters such as :json, which
|
|
58
|
+
# already escape control characters via JSON encoding.
|
|
59
|
+
# Default: false (preserve newlines and ANSI colors in text output)
|
|
37
60
|
def initialize(time_format: nil,
|
|
38
61
|
log_host: true,
|
|
39
62
|
log_application: true,
|
|
40
63
|
log_environment: true,
|
|
41
|
-
precision: PRECISION
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
@
|
|
45
|
-
@
|
|
46
|
-
@
|
|
64
|
+
precision: PRECISION,
|
|
65
|
+
escape_control_chars: false)
|
|
66
|
+
@time_format = time_format || self.class.build_time_format(precision)
|
|
67
|
+
@log_host = log_host
|
|
68
|
+
@log_application = log_application
|
|
69
|
+
@log_environment = log_environment
|
|
70
|
+
@precision = precision
|
|
71
|
+
@escape_control_chars = escape_control_chars
|
|
47
72
|
end
|
|
48
73
|
|
|
49
74
|
# Return default time format string
|
|
@@ -68,6 +93,20 @@ module SemanticLogger
|
|
|
68
93
|
|
|
69
94
|
private
|
|
70
95
|
|
|
96
|
+
# When `escape_control_chars` is enabled, return a copy of the supplied
|
|
97
|
+
# value with any control characters replaced by a printable escaped form
|
|
98
|
+
# so that untrusted log data cannot forge log entries or inject terminal
|
|
99
|
+
# escape sequences. Otherwise the value is returned unchanged.
|
|
100
|
+
#
|
|
101
|
+
# Note: This escapes (preserves) control characters, and is opt-in. It is
|
|
102
|
+
# distinct from Log#cleansed_message, which unconditionally *strips* ANSI
|
|
103
|
+
# colorization from the message for structured (JSON/Loki) output.
|
|
104
|
+
def escape_control_characters(value)
|
|
105
|
+
return value unless escape_control_chars && value
|
|
106
|
+
|
|
107
|
+
value.to_s.gsub(CONTROL_CHARS) { |char| CONTROL_CHAR_ESCAPES[char] || format("\\x%02x", char.ord) }
|
|
108
|
+
end
|
|
109
|
+
|
|
71
110
|
# Return the Time as a formatted string
|
|
72
111
|
def format_time(time)
|
|
73
112
|
time = time.dup
|
|
@@ -85,7 +85,10 @@ module SemanticLogger
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def tags
|
|
88
|
-
|
|
88
|
+
return if log.tags.nil? || log.tags.empty?
|
|
89
|
+
|
|
90
|
+
tags = log.tags.map { |tag| escape_control_characters(tag) }
|
|
91
|
+
"[#{color}#{tags.join("#{color_map.clear}] [#{color}")}#{color_map.clear}]"
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
# Named Tags
|
|
@@ -94,7 +97,7 @@ module SemanticLogger
|
|
|
94
97
|
return if named_tags.nil? || named_tags.empty?
|
|
95
98
|
|
|
96
99
|
list = []
|
|
97
|
-
named_tags.each_pair { |name, value| list << "#{color}#{name}: #{value}#{color_map.clear}" }
|
|
100
|
+
named_tags.each_pair { |name, value| list << "#{color}#{escape_control_characters(name)}: #{escape_control_characters(value)}#{color_map.clear}" }
|
|
98
101
|
"{#{list.join(', ')}}"
|
|
99
102
|
end
|
|
100
103
|
|
|
@@ -123,7 +126,7 @@ module SemanticLogger
|
|
|
123
126
|
def exception
|
|
124
127
|
return unless log.exception
|
|
125
128
|
|
|
126
|
-
"-- Exception: #{color}#{log.exception.class}: #{log.exception.message}#{color_map.clear}\n#{log.backtrace_to_s}"
|
|
129
|
+
"-- Exception: #{color}#{log.exception.class}: #{escape_control_characters(log.exception.message)}#{color_map.clear}\n#{log.backtrace_to_s}"
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
def call(log, logger)
|
|
@@ -32,7 +32,9 @@ module SemanticLogger
|
|
|
32
32
|
|
|
33
33
|
# Tags
|
|
34
34
|
def tags
|
|
35
|
-
|
|
35
|
+
return if log.tags.nil? || log.tags.empty?
|
|
36
|
+
|
|
37
|
+
"[#{log.tags.map { |tag| escape_control_characters(tag) }.join('] [')}]"
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Named Tags
|
|
@@ -41,7 +43,7 @@ module SemanticLogger
|
|
|
41
43
|
return if named_tags.nil? || named_tags.empty?
|
|
42
44
|
|
|
43
45
|
list = []
|
|
44
|
-
named_tags.each_pair { |name, value| list << "#{name}: #{value}" }
|
|
46
|
+
named_tags.each_pair { |name, value| list << "#{escape_control_characters(name)}: #{escape_control_characters(value)}" }
|
|
45
47
|
"{#{list.join(', ')}}"
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -57,7 +59,7 @@ module SemanticLogger
|
|
|
57
59
|
|
|
58
60
|
# Log message
|
|
59
61
|
def message
|
|
60
|
-
"-- #{log.message}" if log.message
|
|
62
|
+
"-- #{escape_control_characters(log.message)}" if log.message
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
# Payload
|
|
@@ -70,7 +72,7 @@ module SemanticLogger
|
|
|
70
72
|
|
|
71
73
|
# Exception
|
|
72
74
|
def exception
|
|
73
|
-
"-- Exception: #{log.exception.class}: #{log.exception.message}\n#{log.backtrace_to_s}" if log.exception
|
|
75
|
+
"-- Exception: #{log.exception.class}: #{escape_control_characters(log.exception.message)}\n#{log.backtrace_to_s}" if log.exception
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# Default text log format
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
module SemanticLogger
|
|
3
|
+
module Formatters
|
|
4
|
+
# Formatter conforming to the Elastic Common Schema (ECS).
|
|
5
|
+
#
|
|
6
|
+
# Emits log events using the nested field names defined by ECS so that they
|
|
7
|
+
# integrate cleanly with Filebeat and the Elastic stack (Elasticsearch,
|
|
8
|
+
# Kibana) without requiring an ingest pipeline to rename fields.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# SemanticLogger.add_appender(io: $stdout, formatter: :ecs)
|
|
12
|
+
#
|
|
13
|
+
# # Route the payload, metric, and other SemanticLogger-specific data into
|
|
14
|
+
# # a custom top-level namespace (default "semantic_logger"):
|
|
15
|
+
# SemanticLogger.add_appender(io: $stdout, formatter: {ecs: {namespace: "my_app"}})
|
|
16
|
+
#
|
|
17
|
+
# # Or merge the payload directly into ECS `labels` instead of a namespace:
|
|
18
|
+
# SemanticLogger.add_appender(io: $stdout, formatter: {ecs: {namespace: nil}})
|
|
19
|
+
#
|
|
20
|
+
# == Field mapping (SemanticLogger -> ECS 8.x)
|
|
21
|
+
# time -> @timestamp (ISO-8601)
|
|
22
|
+
# level -> log.level
|
|
23
|
+
# name -> log.logger
|
|
24
|
+
# file_name / line -> log.origin.file.name / log.origin.file.line
|
|
25
|
+
# message -> message
|
|
26
|
+
# thread_name -> process.thread.name
|
|
27
|
+
# pid -> process.pid
|
|
28
|
+
# host -> host.hostname
|
|
29
|
+
# application -> service.name
|
|
30
|
+
# environment -> service.environment
|
|
31
|
+
# exception -> error.type / error.message / error.stack_trace
|
|
32
|
+
# duration -> event.duration (nanoseconds, as required by ECS)
|
|
33
|
+
# tags -> tags (ECS top-level array)
|
|
34
|
+
# named_tags -> labels.* (scalar key/value pairs)
|
|
35
|
+
# payload -> <namespace>.* (or labels.* when namespace is nil)
|
|
36
|
+
# metric/metric_amount -> <namespace>.metric / <namespace>.metric_amount
|
|
37
|
+
#
|
|
38
|
+
# == Reference
|
|
39
|
+
# * https://www.elastic.co/docs/reference/ecs
|
|
40
|
+
# * https://www.elastic.co/docs/reference/ecs/ecs-custom-fields-in-ecs
|
|
41
|
+
class Ecs < Raw
|
|
42
|
+
# ECS version this formatter targets.
|
|
43
|
+
ECS_VERSION = "8.11.0".freeze
|
|
44
|
+
|
|
45
|
+
# namespace: [String|Symbol|nil]
|
|
46
|
+
# Top-level field set used to hold SemanticLogger-specific data that has
|
|
47
|
+
# no native ECS home (payload, metric, metric_amount). A proper-noun
|
|
48
|
+
# namespace is guaranteed never to collide with a current or future ECS
|
|
49
|
+
# field. Set to nil to merge the payload into ECS `labels` instead.
|
|
50
|
+
# Default: "semantic_logger"
|
|
51
|
+
attr_reader :namespace
|
|
52
|
+
|
|
53
|
+
def initialize(namespace: "semantic_logger", time_format: :iso_8601, time_key: :timestamp, **args)
|
|
54
|
+
@namespace = namespace&.to_sym
|
|
55
|
+
|
|
56
|
+
super(time_format: time_format, time_key: time_key, **args)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the log event as a single line of ECS-formatted JSON, so it can
|
|
60
|
+
# be written to stdout / a file and shipped by Filebeat or Elastic Agent.
|
|
61
|
+
def call(log, logger)
|
|
62
|
+
Utils.to_json(ecs_hash(super))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns a batch of log events as a single JSON array.
|
|
66
|
+
def batch(logs, logger)
|
|
67
|
+
"[#{logs.map { |log| call(log, logger) }.join(',')}]"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Remap the flat hash built by Raw#call into the nested ECS field layout.
|
|
73
|
+
def ecs_hash(hash)
|
|
74
|
+
result = base(hash)
|
|
75
|
+
result[:process] = process(hash)
|
|
76
|
+
result[:host] = {hostname: hash[:host]} if hash[:host]
|
|
77
|
+
add_service(result, hash)
|
|
78
|
+
add_origin(result, hash)
|
|
79
|
+
add_event(result, hash)
|
|
80
|
+
result[:tags] = hash[:tags] if hash[:tags]
|
|
81
|
+
add_error(result, hash)
|
|
82
|
+
add_extras(result, hash)
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def base(hash)
|
|
87
|
+
log = {level: hash[:level].to_s}
|
|
88
|
+
log[:logger] = hash[:name] if hash[:name]
|
|
89
|
+
{
|
|
90
|
+
"@timestamp": hash[:timestamp],
|
|
91
|
+
message: hash[:message],
|
|
92
|
+
ecs: {version: ECS_VERSION},
|
|
93
|
+
log: log
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def process(hash)
|
|
98
|
+
result = {pid: hash[:pid]}
|
|
99
|
+
result[:thread] = {name: hash[:thread].to_s} if hash[:thread]
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_service(result, hash)
|
|
104
|
+
service = {}
|
|
105
|
+
service[:name] = hash[:application] if hash[:application]
|
|
106
|
+
service[:environment] = hash[:environment] if hash[:environment]
|
|
107
|
+
result[:service] = service unless service.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_origin(result, hash)
|
|
111
|
+
return unless hash[:file]
|
|
112
|
+
|
|
113
|
+
result[:log][:origin] = {file: {name: hash[:file], line: hash[:line]}.compact}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ECS event.duration is measured in nanoseconds.
|
|
117
|
+
def add_event(result, hash)
|
|
118
|
+
return unless hash[:duration_ms]
|
|
119
|
+
|
|
120
|
+
result[:event] = {duration: (hash[:duration_ms] * 1_000_000).round}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_error(result, hash)
|
|
124
|
+
return unless hash[:exception]
|
|
125
|
+
|
|
126
|
+
result[:error] = {
|
|
127
|
+
type: hash[:exception][:name],
|
|
128
|
+
message: hash[:exception][:message],
|
|
129
|
+
stack_trace: Array(hash[:exception][:stack_trace]).join("\n")
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Place SemanticLogger-specific data (payload, metric) that has no native
|
|
134
|
+
# ECS home into the configured namespace, plus named_tags into labels.
|
|
135
|
+
def add_extras(result, hash)
|
|
136
|
+
labels = hash[:named_tags].is_a?(Hash) ? hash[:named_tags].dup : {}
|
|
137
|
+
|
|
138
|
+
extra = {}
|
|
139
|
+
extra[:payload] = hash[:payload] if hash[:payload]
|
|
140
|
+
extra[:metric] = hash[:metric] if hash[:metric]
|
|
141
|
+
extra[:metric_amount] = hash[:metric_amount] if hash[:metric_amount]
|
|
142
|
+
|
|
143
|
+
unless extra.empty?
|
|
144
|
+
namespace ? result[namespace] = extra : labels.merge!(extra)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
result[:labels] = labels unless labels.empty?
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -7,9 +7,9 @@ module SemanticLogger
|
|
|
7
7
|
class Fluentd < Json
|
|
8
8
|
attr_reader :need_process_info
|
|
9
9
|
|
|
10
|
-
def initialize(time_format: :rfc_3339, time_key: :time, need_process_info: false, **args)
|
|
10
|
+
def initialize(time_format: :rfc_3339, time_key: :time, need_process_info: false, log_host: false, **args)
|
|
11
11
|
@need_process_info = need_process_info
|
|
12
|
-
super(time_format: time_format, time_key: time_key, **args)
|
|
12
|
+
super(time_format: time_format, time_key: time_key, log_host: log_host, **args)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def level
|
|
@@ -17,8 +17,19 @@ module SemanticLogger
|
|
|
17
17
|
hash["severity_index"] = log.level_index
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# Ignore process fields: pid, thread, file and line by default.
|
|
21
|
+
# These are rarely useful under Fluentd (e.g. containerized processes
|
|
22
|
+
# usually have pid 1), so they are only included when explicitly requested
|
|
23
|
+
# via `need_process_info: true`.
|
|
24
|
+
def pid
|
|
25
|
+
super if need_process_info
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def thread_name
|
|
29
|
+
super if need_process_info
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def file_name_and_line
|
|
22
33
|
super if need_process_info
|
|
23
34
|
end
|
|
24
35
|
end
|
|
@@ -9,7 +9,12 @@ module SemanticLogger
|
|
|
9
9
|
|
|
10
10
|
# Returns log messages in JSON format
|
|
11
11
|
def call(log, logger)
|
|
12
|
-
|
|
12
|
+
Utils.to_json(super)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns a batch of log messages as a single JSON array.
|
|
16
|
+
def batch(logs, logger)
|
|
17
|
+
"[#{logs.map { |log| call(log, logger) }.join(',')}]"
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
@@ -64,9 +64,9 @@ module SemanticLogger
|
|
|
64
64
|
flattened = @parsed.map do |key, value|
|
|
65
65
|
case value
|
|
66
66
|
when Hash, Array
|
|
67
|
-
"#{key}=#{value.to_s
|
|
67
|
+
"#{key}=#{Utils.to_json(value.to_s)}"
|
|
68
68
|
else
|
|
69
|
-
"#{key}=#{
|
|
69
|
+
"#{key}=#{Utils.to_json(value)}"
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -10,7 +10,7 @@ module SemanticLogger
|
|
|
10
10
|
self.logger = logger
|
|
11
11
|
self.log = log
|
|
12
12
|
|
|
13
|
-
{streams: [build_stream]}
|
|
13
|
+
Utils.to_json({streams: [build_stream]})
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Returns [String] a JSON batch of logs
|
|
@@ -22,7 +22,7 @@ module SemanticLogger
|
|
|
22
22
|
build_stream
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
{streams: streams}
|
|
25
|
+
Utils.to_json({streams: streams})
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
private
|
|
@@ -82,7 +82,7 @@ module SemanticLogger
|
|
|
82
82
|
|
|
83
83
|
log.context.each do |key, value|
|
|
84
84
|
serialized_value = if value.is_a?(Hash)
|
|
85
|
-
|
|
85
|
+
Utils.to_json(value)
|
|
86
86
|
else
|
|
87
87
|
value.to_s
|
|
88
88
|
end
|
|
@@ -144,7 +144,7 @@ module SemanticLogger
|
|
|
144
144
|
|
|
145
145
|
result[string_key] = case value
|
|
146
146
|
when Hash
|
|
147
|
-
|
|
147
|
+
Utils.to_json(stringify_hash(value))
|
|
148
148
|
else
|
|
149
149
|
value.to_s
|
|
150
150
|
end
|
|
@@ -5,10 +5,19 @@ module SemanticLogger
|
|
|
5
5
|
# primitives allowed by OTLP logs in Ruby: String, Integer, Float, TrueClass, FalseClass
|
|
6
6
|
PRIMS = [String, Integer, Float, TrueClass, FalseClass].freeze
|
|
7
7
|
|
|
8
|
+
# Returns the attributes hash submitted to the OpenTelemetry SDK.
|
|
9
|
+
#
|
|
10
|
+
# Unlike the JSON formatters there is no single `.to_json` boundary here:
|
|
11
|
+
# the hash is handed to the OTLP exporter, which requires valid UTF-8. Cleanse
|
|
12
|
+
# the whole structure so binary / non UTF-8 strings cannot break the export.
|
|
13
|
+
def call(log, logger)
|
|
14
|
+
Utils.encode_utf8(super)
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
# Log level
|
|
9
18
|
def level
|
|
10
19
|
hash[:level] = log.level.to_s
|
|
11
|
-
hash[:level_index] = severity_number(log.
|
|
20
|
+
hash[:level_index] = severity_number(log.level)
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
# Payload is submitted directly as attributes
|
|
@@ -35,10 +44,11 @@ module SemanticLogger
|
|
|
35
44
|
|
|
36
45
|
out[k.to_s] =
|
|
37
46
|
if v.is_a?(Hash)
|
|
38
|
-
# Stringify whole hash.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
# Stringify whole hash. Cleanse here too: this runs while building the
|
|
48
|
+
# hash, before the top-level call cleanse, so it must not raise on its own.
|
|
49
|
+
Utils.to_json(
|
|
50
|
+
v.transform_values { |vv| coerce_value(vv) }.transform_keys!(&:to_s)
|
|
51
|
+
)
|
|
42
52
|
else
|
|
43
53
|
coerce_value(v)
|
|
44
54
|
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module SemanticLogger
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats log messages using a configurable pattern string, so a custom
|
|
6
|
+
# log line layout can be specified directly in the configuration without
|
|
7
|
+
# having to write a new formatter class.
|
|
8
|
+
#
|
|
9
|
+
# Pattern placeholders use the form `%{directive}`, where `directive` is the
|
|
10
|
+
# name of any of the formatting methods (inherited from Default, or defined
|
|
11
|
+
# below). Named tags support a parameterized form: `%{named_tags:request_id}`
|
|
12
|
+
# returns the value of a single named tag. Use `%%{...}` to emit a literal
|
|
13
|
+
# `%{...}` without interpolation.
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
# SemanticLogger.add_appender(
|
|
17
|
+
# io: $stdout,
|
|
18
|
+
# formatter: {
|
|
19
|
+
# pattern: { pattern: "%{time} %{level} %{name} -- %{message}" }
|
|
20
|
+
# }
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# Available directives:
|
|
24
|
+
# time Formatted timestamp. Optionally accepts a strftime
|
|
25
|
+
# format, e.g. time:%Y-%m-%dT%H:%M:%S.%6N.
|
|
26
|
+
# level Full level name, e.g. "debug".
|
|
27
|
+
# level_short Single character level, e.g. "D".
|
|
28
|
+
# name Logger / class name.
|
|
29
|
+
# message Log message.
|
|
30
|
+
# payload Payload rendered as a string.
|
|
31
|
+
# exception_class Class of the logged exception, e.g. "RuntimeError".
|
|
32
|
+
# exception_message Message of the logged exception.
|
|
33
|
+
# backtrace Backtrace of the logged exception.
|
|
34
|
+
# duration Human readable duration, e.g. "1.2ms".
|
|
35
|
+
# duration_ms Duration in milliseconds (numeric).
|
|
36
|
+
# thread_name Name of the thread that logged the message.
|
|
37
|
+
# pid Process id.
|
|
38
|
+
# file_name Ruby file name that logged the message, e.g. "app.rb".
|
|
39
|
+
# line Line number within the Ruby file, e.g. 42.
|
|
40
|
+
# tags Tags, comma separated.
|
|
41
|
+
# named_tags All named tags, or one tag with named_tags:key.
|
|
42
|
+
# host Host name.
|
|
43
|
+
# application Application name.
|
|
44
|
+
# environment Environment name.
|
|
45
|
+
class Pattern < Default
|
|
46
|
+
attr_reader :pattern
|
|
47
|
+
|
|
48
|
+
# Approximates the Default formatter's output.
|
|
49
|
+
DEFAULT_PATTERN = "%{time} %{level} [%{pid}:%{thread_name}] %{name} -- %{message}".freeze
|
|
50
|
+
|
|
51
|
+
# The directives that may appear in a pattern. The value is whether the
|
|
52
|
+
# directive accepts a parameter, e.g. %{named_tags:request_id}.
|
|
53
|
+
DIRECTIVES = {
|
|
54
|
+
time: true,
|
|
55
|
+
level: false,
|
|
56
|
+
level_short: false,
|
|
57
|
+
name: false,
|
|
58
|
+
message: false,
|
|
59
|
+
payload: false,
|
|
60
|
+
exception_class: false,
|
|
61
|
+
exception_message: false,
|
|
62
|
+
backtrace: false,
|
|
63
|
+
duration: false,
|
|
64
|
+
duration_ms: false,
|
|
65
|
+
thread_name: false,
|
|
66
|
+
pid: false,
|
|
67
|
+
file_name: false,
|
|
68
|
+
line: false,
|
|
69
|
+
tags: false,
|
|
70
|
+
named_tags: true,
|
|
71
|
+
host: false,
|
|
72
|
+
application: false,
|
|
73
|
+
environment: false
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# A single interpolated directive within a compiled pattern.
|
|
77
|
+
Token = Struct.new(:method_name, :arguments)
|
|
78
|
+
private_constant :Token
|
|
79
|
+
|
|
80
|
+
# Parameters:
|
|
81
|
+
# pattern: [String]
|
|
82
|
+
# The pattern string used to format every log entry.
|
|
83
|
+
# Default: DEFAULT_PATTERN
|
|
84
|
+
#
|
|
85
|
+
# Plus all the options supported by SemanticLogger::Formatters::Base.
|
|
86
|
+
def initialize(pattern: DEFAULT_PATTERN, **args)
|
|
87
|
+
@pattern = pattern
|
|
88
|
+
super(**args)
|
|
89
|
+
# Parse the pattern once, up front, so that formatting every log entry
|
|
90
|
+
# is just a walk over the pre-compiled tokens (no regex on the hot path).
|
|
91
|
+
# Unknown directives raise here, at configuration time, not per log.
|
|
92
|
+
@tokens = compile(pattern)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Formatted timestamp. With a strftime format argument, e.g.
|
|
96
|
+
# %{time:%Y-%m-%dT%H:%M:%S.%6N}, the time is formatted with that string.
|
|
97
|
+
# Without an argument it uses the formatter's configured time_format.
|
|
98
|
+
def time(format = nil)
|
|
99
|
+
return super() if format.nil?
|
|
100
|
+
|
|
101
|
+
log.time.strftime(format)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Full level name, e.g. "debug" (Default formatter uses the short "D").
|
|
105
|
+
def level
|
|
106
|
+
log.level.to_s
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Single character level, e.g. "D".
|
|
110
|
+
def level_short
|
|
111
|
+
log.level_to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Log message (without the "-- " prefix the Default formatter adds).
|
|
115
|
+
def message
|
|
116
|
+
escape_control_characters(log.message)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Raw payload rendered as a string.
|
|
120
|
+
def payload
|
|
121
|
+
log.payload_to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Class of the logged exception, e.g. "RuntimeError".
|
|
125
|
+
def exception_class
|
|
126
|
+
log.exception&.class
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Message of the logged exception.
|
|
130
|
+
def exception_message
|
|
131
|
+
escape_control_characters(log.exception&.message)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Backtrace of the logged exception.
|
|
135
|
+
def backtrace
|
|
136
|
+
log.backtrace_to_s if log.exception
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Human readable duration (without the Default formatter's surrounding parentheses).
|
|
140
|
+
def duration
|
|
141
|
+
log.duration_human
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Duration in milliseconds.
|
|
145
|
+
def duration_ms
|
|
146
|
+
log.duration
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Name of the Ruby file that logged the message, e.g. "app.rb".
|
|
150
|
+
def file_name
|
|
151
|
+
log.file_name_and_line(true)&.first
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Line number within the Ruby file that logged the message.
|
|
155
|
+
def line
|
|
156
|
+
log.file_name_and_line(true)&.last
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Tags joined by a comma (without the Default formatter's surrounding brackets).
|
|
160
|
+
def tags
|
|
161
|
+
return if log.tags.nil? || log.tags.empty?
|
|
162
|
+
|
|
163
|
+
log.tags.map { |tag| escape_control_characters(tag) }.join(", ")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# With a key: the value of a single named tag, e.g. %{named_tags:request_id}.
|
|
167
|
+
# Without a key: all named tags rendered as "key: value, ...".
|
|
168
|
+
def named_tags(key = nil)
|
|
169
|
+
named = log.named_tags
|
|
170
|
+
return if named.nil? || named.empty?
|
|
171
|
+
|
|
172
|
+
if key
|
|
173
|
+
escape_control_characters(named[key.to_sym] || named[key.to_s])
|
|
174
|
+
else
|
|
175
|
+
named.map { |name, value| "#{escape_control_characters(name)}: #{escape_control_characters(value)}" }.join(", ")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Host name.
|
|
180
|
+
def host
|
|
181
|
+
logger&.host if log_host
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Application name.
|
|
185
|
+
def application
|
|
186
|
+
logger&.application if log_application
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Environment name.
|
|
190
|
+
def environment
|
|
191
|
+
logger&.environment if log_environment
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def call(log, logger)
|
|
195
|
+
self.log = log
|
|
196
|
+
self.logger = logger
|
|
197
|
+
|
|
198
|
+
@tokens.each_with_object(+"") do |token, out|
|
|
199
|
+
out << (token.is_a?(Token) ? public_send(token.method_name, *token.arguments).to_s : token)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# Parse the pattern string into an array of tokens: frozen literal strings
|
|
206
|
+
# and Token structs for each %{directive} placeholder. %%{...} is an escape
|
|
207
|
+
# that produces a literal %{...}.
|
|
208
|
+
def compile(string)
|
|
209
|
+
tokens = []
|
|
210
|
+
pos = 0
|
|
211
|
+
|
|
212
|
+
string.scan(/%%?\{[^}]+\}/) do |match|
|
|
213
|
+
current = Regexp.last_match
|
|
214
|
+
tokens << string[pos...current.begin(0)].freeze if current.begin(0) > pos
|
|
215
|
+
|
|
216
|
+
if match.start_with?("%%")
|
|
217
|
+
tokens << match[1..].freeze
|
|
218
|
+
else
|
|
219
|
+
name, arg = match[/\{([^}]+)\}/, 1].split(":", 2)
|
|
220
|
+
name = name.strip.to_sym
|
|
221
|
+
raise(ArgumentError, "Invalid pattern directive: %{#{name}}") unless DIRECTIVES.key?(name)
|
|
222
|
+
raise(ArgumentError, "%{#{name}} does not accept an argument") if arg && !DIRECTIVES[name]
|
|
223
|
+
|
|
224
|
+
tokens << Token.new(name, arg ? [arg.strip] : []).freeze
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
pos = current.end(0)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
tokens << string[pos..].freeze if pos < string.length
|
|
231
|
+
tokens
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -18,12 +18,12 @@ module SemanticLogger
|
|
|
18
18
|
|
|
19
19
|
# Application name
|
|
20
20
|
def application
|
|
21
|
-
hash[:application] = logger.application if log_application && logger
|
|
21
|
+
hash[:application] = logger.application if log_application && logger&.application
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Environment
|
|
25
25
|
def environment
|
|
26
|
-
hash[:environment] = logger.environment if log_environment && logger
|
|
26
|
+
hash[:environment] = logger.environment if log_environment && logger&.environment
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Date & time
|