logtail-rack 0.2.7 → 0.2.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f416be58cf3b8f82cdd582233d0de6e3b88653f11fbfdc76800dd60aadb9f55
4
- data.tar.gz: 59cfce37f93ffbee0442c8a2d257d444e578a604f7685c3dc0371e4fee2b97c2
3
+ metadata.gz: 4df8d3a1e2937eb55804fc33386d4fff4225cb262e3aee05730dc8d34cb0ea3f
4
+ data.tar.gz: 36d88de7aeffcc49e185f2dc9149e4fc7811f113ee46e65c22d5410bbca539da
5
5
  SHA512:
6
- metadata.gz: c70fddaa55215c0c43ca728cee93d7c6c77d07b2ee69e85b2923dc72cdd572e674f795cd2f2f1da543a1f0364308e94e51385fbaaa03879741a45430fd62523c
7
- data.tar.gz: a074cb26984e9d0802b9aae58cd7961a0e894ae0095655ffc6f7e9d4aec7f4c7a428f7b359b13ac3fe99d079c01e917e74594d93a1afe5509d385fbefe57b96c
6
+ metadata.gz: 7e0e826ff231eaffa61f7a25846321cb9e39966f62d223f9f9d7bf290b80faacd504fa6230b2a314ab54f0bee4d6ac923d1b2431af81c64f47400b9eb0280fc3
7
+ data.tar.gz: 5ad21673f3fa6afbd3f14f839637a5fafa89ba6f7d4c87eda2461b8a58ac62de72e5fb80328a93a62e8f60d94cf14912d700597494063b5dffbe8774c74240d8
data/Gemfile CHANGED
@@ -5,3 +5,5 @@ gemspec
5
5
  gem "base64" if RUBY_VERSION >= "3.4.0"
6
6
  # No longer a default gem on Ruby 4.0 and TruffleRuby 40, and logtail 0.1.17 requires it undeclared
7
7
  gem "logger"
8
+ # Rack 3 moved the session middlewares the tests use into this gem; its 1.x releases for older Racks are empty
9
+ gem "rack-session"
@@ -17,7 +17,7 @@ module Logtail
17
17
  error_message: exception.message,
18
18
  backtrace: exception.backtrace
19
19
  )
20
- end
20
+ end rescue logging_failed($!)
21
21
 
22
22
  raise exception
23
23
  end
@@ -1,3 +1,4 @@
1
+ require "logtail/config"
1
2
  require "logtail/contexts/http"
2
3
  require "logtail/current_context"
3
4
  require "logtail-rack/middleware"
@@ -10,19 +11,30 @@ module Logtail
10
11
  # A Rack middleware that is reponsible for adding the HTTP context {Logtail::Contexts::HTTP}.
11
12
  class HTTPContext < Middleware
12
13
  def call(env)
13
- request = Util::Request.new(env)
14
- context = Contexts::HTTP.new(
15
- host: Util::Encoding.force_utf8_encoding(request.host),
16
- method: Util::Encoding.force_utf8_encoding(request.request_method),
17
- path: request.path,
18
- remote_addr: Util::Encoding.force_utf8_encoding(request.ip),
19
- request_id: request.request_id
20
- )
21
-
22
- CurrentContext.with(context.to_hash) do
14
+ context = get_http_context(env)
15
+ if context
16
+ CurrentContext.with(context) do
17
+ @app.call(env)
18
+ end
19
+ else
23
20
  @app.call(env)
24
21
  end
25
22
  end
23
+
24
+ private
25
+ def get_http_context(env)
26
+ request = Util::Request.new(env)
27
+ Contexts::HTTP.new(
28
+ host: Util::Encoding.force_utf8_encoding(request.host),
29
+ method: Util::Encoding.force_utf8_encoding(request.request_method),
30
+ path: request.path,
31
+ remote_addr: Util::Encoding.force_utf8_encoding(request.ip),
32
+ request_id: request.request_id
33
+ ).to_hash
34
+ rescue StandardError => e
35
+ Logtail::Config.instance.debug { "Could not build the HTTP context: #{e.inspect}\n\n#{e.backtrace}" }
36
+ nil
37
+ end
26
38
  end
