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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +42 -81
  3. data/Rakefile +10 -3
  4. data/lib/semantic_logger/appender/async.rb +86 -173
  5. data/lib/semantic_logger/appender/cloudwatch_logs.rb +4 -4
  6. data/lib/semantic_logger/appender/elasticsearch.rb +6 -182
  7. data/lib/semantic_logger/appender/elasticsearch_base.rb +212 -0
  8. data/lib/semantic_logger/appender/elasticsearch_http.rb +2 -2
  9. data/lib/semantic_logger/appender/file.rb +20 -4
  10. data/lib/semantic_logger/appender/graylog.rb +2 -2
  11. data/lib/semantic_logger/appender/http.rb +27 -2
  12. data/lib/semantic_logger/appender/io.rb +8 -4
  13. data/lib/semantic_logger/appender/kafka.rb +2 -2
  14. data/lib/semantic_logger/appender/loki.rb +2 -4
  15. data/lib/semantic_logger/appender/mongodb.rb +3 -6
  16. data/lib/semantic_logger/appender/new_relic_logs.rb +2 -2
  17. data/lib/semantic_logger/appender/open_telemetry.rb +8 -6
  18. data/lib/semantic_logger/appender/opensearch.rb +35 -0
  19. data/lib/semantic_logger/appender/rabbitmq.rb +3 -3
  20. data/lib/semantic_logger/appender/sentry_ruby.rb +2 -2
  21. data/lib/semantic_logger/appender/splunk.rb +2 -2
  22. data/lib/semantic_logger/appender/splunk_http.rb +3 -3
  23. data/lib/semantic_logger/appender/syslog.rb +5 -4
  24. data/lib/semantic_logger/appender/tcp.rb +2 -2
  25. data/lib/semantic_logger/appender/udp.rb +2 -2
  26. data/lib/semantic_logger/appender/wrapper.rb +4 -4
  27. data/lib/semantic_logger/appender.rb +30 -19
  28. data/lib/semantic_logger/appenders.rb +26 -5
  29. data/lib/semantic_logger/base.rb +100 -21
  30. data/lib/semantic_logger/concerns/compatibility.rb +2 -2
  31. data/lib/semantic_logger/core_ext/process.rb +34 -0
  32. data/lib/semantic_logger/formatters/base.rb +46 -7
  33. data/lib/semantic_logger/formatters/color.rb +6 -3
  34. data/lib/semantic_logger/formatters/default.rb +6 -4
  35. data/lib/semantic_logger/formatters/ecs.rb +151 -0
  36. data/lib/semantic_logger/formatters/fluentd.rb +15 -4
  37. data/lib/semantic_logger/formatters/json.rb +6 -1
  38. data/lib/semantic_logger/formatters/logfmt.rb +2 -2
  39. data/lib/semantic_logger/formatters/loki.rb +4 -4
  40. data/lib/semantic_logger/formatters/open_telemetry.rb +15 -5
  41. data/lib/semantic_logger/formatters/pattern.rb +235 -0
  42. data/lib/semantic_logger/formatters/raw.rb +2 -2
  43. data/lib/semantic_logger/formatters/signalfx.rb +2 -2
  44. data/lib/semantic_logger/formatters/syslog.rb +14 -3
  45. data/lib/semantic_logger/formatters/syslog_cee.rb +1 -1
  46. data/lib/semantic_logger/formatters.rb +2 -0
  47. data/lib/semantic_logger/log.rb +18 -4
  48. data/lib/semantic_logger/logger.rb +2 -2
  49. data/lib/semantic_logger/metric/new_relic.rb +2 -2
  50. data/lib/semantic_logger/metric/signalfx.rb +2 -2
  51. data/lib/semantic_logger/metric/statsd.rb +2 -2
  52. data/lib/semantic_logger/processor.rb +21 -0
  53. data/lib/semantic_logger/queue_processor.rb +369 -0
  54. data/lib/semantic_logger/reporters/minitest.rb +4 -4
  55. data/lib/semantic_logger/semantic_logger.rb +103 -11
  56. data/lib/semantic_logger/subscriber.rb +15 -2
  57. data/lib/semantic_logger/sync_processor.rb +25 -3
  58. data/lib/semantic_logger/test/capture_log_events.rb +2 -2
  59. data/lib/semantic_logger/test/minitest.rb +8 -4
  60. data/lib/semantic_logger/test/rspec.rb +249 -0
  61. data/lib/semantic_logger/utils.rb +83 -4
  62. data/lib/semantic_logger/version.rb +1 -1
  63. data/lib/semantic_logger.rb +9 -0
  64. metadata +17 -8
  65. 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
- @time_format = time_format || self.class.build_time_format(precision)
43
- @log_host = log_host
44
- @log_application = log_application
45
- @log_environment = log_environment
46
- @precision = precision
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
- "[#{color}#{log.tags.join("#{color_map.clear}] [#{color}")}#{color_map.clear}]" if log.tags && !log.tags.empty?
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
- "[#{log.tags.join('] [')}]" if log.tags && !log.tags.empty?
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
- def process_info
21
- # Ignore fields: pid, thread, file and line by default
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
- super.to_json
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.to_json}"
67
+ "#{key}=#{Utils.to_json(value.to_s)}"
68
68
  else
69
- "#{key}=#{value.to_json}"
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]}.to_json
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}.to_json
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
- value.to_json
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
- JSON.generate(stringify_hash(value))
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.level_index)
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
- v.transform_values { |vv| coerce_value(vv) }.
40
- transform_keys!(&:to_s).
41
- to_json
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 && logger.application
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 && logger.environment
26
+ hash[:environment] = logger.environment if log_environment && logger&.environment
27
27
  end
28
28
 
29
29
  # Date & time