semantic_logger 5.1.0 → 5.2.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -12
  3. data/Rakefile +15 -4
  4. data/docs/api.md +37 -11
  5. data/docs/appenders.md +123 -39
  6. data/docs/config.md +115 -13
  7. data/docs/index.md +31 -5
  8. data/docs/log.md +19 -11
  9. data/docs/metrics.md +5 -2
  10. data/docs/operations.md +63 -17
  11. data/docs/rails.md +77 -3
  12. data/docs/security.md +183 -16
  13. data/docs/testing.md +5 -3
  14. data/docs/upgrading.md +295 -7
  15. data/lib/semantic_logger/appender/async.rb +18 -5
  16. data/lib/semantic_logger/appender/bugsnag.rb +1 -1
  17. data/lib/semantic_logger/appender/cloudwatch_logs.rb +122 -27
  18. data/lib/semantic_logger/appender/elasticsearch_base.rb +31 -6
  19. data/lib/semantic_logger/appender/elasticsearch_http.rb +1 -1
  20. data/lib/semantic_logger/appender/file.rb +138 -30
  21. data/lib/semantic_logger/appender/graylog.rb +22 -6
  22. data/lib/semantic_logger/appender/honeybadger.rb +1 -1
  23. data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
  24. data/lib/semantic_logger/appender/http.rb +70 -11
  25. data/lib/semantic_logger/appender/io.rb +1 -1
  26. data/lib/semantic_logger/appender/kafka.rb +26 -12
  27. data/lib/semantic_logger/appender/loki.rb +13 -3
  28. data/lib/semantic_logger/appender/mongodb.rb +20 -1
  29. data/lib/semantic_logger/appender/new_relic.rb +1 -1
  30. data/lib/semantic_logger/appender/new_relic_logs.rb +9 -11
  31. data/lib/semantic_logger/appender/open_telemetry.rb +2 -2
  32. data/lib/semantic_logger/appender/rabbitmq.rb +6 -1
  33. data/lib/semantic_logger/appender/sentry.rb +1 -1
  34. data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
  35. data/lib/semantic_logger/appender/splunk.rb +10 -1
  36. data/lib/semantic_logger/appender/syslog.rb +64 -26
  37. data/lib/semantic_logger/appender/tcp.rb +6 -1
  38. data/lib/semantic_logger/appender/udp.rb +11 -1
  39. data/lib/semantic_logger/appender/wrapper.rb +1 -1
  40. data/lib/semantic_logger/appenders.rb +9 -1
  41. data/lib/semantic_logger/base.rb +82 -30
  42. data/lib/semantic_logger/exception_details.rb +50 -0
  43. data/lib/semantic_logger/formatters/base.rb +52 -6
  44. data/lib/semantic_logger/formatters/color.rb +18 -2
  45. data/lib/semantic_logger/formatters/default.rb +22 -5
  46. data/lib/semantic_logger/formatters/logfmt.rb +153 -21
  47. data/lib/semantic_logger/formatters/loki.rb +26 -22
  48. data/lib/semantic_logger/formatters/one_line.rb +67 -3
  49. data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
  50. data/lib/semantic_logger/formatters/pattern.rb +8 -4
  51. data/lib/semantic_logger/formatters/raw.rb +4 -5
  52. data/lib/semantic_logger/formatters/syslog.rb +110 -18
  53. data/lib/semantic_logger/formatters/syslog_cee.rb +29 -16
  54. data/lib/semantic_logger/levels.rb +10 -1
  55. data/lib/semantic_logger/log.rb +183 -37
  56. data/lib/semantic_logger/loggable.rb +26 -3
  57. data/lib/semantic_logger/metric/new_relic.rb +1 -1
  58. data/lib/semantic_logger/metric/signalfx.rb +8 -6
  59. data/lib/semantic_logger/metric/statsd.rb +15 -7
  60. data/lib/semantic_logger/queue_processor/request.rb +55 -0
  61. data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
  62. data/lib/semantic_logger/queue_processor.rb +165 -66
  63. data/lib/semantic_logger/semantic_logger.rb +15 -2
  64. data/lib/semantic_logger/subscriber.rb +96 -10
  65. data/lib/semantic_logger/sync_processor.rb +11 -0
  66. data/lib/semantic_logger/test/capture_log_events.rb +8 -5
  67. data/lib/semantic_logger/utils.rb +244 -16
  68. data/lib/semantic_logger/version.rb +1 -1
  69. data/lib/semantic_logger.rb +1 -1
  70. metadata +21 -5
  71. data/lib/semantic_logger/core_ext/thread.rb +0 -30