27
39
  end
28
40
  end
@@ -1,4 +1,5 @@
1
1
  require "set"
2
+ require "uri"
2
3
 
3
4
  require "logtail/config"
4
5
  require "logtail/contexts/http"
@@ -15,7 +16,9 @@ module Logtail
15
16
  # response events. The {Events::HTTPRequest} and {Events::HTTPResponse} events
16
17
  # respectively.
17
18
  class HTTPEvents < Middleware
19
+ DEFAULT_STATUS_FOR_EXCEPTION = ->(_exception) { 500 }
18
20
  DEFAULT_HTTP_HEADER_FILTERS = ["Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie"].freeze
21
+ DEFAULT_QUERY_STRING_FILTERS = %w[passw secret token _key crypt salt certificate otp ssn cvv cvc].freeze
19
22
 
20
23
  class << self
21
24
  # Allows you to capture the HTTP request body, default is off (false).
@@ -105,6 +108,29 @@ module Logtail
105
108
  @silence_request
106
109
  end
107
110
 
111
+ # When the app raises instead of returning a response, the response event is still
112
+ # logged, and the exception is re-raised unchanged. This setting resolves the HTTP
113
+ # status of that event: a callable that receives the exception and returns an Integer
114
+ # status. The default, {DEFAULT_STATUS_FOR_EXCEPTION}, returns 500, and so does a
115
+ # callable that raises itself.
116
+ #
117
+ # @example
118
+ # Logtail::Integrations::Rack::HTTPEvents.status_for_exception = lambda do |exception|
119
+ # exception.is_a?(MyApp::NotFound) ? 404 : 500
120
+ # end
121
+ def status_for_exception=(callable)
122
+ if !callable.respond_to?(:call)
123
+ raise ArgumentError.new("The value passed to #status_for_exception must respond to #call")
124
+ end
125
+
126
+ @status_for_exception = callable
127
+ end
128
+
129
+ # Accessor method for {#status_for_exception=}
130
+ def status_for_exception
131
+ @status_for_exception
132
+ end
133
+
108
134
  # Filter sensitive HTTP headers (such as "Authorization: Bearer secret_token")
109
135
  #
110
136
  # Filtered HTTP header values will be sent to Better Stack as "[FILTERED]"
@@ -123,14 +149,60 @@ module Logtail
123
149
  @http_header_filters
124
150
  end
125
151
 
152
+ # Filter sensitive query string parameters (such as "?token=secret_token")
153
+ #
154
+ # Filtered values will be sent to Better Stack as "[FILTERED]", in the query string of
155
+ # the request event and in the query of the URLs in the Referer and Location headers.
156
+ # Like Rails' filter_parameters, a String or Symbol filters every parameter whose
157
+ # URL-decoded name contains it, ignoring case, and a Regexp every name it matches.
158
+ #
159
+ # {DEFAULT_QUERY_STRING_FILTERS} are filtered out of the box. Setting this replaces
160
+ # the whole list, pass an empty list to log query strings unfiltered.
161
+ #
162
+ # @example
163
+ # Logtail::Integrations::Rack::HTTPEvents.query_string_filters = Logtail::Integrations::Rack::HTTPEvents::DEFAULT_QUERY_STRING_FILTERS + ["code", /\Aapi_/]
164
+ def query_string_filters=(value)
165
+ strings = value.select { |filter| filter.is_a?(String) || filter.is_a?(Symbol) }
166
+ regexps = value.select { |filter| filter.is_a?(Regexp) }
167
+ if !strings.empty?
168
+ regexps << Regexp.new(strings.map { |filter| Regexp.escape(filter.to_s) }.join("|"), Regexp::IGNORECASE)
169
+ end
170
+
171
+ @query_string_filters = value
172
+ @query_string_filter_regexps = regexps
173
+ end
174
+
175
+ # Accessor method for {#query_string_filters=}
176
+ def query_string_filters
177
+ @query_string_filters
178
+ end
179
+
180
+ # Whether {#query_string_filters} filter the parameter with this name from a query string
181
+ def filter_query_string_parameter?(name)
182
+ begin
183
+ name = URI.decode_www_form_component(name)
184
+ rescue ArgumentError
185
+ # Invalid %-encoding, match the name as it is
186
+ end
187
+
188
+ name = name.scrub
189
+ @query_string_filter_regexps.any? { |regexp| regexp =~ name }
190
+ end
191
+
126
192
  def normalize_header_name(name)
127
193
  name.to_s.downcase.gsub("-", "_")
128
194
  end
129
195
  end
130
196
 
197
+ self.status_for_exception = DEFAULT_STATUS_FOR_EXCEPTION
131
198
  self.http_header_filters = DEFAULT_HTTP_HEADER_FILTERS
199
+ self.query_string_filters = DEFAULT_QUERY_STRING_FILTERS
132
200
 
133
- CONTENT_LENGTH_KEY = 'Content-Length'.freeze
201
+ CONTENT_LENGTH_KEY = 'content-length'.freeze
202
+ # A query string parameter name and its value, up to the next separator
203
+ QUERY_STRING_PARAMETER = /([^&;=]+)=([^&;]+)/
204
+ URL_QUERY = /\?([^#]*)/
205
+ URL_HEADERS = ["referer", "location"].freeze
134
206
 
135
207
  def call(env)
136
208
  request = Util::Request.new(env)
@@ -145,14 +217,14 @@ module Logtail
145
217
  end
146
218
 
147
219
  elsif collapse_into_single_event?
148
- request_start = Time.now
149
- status, headers, body = @app.call(env)
150
- request_end = Time.now
220
+ request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
221
+ status, headers, body = call_app(env, request, request_start)
222
+ request_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
223
 
152
224
  Config.instance.logger.info do
153
- http_context = CurrentContext.fetch(:http)
154
- content_length = safe_to_i(headers[CONTENT_LENGTH_KEY])
155
- duration_ms = (request_end - request_start) * 1000.0
225
+ http_context = CurrentContext.fetch(:http, nil)
226
+ content_length = response_content_length(headers)
227
+ duration_ms = ((request_end - request_start) * 1000.0).round(1)
156
228
 
157
229
  http_response = HTTPResponse.new(
158
230
  content_length: content_length,
@@ -177,7 +249,7 @@ module Logtail
177
249
  }
178
250
  }
179
251
  }
180
- end
252
+ end rescue logging_failed($!)
181
253
 
182
254
  [status, headers, body]
183
255
  else
@@ -191,7 +263,7 @@ module Logtail
191
263
  method: request.request_method,
192
264
  path: request.path,
193
265
  port: request.port,
194
- query_string: Util::Encoding.force_utf8_encoding(request.query_string),
266
+ query_string: filter_query_string(Util::Encoding.force_utf8_encoding(request.query_string)),
195
267
  request_id: request.request_id,
196
268
  scheme: Util::Encoding.force_utf8_encoding(request.scheme),
197
269
  )
@@ -214,16 +286,17 @@ module Logtail
214
286
  }
215
287
  }
216
288
  }
217
- end
289
+ end rescue logging_failed($!)
218
290
 
219
- request_start = Time.now
220
- status, headers, body = @app.call(env)
221
- request_end = Time.now
291
+ request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
292
+ status, headers, body = call_app(env, request, request_start)
293
+ request_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
222
294
 
223
295
  Config.instance.logger.info do
224
- event_body = capture_response_body? ? body : nil
225
- content_length = safe_to_i(headers[CONTENT_LENGTH_KEY])
226
- duration_ms = (request_end - request_start) * 1000.0
296
+ # Only an Array body can be read twice, other bodies may stream to the server once
297
+ event_body = capture_response_body? && body.is_a?(Array) ? body.join : nil
298
+ content_length = response_content_length(headers)
299
+ duration_ms = ((request_end - request_start) * 1000.0).round(1)
227
300
 
228
301
  http_response = HTTPResponse.new(
229
302
  body: event_body,
@@ -248,7 +321,7 @@ module Logtail
248
321
  }