@@ -31,7 +31,13 @@ module SemanticLogger
31
31
  class Http < SemanticLogger::Subscriber
32
32
  attr_accessor :username, :compress, :header,
33
33
  :open_timeout, :read_timeout, :continue_timeout
34
- attr_reader :http, :url, :server, :port, :path, :ssl_options, :proxy_url
34
+ attr_reader :http, :url, :server, :port, :path, :query, :ssl_options, :proxy_url
35
+
36
+ # Maximum number of bytes of an error response body to write to the internal logger.
37
+ MAX_LOGGED_RESPONSE_BODY = 512
38
+
39
+ # Describes a valid url in the error raised for an invalid one.
40
+ URL_FORMAT_HINT = "Should be similar to: 'http://hostname:port/path'".freeze
35
41
 
36
42
  # Create HTTP(S) log appender
37
43
  #
@@ -42,6 +48,9 @@ module SemanticLogger
42
48
  # To enable SSL include https in the URL.
43
49
  # Example: https://example.com/some_path
44
50
  # verify_mode will default: OpenSSL::SSL::VERIFY_PEER
51
+ # A query string is kept and sent with every request that submits log data,
52
+ # for the log servers that are configured through it.
53
+ # Example: http://example.com/some_path?source=my_app
45
54
  #
46
55
  # application: [String]
47
56
  # Name of this application to appear in log messages.
@@ -136,11 +145,10 @@ module SemanticLogger
136
145
  }.merge(header)
137
146
  @header["Content-Encoding"] = "gzip" if @compress
138
147
 
139
- uri = URI.parse(@url)
148
+ uri = parse_url(@url)
140
149
  @server = uri.host
141
- unless @server
142
- raise(ArgumentError, "Invalid format for :url: #{@url.inspect}. Should be similar to: 'http://hostname:port/path'")
143
- end
150
+ # The url is left out of the message, since it can hold a user and password.
151
+ raise(ArgumentError, "Invalid format for :url, it has no host. #{URL_FORMAT_HINT}") unless @server
144
152
 
145
153
  @port = uri.port
146
154
  @username = uri.user if !@username && uri.user
@@ -148,6 +156,11 @@ module SemanticLogger
148
156
  @path = uri.path
149
157
  # Path cannot be empty
150
158
  @path = "/" if @path == ""
159
+ # Query string of the url, applied to every request, see #uri_with_query.
160
+ # Kept separate from the path, because subclasses build their own request paths
161
+ # from it. For example Appender::ElasticsearchHttp appends the index name.
162
+ @query = uri.query
163
+ @query = nil if @query == ""
151
164
 
152
165
  if uri.scheme == "https"
153
166
  @ssl_options[:use_ssl] = true
@@ -157,7 +170,7 @@ module SemanticLogger
157
170
  @port ||= HTTP.http_default_port
158
171
  end
159
172
 
160
- @proxy_uri = URI.parse(@proxy_url) if @proxy_url && @proxy_url != :ENV
173
+ @proxy_uri = Utils.parse_url(@proxy_url, option: ":proxy_url") if @proxy_url && @proxy_url != :ENV
161
174
 
162
175
  @http = nil
163
176
 
@@ -196,7 +209,7 @@ module SemanticLogger
196
209
 
197
210
  # Forward log messages to HTTP Server
198
211
  def log(log)
199
- message = formatter.call(log, self)
212
+ message = format_log(log)
200
213
  logger.trace(message)
201
214
  post(message)
202
215
  end
@@ -204,7 +217,7 @@ module SemanticLogger
204
217
  # Forward a batch of log messages to the HTTP Server in a single request.
205
218
  # Only used when the appender is created with `batch: true`.
206
219
  def batch(logs)
207
- message = formatter.batch(logs, self)
220
+ message = format_batch(logs)
208
221
  logger.trace(message)
209
222
  post(message)
210
223
  end
@@ -216,8 +229,30 @@ module SemanticLogger
216
229
  false
217
230
  end
218
231
 