249
322
  }
250
323
  }
251
- end
324
+ end rescue logging_failed($!)
252
325
 
253
326
  [status, headers, body]
254
327
  end
@@ -275,14 +348,88 @@ module Logtail
275
348
  end
276
349
  end
277
350
 
351
+ # Calls the app. When it raises instead of returning a response, logs the response
352
+ # event with the status from {.status_for_exception} and re-raises the exception.
353
+ def call_app(env, request, request_start)
354
+ @app.call(env)
355
+ rescue Exception => exception
356
+ log_exception_response(request, exception, request_start)
357
+ raise exception
358
+ end
359
+
360
+ # Never raises, so that the exception of the app is the one that propagates.
361
+ def log_exception_response(request, exception, request_start)
362
+ request_end = Process.clock_gettime(Process::CLOCK_MONOTONIC)
363
+
364
+ Config.instance.logger.info do
365
+ duration_ms = ((request_end - request_start) * 1000.0).round(1)
366
+
367
+ http_response = HTTPResponse.new(
368
+ http_context: collapse_into_single_event? ? CurrentContext.fetch(:http, nil) : nil,
369
+ request_id: request.request_id,
370
+ status: status_for_exception(exception),
371
+ duration_ms: duration_ms,
372
+ )
373
+
374
+ {
375
+ message: http_response.message,
376
+ event: {
377
+ http_response_sent: {
378
+ body: http_response.body,
379
+ content_length: http_response.content_length,
380
+ headers_json: http_response.headers_json,
381
+ request_id: http_response.request_id,
382
+ service_name: http_response.service_name,
383
+ status: http_response.status,
384
+ duration_ms: http_response.duration_ms,
385
+ }
386
+ }
387
+ }
388
+ end
389
+ rescue Exception => e
390
+ Config.instance.debug { "Failed to log the response of a request that raised #{exception.class}: #{e.class}: #{e.message}" }
391
+ end
392
+
393
+ def status_for_exception(exception)
394
+ self.class.status_for_exception.call(exception)
395
+ rescue Exception => e
396
+ Config.instance.debug { "status_for_exception raised #{e.class}: #{e.message}, logging status 500" }
397
+ 500
398
+ end
399
+
278
400
  def filter_http_headers(headers)
279
401
  headers.map do |name, value|
280
402
  normalized_name = self.class.normalize_header_name(name)
281
403
  is_filtered = self.class.http_header_filters.include?(normalized_name)
404
+ value = filter_url_query(value) if URL_HEADERS.include?(normalized_name)
282
405
  [name, is_filtered ? "[FILTERED]" : value]
283
406
  end.to_h
284
407
  end
285
408
 
409
+ # Replaces the value of every parameter that {.query_string_filters} match with
410
+ # "[FILTERED]", leaving the rest of the query string unchanged.
411
+ def filter_query_string(query_string)
412
+ return query_string if !query_string.is_a?(String) || self.class.query_string_filters.empty?
413
+
414
+ query_string.gsub(QUERY_STRING_PARAMETER) do |parameter|
415
+ name = Regexp.last_match(1)
416
+ self.class.filter_query_string_parameter?(name) ? "#{name}=[FILTERED]" : parameter
417
+ end
418
+ end
419
+
420
+ # Filters the query of the URL in a Referer or Location header.
421
+ def filter_url_query(url)
422
+ return url if !url.is_a?(String) || !url.include?("?") || self.class.query_string_filters.empty?
423
+
424
+ Util::Encoding.force_utf8_encoding(url).sub(URL_QUERY) { "?#{filter_query_string(Regexp.last_match(1))}" }
425
+ end
426
+
427
+ # Rack 3 applications return "content-length", older ones usually "Content-Length".
428
+ def response_content_length(headers)
429
+ _name, value = headers.find { |name, _value| name.to_s.downcase == CONTENT_LENGTH_KEY }
430
+ safe_to_i(value)
431
+ end
432
+
286
433
  def safe_to_i(val)
287
434
  val.nil? ? nil : val.to_i