232
+ # The whole batch is formatted into a single request before anything is sent.
233
+ def batch_all_or_nothing?
234
+ true
235
+ end
236
+
237
+ # Returns [String] the scheme, host, and any non-default port of the url. Safe to log.
238
+ # See Utils.redacted_url.
239
+ def redacted_url
240
+ @redacted_url ||= Utils.redacted_url(url)
241
+ end
242
+
243
+ # Only the redacted url, since the instance variables hold the password, the authorization
244
+ # headers, and any credential a subclass adds. See Subscriber#inspect.
245
+ def inspect_details
246
+ "url=#{redacted_url.inspect}"
247
+ end
248
+
219
249
  private
220
250
 
251
+ # Returns [URI] the parsed url, without the url in the error raised for an invalid one.
252
+ def parse_url(url)
253
+ Utils.parse_url(url, hint: URL_FORMAT_HINT)
254
+ end
255
+
221
256
  # Use JSON Formatter by default
222
257
  def default_formatter
223
258
  SemanticLogger::Formatters::Json.new
@@ -233,22 +268,43 @@ module SemanticLogger
233
268
 
234
269
  # HTTP Post
235
270
  def post(body, request_uri = path)
236
- request = Net::HTTP::Post.new(request_uri, @header)
271
+ request = Net::HTTP::Post.new(uri_with_query(request_uri), @header)
237
272
  process_request(request, body)
238
273
  end
239
274
 
240
275
  # HTTP Put
241
276
  def put(body, request_uri = path)
242
- request = Net::HTTP::Put.new(request_uri, @header)
277
+ request = Net::HTTP::Put.new(uri_with_query(request_uri), @header)
243
278
  process_request(request, body)
244
279
  end
245
280
 
246
281
  # HTTP Delete
282
+ #
283
+ # The query string of the configured url is _not_ applied here: it configures how
284
+ # the server ingests log data, and servers reject it on a maintenance request.
285
+ # Appender::ElasticsearchHttp#delete_all deletes an index, and Elasticsearch fails
286
+ # the request with a 400 when it carries unrecognized parameters.
247
287
  def delete(request_uri = path)
248
288
  request = Net::HTTP::Delete.new(request_uri, @header)
249
289
  process_request(request)
250
290
  end
251
291
 
292
+ # Applies the query string of the configured url, if any, to the uri of a request
293
+ # that submits log data.
294
+ #
295
+ # Kept out of `path` on purpose: subclasses build their own request paths from it
296
+ # (Appender::ElasticsearchHttp appends the index name and `_doc` to it), and the
297
+ # query has to stay at the end of the request uri regardless.
298
+ #
299
+ # @param request_uri [String] path of the request, `path` by default.
300
+ # @return [String]
301
+ def uri_with_query(request_uri)
302
+ return request_uri unless @query
303
+
304
+ separator = request_uri.include?("?") ? "&" : "?"
305
+ "#{request_uri}#{separator}#{@query}"
306
+ end
307
+
252
308
  # Process HTTP Request
253
309
  def process_request(request, body = nil)
254
310
  if body
@@ -260,7 +316,10 @@ module SemanticLogger
260
316
  true
261
317
  else
262
318
  # Failures are logged to the global semantic logger failsafe logger (Usually stderr or file)
263
- logger.error("Bad HTTP response from: #{url} code: #{response.code}, #{response.body}")
319
+ logger.error(
320
+ "Bad HTTP response from: #{redacted_url} code: #{response.code}, " \
321
+ "#{response.body.to_s[0, MAX_LOGGED_RESPONSE_BODY].inspect}"
322
+ )
264
323
  false
265
324
  end
266
325
  rescue RuntimeError => e
@@ -50,7 +50,7 @@ module SemanticLogger
50
50
  # Since only one appender thread will be writing to the file at a time
51
51
  # it is not necessary to protect access to the file with a semaphore
52
52
  # Allow this logger to filter out log levels lower than it's own
53
- @io.write(formatter.call(log, self) << "\n")
53
+ @io.write(format_log(log) << "\n")
54
54
  true
55
55
  end
56
56
 
@@ -23,6 +23,9 @@ require "date"
23
23
  module SemanticLogger
24
24
  module Appender
25
25
  class Kafka < SemanticLogger::Subscriber
26
+ SEED_BROKERS_HINT = "Each should be similar to: 'hostname:port' or 'kafka://hostname:port'".freeze
27
+ private_constant :SEED_BROKERS_HINT
28
+
26
29
  attr_accessor :seed_brokers, :client_id, :connect_timeout, :socket_timeout,
27
30
  :ssl_ca_cert, :ssl_client_cert, :ssl_client_cert_key, :ssl_ca_certs_from_system,
28
31
  :delivery_threshold, :delivery_interval, :required_acks,
@@ -149,17 +152,22 @@ module SemanticLogger
149
152
  end
150
153
 
151
154
  def reopen
152
- @kafka = ::Kafka.new(
153
- seed_brokers: seed_brokers,
154
- client_id: client_id,
155
- connect_timeout: connect_timeout,
156
- socket_timeout: socket_timeout,
157
- ssl_ca_cert: ssl_ca_cert,
158
- ssl_client_cert: ssl_client_cert,
159
- ssl_client_cert_key: ssl_client_cert_key,
160
- ssl_ca_certs_from_system: ssl_ca_certs_from_system,
161
- logger: logger
162
- )
155
+ # ruby-kafka puts the seed broker, which can hold credentials, in the error it raises for an
156
+ # invalid one: URI::InvalidURIError, or Kafka::Error for an unknown scheme.
157
+ errors = [URI::InvalidURIError, ::Kafka::Error]
158
+ @kafka = Utils.without_url_in_errors(option: ":seed_brokers", hint: SEED_BROKERS_HINT, errors: errors) do
159
+ ::Kafka.new(
160
+ seed_brokers: seed_brokers,
161
+ client_id: client_id,
162
+ connect_timeout: connect_timeout,
163
+ socket_timeout: socket_timeout,
164
+ ssl_ca_cert: ssl_ca_cert,
165
+ ssl_client_cert: ssl_client_cert,
166
+ ssl_client_cert_key: ssl_client_cert_key,
167
+ ssl_ca_certs_from_system: ssl_ca_certs_from_system,
168
+ logger: logger
169
+ )
170
+ end
163
171
 
164
172
  @producer = @kafka.async_producer(
165
173
  delivery_threshold: delivery_threshold,
@@ -177,7 +185,7 @@ module SemanticLogger
177
185
 
178
186
  # Forward log messages to Kafka producer thread.
179
187
  def log(log)
180
- json = formatter.call(log, self)
188
+ json = format_log(log)
181
189
  @producer.produce(json, topic: topic, partition: partition, partition_key: partition_key, key: key)
182
190
  end
183
191
 
@@ -195,6 +203,12 @@ module SemanticLogger
195
203
  )
196
204
  end
197
205
 
206
+ # Only the topic: the seed brokers can hold credentials, and the options hold the TLS private
207
+ # key and SASL passwords. See Subscriber#inspect.
208
+ def inspect_details
209
+ "topic=#{@topic.inspect}"
210
+ end
211
+
198
212
  private
199
213
 
200
214
  attr_reader :producer
@@ -33,6 +33,8 @@ module SemanticLogger
33
33
  # url: [String]
34
34
  # Define the loki instance URL.
35
35
  # Example: https://logs-prod-999.grafana.net
36
+ # A query string is kept and sent with every request.
37
+ # Example: https://logs-prod-999.grafana.net?orgid=my_org
36
38
  # Default: nil
37
39
  def initialize(url: nil,
38
40
  formatter: SemanticLogger::Formatters::Loki.new,
@@ -40,18 +42,26 @@ module SemanticLogger
40
42
  path: INGESTION_PATH,
41
43
  **args,
42
44
  &)
43
- super(url: "#{url}/#{path}", formatter: formatter, header: header, **args, &)
45
+ super(url: self.class.ingestion_url(url, path), formatter: formatter, header: header, **args, &)
46
+ end
47
+
48
+ # Appends the ingestion path to the supplied url, keeping any query string at the
49
+ # end of the url where Appender::Http can parse it out.
50
+ def self.ingestion_url(url, path)
51
+ uri = Utils.parse_url(url.to_s)
52
+ uri.path = "#{uri.path.chomp('/')}/#{path.delete_prefix('/')}"
53
+ uri.to_s
44
54
  end
45
55
 
46
56
  def log(log)
47
- message = formatter.call(log, self)
57
+ message = format_log(log)
48
58
  logger.trace(message)
49
59
  post(message)
50
60
  end
51
61
 
52
62
  # Logs in batches
53
63
  def batch(logs)
54
- message = formatter.batch(logs, self)
64
+ message = format_batch(logs)
55
65
  logger.trace(message)
56
66
  post(message)
57
67
  end