288
435
  end
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  require "logtail-rack/util/encoding"
2
4
 
3
5
  module Logtail
@@ -26,7 +28,7 @@ module Logtail
26
28
  @service_name = attributes[:service_name]
27
29
 
28
30
  if @headers
29
- @headers_json = Util::Encoding.force_utf8_encoding(@headers).to_json
31
+ @headers_json = ::JSON.generate(Util::Encoding.force_utf8_encoding(@headers))
30
32
  end
31
33
  end
32
34
 
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  require "logtail-rack/util/encoding"
2
4
 
3
5
  module Logtail
@@ -21,7 +23,7 @@ module Logtail
21
23
  @duration_ms = attributes[:duration_ms]
22
24
 
23
25
  if @headers
24
- @headers_json = Util::Encoding.force_utf8_encoding(@headers).to_json
26
+ @headers_json = ::JSON.generate(Util::Encoding.force_utf8_encoding(@headers))
25
27
  end
26
28
  end
27
29
 
@@ -1,3 +1,5 @@
1
+ require "logtail/config"
2
+
1
3
  module Logtail
2
4
  module Integrations
3
5
  module Rack
@@ -22,6 +24,18 @@ module Logtail
22
24
  def initialize(app)
23
25
  @app = app
24
26
  end
27
+
28
+ private
29
+ # Logging must never fail the request, so an error raised while building or writing an event
30
+ # only goes to the debug logger. The middlewares call the logger themselves, so that the
31
+ # runtime context of the line points to them, and rescue the error with this method:
32
+ #
33
+ # Config.instance.logger.info do
34
+ # ...
35
+ # end rescue logging_failed($!)
36
+ def logging_failed(error)
37
+ Config.instance.debug { "#{self.class.name} could not log an event: #{error.inspect}\n\n#{error.backtrace}" }
38
+ end
25
39
  end
26
40
  end
27
41
  end
@@ -25,7 +25,10 @@ module Logtail
25
25
  if session = env['rack.session']
26
26
  if session.respond_to?(:id)
27
27
  Logtail::Config.instance.debug { "Rack env session detected, using id attribute" }
28
- session.id
28
+ id = session.id
29
+ # Since Rack 1.6.12 and 2.0.8 a Rack::Session::SessionId, which MessagePack can't encode. Its public id
30
+ # is the session cookie of server-side stores, the private id a hash of it that can't be used as one.
31
+ id.respond_to?(:private_id) ? id.private_id : id
29
32
  elsif session.respond_to?(:[])
30
33
  Logtail::Config.instance.debug { "Rack env session detected, using the session_id key" }
31
34
  session["session_id"]
@@ -39,6 +42,7 @@ module Logtail
39
42
  nil
40
43
  end
41
44
  rescue Exception => e
45
+ Logtail::Config.instance.debug { "Could not obtain the session id: #{e.inspect}\n\n#{e.backtrace}" }
42
46
  nil
43
47
  end
44
48
  end
@@ -93,6 +93,9 @@ module Logtail
93
93
  Logtail::Config.instance.debug { "Could not locate any user data" }
94
94
  nil
95
95
  end
96
+ rescue StandardError => e
97
+ Logtail::Config.instance.debug { "Could not obtain the user context: #{e.inspect}\n\n#{e.backtrace}" }
98
+ nil
96
99
  end
97
100
 
98
101
  def get_user_object_hash(user)
@@ -13,6 +13,10 @@ module Logtail
13
13
  REQUEST_ID_KEY_NAME2 = 'HTTP_X_REQUEST_ID'.freeze
14
14
 
15
15
  def body_content
16
+ # Rack 3 allows a missing or non-rewindable input, and reading one that can't be rewound takes the body
17
+ # from the app
18
+ return nil unless body.respond_to?(:rewind)
19
+
16
20
  content = body.read
17
21
  body.rewind
18
22
  content
@@ -1,7 +1,7 @@
1
1
  module Logtail
2
2
  module Integrations
3
3
  module Rack
4
- VERSION = "0.2.7"
4
+ VERSION = "0.2.9"
5
5
  end
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logtail-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Better Stack