@@ -164,10 +164,29 @@ module SemanticLogger
164
164
  # Log the message to MongoDB
165
165
  def log(log)
166
166
  # Insert log entry into Mongo
167
- collection.insert_one(formatter.call(log, self))
167
+ collection.insert_one(format_log(log))
168
168
  true
169
169
  end
170
170
 
171
+ # Errors raised for a document that cannot be stored, which retrying cannot insert either.
172
+ # BSON raises BSON::Error, ArgumentError (a key with a NUL byte), or RangeError (an Integer too
173
+ # large for 64 bits) while the document is serialized, before anything is sent, and the driver
174
+ # raises Mongo::Error::MaxBSONSize for a document larger than the server accepts.
175
+ DOCUMENT_ERRORS = [BSON::Error, ArgumentError, RangeError, Mongo::Error::MaxBSONSize].freeze
176
+
177
+ # Except BSON::Error::BSONDecodeError, which comes from reading the server's reply rather than
178
+ # from the document, so it is retried like any other error from the server.
179
+ def message_error?(exception)
180
+ return false if exception.is_a?(BSON::Error::BSONDecodeError)
181
+
182
+ super || DOCUMENT_ERRORS.any? { |error| exception.is_a?(error) }
183
+ end
184
+
185
+ # Only the collection name: the uri can hold a user and password. See Subscriber#inspect.
186
+ def inspect_details
187
+ "collection=#{@collection_name.inspect}"
188
+ end
189
+
171
190
  private
172
191
 
173
192
  def default_formatter
@@ -64,7 +64,7 @@ module SemanticLogger
64
64
  # For more documentation on the NewRelic::Agent.notice_error method see:
65
65
  # http://rubydoc.info/github/newrelic/rpm/NewRelic/Agent#notice_error-instance_method
66
66
  # and https://docs.newrelic.com/docs/ruby/ruby-agent-api
67
- ::NewRelic::Agent.notice_error(exception, formatter.call(log, self))
67
+ ::NewRelic::Agent.notice_error(exception, format_log(log))
68
68
  true
69
69
  end
70
70
  end
@@ -57,17 +57,15 @@ module SemanticLogger
57
57
 
58
58
  # Send an error notification to New Relic
59
59
  def log(log)
60
- begin
61
- message = formatter.call(log, self) # Generate the structured log
62
- json_message = Utils.to_json(message) # Convert the log to JSON, repairing any non UTF-8 data
63
- level = log.level.to_s.upcase # Determine the log level
64
- self.class.log_newrelic(json_message, level)
65
- rescue JSON::GeneratorError => e
66
- warn("Failed to serialize log message to JSON: #{e.message}")
67
- warn("Problematic data: #{message.inspect}")
68
- rescue StandardError => e
69
- warn("Unexpected error while logging to New Relic: #{e.message}")
70
- end
60
+ message = format_log(log) # Generate the structured log
61
+ json_message = Utils.to_json(message) # Convert the log to JSON, repairing any non UTF-8 data
62
+ level = log.level.to_s.upcase # Determine the log level
63
+ self.class.log_newrelic(json_message, level)
64
+ true
65
+ rescue StandardError => e
66
+ # Reported to the internal logger: this appender's own #warn would log the error through
67
+ # this method again, recursing until SystemStackError.
68
+ logger.warn("Could not log to New Relic", e)
71
69
  true
72
70
  end
73
71
 
@@ -48,7 +48,7 @@ module SemanticLogger
48
48
  def log(log)
49
49
  # return log_metric(log) if metrics && log.metric_only?
50
50
 
51
- body = formatter.call(log, self)
51
+ body = format_log(log)
52
52
  level = body.delete(:level)
53
53
  level_index = body.delete(:level_index)
54
54
  time = body.delete(:time)
@@ -89,7 +89,7 @@ module SemanticLogger
89
89
  # For logging metrics only log events.
90
90
  # def log_metric(log)
91
91
  # puts "**** TODO: Metric Only Event ****"
92
- # ap formatter.call(log, self)
92
+ # ap format_log(log)
93
93
  # ap log.payload
94
94
  # true
95
95
  # end
@@ -107,7 +107,7 @@ module SemanticLogger
107
107
  end
108
108
 
109
109
  def log(log)
110
- queue.publish(formatter.call(log, self))
110
+ queue.publish(format_log(log))
111
111
  end
112
112
 
113
113
  def flush
@@ -122,6 +122,11 @@ module SemanticLogger
122
122
  def queue
123
123
  @queue ||= @channel.queue(@queue_name)
124
124
  end
125
+
126
+ # Only the queue name: the connection options hold the password. See Subscriber#inspect.
127
+ def inspect_details
128
+ "queue_name=#{@queue_name.inspect}"
129
+ end
125
130
  end
126
131
  end
127
132
  end
@@ -54,7 +54,7 @@ module SemanticLogger
54
54
  # Ignore logs coming from Raven itself
55
55
  return false if log.name == "Raven"
56
56
 
57
- context = formatter.call(log, self)
57
+ context = format_log(log)
58
58
  user = context.delete(:user)
59
59
  tags = context.delete(:tags)
60
60
  attrs = {
@@ -12,6 +12,15 @@ end
12
12
  module SemanticLogger
13
13
  module Appender
14
14
  class SentryRuby < SemanticLogger::Subscriber
15
+ # Keys of the named tags or payload that set the Sentry user, mapped to its fields.
16
+ USER_KEYS = {user_id: :id, username: :username, user_email: :email, ip_address: :ip_address}.freeze
17
+
18
+ # The types of value taken from the payload for a field of the Sentry user.
19
+ USER_VALUE_TYPES = [String, Symbol, Integer].freeze
20
+
21
+ # The types of value accepted in a fingerprint, which Sentry turns into Strings.
22
+ FINGERPRINT_PART_TYPES = [String, Symbol, Numeric].freeze
23
+
15
24
  # Create Appender
16
25
  #
17
26
  # Parameters
@@ -54,16 +63,19 @@ module SemanticLogger
54
63
  # Ignore logs coming from Sentry itself
55
64
  return false if log.name == "Sentry"
56
65
 
57
- context = formatter.call(log, self)
58
- payload = context.delete(:payload) || {}
59
- named_tags = context[:named_tags] || {}
60
- transaction_name = named_tags.delete(:transaction_name)
66
+ context = format_log(log)
67
+ # Copies, since the payload and named tags are the log entry's own, which every other
68
+ # appender writes after this one, and the payload can be the application's own Hash. The
69
+ # keys taken out below must only be left out of what is sent to Sentry.
70
+ payload = payload_copy(context.delete(:payload))
71
+ named_tags = (context[:named_tags] || {}).dup
72
+ context[:named_tags] = named_tags
73
+ transaction_name = named_tags.delete(:transaction_name)
74
+ fingerprint = extract_fingerprint!(named_tags) || extract_fingerprint!(payload, from_payload: true)
61
75
 
62
76
  user = extract_user!(named_tags, payload)
63
77
  tags = extract_tags!(context)
64
78
 
65
- fingerprint = payload.delete(:fingerprint)
66
-
67
79
  ::Sentry.with_scope do |scope|
68
80
  scope.set_user(user) if user
69
81
  scope.set_level(context.delete(:level)) if context[:level]
@@ -100,26 +112,88 @@ module SemanticLogger
100
112
  # Any additional value nested in a :user key will be added, provided any of
101
113
  # the above keys is already present.
102
114
  #
103
- def extract_user!(*sources)
104
- keys = {user_id: :id, username: :username, user_email: :email, ip_address: :ip_address}
105
-
115
+ # The payload can hold untrusted data, such as a request body, so taking the user from it is
116
+ # deprecated, and the named tags win: a payload field is only taken when the named tags do not
117
+ # set it, and its value is a String, Symbol, or Integer. A payload value that is not taken is
118
+ # left where it is, and sent to Sentry as data.
119
+ def extract_user!(named_tags, payload)
106
120
  user = {}
107
121
 
108
- sources.each do |source|
109
- keys.each do |source_key, target_key|
110
- value = source.delete(source_key)
111
- user[target_key] = value if value
122
+ USER_KEYS.each do |source_key, target_key|
123
+ value = named_tags.delete(source_key)
124
+ if value
125
+ user[target_key] = value
126
+ elsif user_value?(payload[source_key])
127
+ user[target_key] = payload.delete(source_key)
128
+ payload_context_deprecated
112
129
  end
113
130
  end
114
131
 
115
132
  return if user.empty?
116
133
 
117
- sources.each do |source|
118
- extras = source.delete(:user)
119
- user.merge!(extras) if extras.is_a?(Hash)
134
+ user.merge!(named_tags.delete(:user)) if named_tags[:user].is_a?(Hash)
135
+ merge_payload_user!(user, payload)
136
+ user
137
+ end
138
+
139
+ # Adds the fields of a :user Hash in the payload that the user does not have yet, when each of
140
+ # its keys is a Symbol or String, and each of its values a String, Symbol, or Integer.
141
+ def merge_payload_user!(user, payload)
142
+ extras = payload[:user]
143
+ return unless extras.is_a?(Hash) && extras.all? { |key, value| user_key?(key) && user_value?(value) }
144
+
145
+ payload.delete(:user)
146
+ payload_context_deprecated
147
+ extras.each do |key, value|
148
+ user[key.to_sym] = value unless user.key?(key.to_sym) || user.key?(key.to_s)
120
149
  end
150
+ end
121
151
 
122
- user
152
+ def user_key?(key)
153
+ key.is_a?(Symbol) || key.is_a?(String)
154
+ end
155
+
156
+ def user_value?(value)
157
+ USER_VALUE_TYPES.any? { |type| value.is_a?(type) }
158
+ end
159
+
160
+ # Returns [Array<String>] the fingerprint taken from the named tags or payload, an Array of
161
+ # Strings, Symbols, or numbers. Returns nil when there is none, or it is of another type, which
162
+ # is then left where it is and sent to Sentry as data. As for the user, taking it from the
163
+ # payload is deprecated.
164
+ def extract_fingerprint!(source, from_payload: false)
165
+ fingerprint = source[:fingerprint]
166
+ return unless fingerprint.is_a?(Array) && fingerprint.all? { |part| fingerprint_part?(part) }
167
+
168
+ payload_context_deprecated if from_payload
169
+ source.delete(:fingerprint).map(&:to_s)
170
+ end
171
+
172
+ def fingerprint_part?(part)
173
+ FINGERPRINT_PART_TYPES.any? { |type| part.is_a?(type) }
174
+ end
175
+
176
+ # Returns [Hash] a copy of the payload that keys can be taken out of. A payload that is not a
177
+ # Hash is sent to Sentry as the extra named payload.
178
+ def payload_copy(payload)
179
+ case payload
180
+ when nil then {}
181
+ when Hash then payload.dup
182
+ else {payload: payload}
183
+ end
184
+ end
185
+
186
+ # Warns once that the Sentry user or fingerprint was taken from the payload.
187
+ def payload_context_deprecated
188
+ return if @payload_context_deprecated
189
+
190
+ @payload_context_deprecated = true
191
+ Kernel.warn(
192
+ "SemanticLogger::Appender::SentryRuby took the Sentry user or fingerprint from the log payload. " \
193
+ "This is deprecated and will be removed in v6, since the payload can hold untrusted data: " \
194
+ "set them with named tags instead, for example SemanticLogger.tagged(user_id: user.id).",
195
+ category: :deprecated
196
+ )
123
197
  end
124
198
 
125
199
  # Extract tags.
@@ -9,6 +9,10 @@ end
9
9
  #
10
10
  # Use the official splunk gem to log messages to Splunk.
11
11
  #
12
+ # Deprecated: will be removed in v6. Use the :splunk_http appender instead.
13
+ # The splunk-sdk-ruby gem (last released in 2015) does not verify the server's TLS certificate,
14
+ # so its connection, including the login credentials, is open to interception.
15
+ #
12
16
  # Example
13
17
  # SemanticLogger.add_appender(
14
18
  # appender: :splunk,
@@ -90,6 +94,11 @@ module SemanticLogger
90
94
  # Proc: Only include log messages where the supplied Proc returns true
91
95
  # The Proc must return true or false.
92
96
  def initialize(index: "main", source_type: nil, **args, &)
97
+ Kernel.warn(
98
+ "SemanticLogger::Appender::Splunk (appender: :splunk) is deprecated and will be removed in v6. " \
99
+ "Its splunk-sdk-ruby gem does not verify TLS certificates. Use appender: :splunk_http instead.",
100
+ category: :deprecated
101
+ )
93
102
  @index = index
94
103
  @source_type = source_type
95
104
 
@@ -109,7 +118,7 @@ module SemanticLogger
109
118
 
110
119
  # Log the message to Splunk
111
120
  def log(log)
112
- event = formatter.call(log, self)
121
+ event = format_log(log)
113
122
  service_index.submit(event.delete(:message), event)
114
123
  true
115
124
  end