logtail-rails 0.2.14 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbc8fb02a00988c89f406be59ac925eba90048afc392ac52c5efca571f835271
4
- data.tar.gz: 8bb940e1915fd178e7cc6854415f345101ec74b9e441173464624ba94ac7ea5a
3
+ metadata.gz: 2c5828f2141c9b70b0f47591b7dcf8855a648c0297477637fd7029cfd5e39a8d
4
+ data.tar.gz: 0a88a29844cdbbc76a20a827f0575fc3f3aa4befe32dde404315bb55a733cfa2
5
5
  SHA512:
6
- metadata.gz: e75cc0a7bd43ea6b8cccac45f8f4b3c74e0acea9b833ac14c6b77c1e5ad63fcbc35dc22d9a32b58a6ba41e7ce3b5f964a20b7a0bdc6df2ebc1a6bdfdb84194d0
7
- data.tar.gz: 7b675f409e820c32dd256371157b155f5b7a902e91e188fe6be389bb139f9ba33797898c4ed43e407928fab5ac4237fd839c123edd3e45c7378467a787e8f9de
6
+ metadata.gz: 9238c31e5e7945f99fdc3acae8a7006f6f0568e3e2269469a0498da0d5010718d2a3dc884bfd64cc471be0c7a54283e32ff327f8fa1dd11d70b76d1baf84dc2c
7
+ data.tar.gz: 76f5185abcdc9efafbe564d65a543cb924dcfb75326686645f346a7e30201b053cad5380701f8abe3d0ed01db85f0a54b631522dca449bf4aba2164967f6d840
data/README.md CHANGED
@@ -14,6 +14,8 @@ Experience SQL-compatible structured log management based on ClickHouse. [Learn
14
14
 
15
15
  [Getting started ⇗](https://betterstack.com/docs/logs/ruby-and-rails/#logging-from-rails)
16
16
 
17
+ [Example project](example-project/README.md): logging, structured data and [adding context to every log line of a request](example-project/README.md#context).
18
+
17
19
  ## Need help?
18
20
  Please let us know at [hello@betterstack.com](mailto:hello@betterstack.com). We're happy to help!
19
21
 
@@ -185,12 +185,53 @@ This will generate the following JSON output:
185
185
  }
186
186
  ```
187
187
 
188
- We will automatically add the information about the current user to each log if you're using Ruby on Rails and the Devise gem.
188
+ We will automatically add the information about the current user to each log if you're using Ruby on Rails with Devise (or any other Warden-based authentication) or with Clearance.
189
189
 
190
- If you're not using Devise or you want to log some additional information for every request your Rails app handles, you can easily implement this using Rails' `around_action` in your application controller. A simple implementation could look like this:
190
+ ### Context for every log line of a request
191
+
192
+ The request and response lines (`Started GET "/" for 127.0.0.1` and `Completed 200 OK in 12.3ms`) are logged by a Rack middleware, before the request reaches your controller. Context set from inside a controller is therefore attached to the logs from the controller action on, but not to these two lines. To attach context to every log line of a request, set it in a Rack middleware instead.
193
+
194
+ The user context from Devise or Clearance is set this way, by the `Logtail::Integrations::Rack::UserContext` middleware. If you authenticate differently, give it a lambda that finds the user in the Rack environment and returns a hash, or `nil` when nobody is signed in:
191
195
 
192
196
  ```ruby
193
- class ApplicationController < ActionController::Base
197
+ # config/initializers/logtail.rb
198
+ Logtail::Integrations::Rack::UserContext.custom_user_hash = lambda do |rack_env|
199
+ user_id = rack_env["rack.session"]["user_id"]
200
+ user = User.find_by(id: user_id) if user_id
201
+ user && { id: user.id, email: user.email }
202
+ end
203
+ ```
204
+
205
+ The hash is logged as `context.user` on every log line of the request, including the request and response lines. The lambda runs on every request, so keep it cheap. It can read anything from the Rack environment, for example an API token from `rack_env["HTTP_AUTHORIZATION"]`.
206
+
207
+ For any other per-request context (a tenant, an API client, a GraphQL operation name), write your own middleware that wraps the request in `Logtail.with_context` and insert it before the middleware that logs the request and response lines:
208
+
209
+ ```ruby
210
+ # config/initializers/logtail.rb
211
+ class LogtailTenantContext
212
+ def initialize(app)
213
+ @app = app
214
+ end
215
+
216
+ def call(env)
217
+ tenant = env["HTTP_X_TENANT"]
218
+ return @app.call(env) unless tenant
219
+
220
+ Logtail.with_context(tenant: { name: tenant }) { @app.call(env) }
221
+ end
222
+ end
223
+
224
+ Rails.application.config.middleware.insert_before Logtail::Integrations::Rack::HTTPEvents, LogtailTenantContext
225
+ ```
226
+
227
+ Inserting it before `Logtail::Integrations::Rack::HTTPEvents` also places it after your authentication middleware (such as Warden), so the session and the signed-in user are already available in `env`.
228
+
229
+ ### Context from a controller
230
+
231
+ If the user is only known once your controller runs (for example, you authenticate from a token in a `before_action`), you can still set the context with Rails' `around_action`. It covers the logs from the controller action and everything it calls, but not the request and response lines, which are logged before the action runs. A simple implementation could look like this:
232
+
233
+ ```ruby
234
+ class ApplicationController < ActionController::Base # or ActionController::API
194
235
  around_action :with_logtail_context
195
236
 
196
237
  private
@@ -202,7 +243,7 @@ class ApplicationController < ActionController::Base
202
243
  yield
203
244
  end
204
245
  end
205
-
246
+
206
247
  def user_context
207
248
  Logtail::Contexts::User.new(
208
249
  id: current_user.id,
@@ -76,9 +76,12 @@ module Logtail
76
76
  super
77
77
 
78
78
  if ::Rails::VERSION::MAJOR > 7 || ::Rails::VERSION::MAJOR == 7 && ::Rails::VERSION::MINOR >= 1
79
- # Clean extra listeners subscribed in parent's attach_to method
80
- ::ActiveSupport::Notifications.notifier.listeners_for("render_template.action_view")
81
- .concat(::ActiveSupport::Notifications.notifier.listeners_for("render_layout.action_view")).flatten
79
+ # Clean extra listeners subscribed in parent's attach_to method. Since Rails 7.2 they count as
80
+ # silenced while ActionView::Base.logger is nil, as it is at boot, and listeners_for skips those.
81
+ # all_listeners_for returns the notifier's cached array, so don't concat onto it.
82
+ notifier = ::ActiveSupport::Notifications.notifier
83
+ %w(render_template.action_view render_layout.action_view)
84
+ .flat_map { |name| notifier.respond_to?(:all_listeners_for) ? notifier.all_listeners_for(name) : notifier.listeners_for(name) }
82
85
  .filter { |listener| listener.delegate.class == ::ActionView::LogSubscriber::Start }
83
86
  .each { |listener| ActiveSupport::Notifications.unsubscribe(listener) }
84
87
  end
@@ -10,3 +10,22 @@ Logtail::Config.instance.define_singleton_method(:logrageify!) do
10
10
  integrations.active_record.silence = true
11
11
  integrations.rack.http_events.collapse_into_single_event = true
12
12
  end
13
+
14
+ module Logtail
15
+ class Config
16
+ # Turns the Rails integration on or off, it's on by default. Set it in `config/application.rb`
17
+ # or `config/environments/*.rb`, see {Logtail::Frameworks::Rails::Railtie} for what it turns off.
18
+ #
19
+ # @example Keep the standard Rails logging in development
20
+ # # config/environments/development.rb
21
+ # config.logtail.enabled = false
22
+ def enabled=(value)
23
+ @enabled = value
24
+ end
25
+
26
+ # Accessor method for {#enabled=}
27
+ def enabled?
28
+ @enabled != false
29
+ end
30
+ end
31
+ end
@@ -18,19 +18,17 @@ module Logtail
18
18
  # We determine this when the app loads to avoid the overhead on a per request basis.
19
19
  EXCEPTION_WRAPPER_TAKES_CLEANER = defined?(::ActionDispatch::ExceptionWrapper) &&
20
20
  !::ActionDispatch::ExceptionWrapper.instance_methods.include?(:env)
21
+ # config.action_dispatch.log_rescued_responses (Rails 7.0+) and
22
+ # config.action_dispatch.debug_exception_log_level (Rails 7.1+), as ActionDispatch::DebugExceptions
23
+ # reads them. This gem silences its logging and logs the exception here instead.
24
+ LOG_RESCUED_RESPONSES_KEY = "action_dispatch.log_rescued_responses".freeze
25
+ DEBUG_EXCEPTION_LOG_LEVEL_KEY = "action_dispatch.debug_exception_log_level".freeze
21
26
 
22
27
  def call(env)
23
28
  begin
24
29
  status, headers, body = @app.call(env)
25
30
  rescue Exception => exception
26
- Config.instance.logger.fatal do
27
- backtrace = extract_backtrace(env, exception)
28
- Events::Error.new(
29
- name: exception.class.name,
30
- error_message: exception.message,
31
- backtrace: backtrace
32
- )
33
- end
31
+ log_exception(env, exception)
34
32
 
35
33
  raise exception
36
34
  end
@@ -38,6 +36,30 @@ module Logtail
38
36
 
39
37
  private
40
38
 
39
+ # Never raises, so that the exception of the app is the one that propagates.
40
+ def log_exception(env, exception)
41
+ return if !log_exception?(env, exception)
42
+
43
+ Config.instance.logger.add(env[DEBUG_EXCEPTION_LOG_LEVEL_KEY] || ::Logger::FATAL) do
44
+ backtrace = extract_backtrace(env, exception)
45
+ Events::Error.new(
46
+ name: exception.class.name,
47
+ error_message: exception.message,
48
+ backtrace: backtrace
49
+ )
50
+ end
51
+ rescue StandardError => e
52
+ Config.instance.debug { "#{self.class.name} could not log #{exception.class}: #{e.inspect}" }
53
+ end
54
+
55
+ # Like DebugExceptions, skip rescued responses such as ActiveRecord::RecordNotFound when
56
+ # log_rescued_responses is false. Older Rails versions don't set it and log every exception.
57
+ def log_exception?(env, exception)
58
+ return true if !env.key?(LOG_RESCUED_RESPONSES_KEY) || env[LOG_RESCUED_RESPONSES_KEY]
59
+
60
+ !::ActionDispatch::ExceptionWrapper.rescue_responses.key?(exception.class.name)
61
+ end
62
+
41
63
  # Rails provides a backtrace cleaner, so we use it here.
42
64
  def extract_backtrace(env, exception)
43
65
  if defined?(::ActionDispatch::ExceptionWrapper)
@@ -6,7 +6,7 @@ module Logtail
6
6
  # them with all their data to Rails.logger, which sends them to Better Stack.
7
7
  class EventLogSubscriber
8
8
  # Rails logger instance
9
- attr_reader :logger
9
+ attr_accessor :logger
10
10
 
11
11
  # Log level to use for logging events
12
12
  mattr_accessor :log_level, default: :info
@@ -14,6 +14,17 @@ module Logtail
14
14
  # Allows to disable the subscriber
15
15
  mattr_accessor :enabled, default: true
16
16
 
17
+ # Subscribes to Rails.event once. Later calls only hand the events to the new logger, so that
18
+ # calling Logtail::Logger.create_default_logger again doesn't log every event twice.
19
+ def self.subscribe(logger)
20
+ if @subscriber
21
+ @subscriber.logger = logger
22
+ else
23
+ @subscriber = new(logger)
24
+ ::Rails.event.subscribe(@subscriber)
25
+ end
26
+ end
27
+
17
28
  # Initialize the subscriber with a logger instance
18
29
  def initialize(logger)
19
30
  @logger = logger
@@ -49,6 +49,10 @@ module Logtail
49
49
  def self.create_logger(*io_devices_and_loggers)
50
50
  logger = Logtail::Logger.new(*io_devices_and_loggers)
51
51
 
52
+ # Rails applies config.log_level to Rails.logger while booting, but not to a logger added with broadcast_to later
53
+ log_level = Rails.application.config.log_level if ENV['LOG_LEVEL'].blank? && Rails.application
54
+ logger.level = ::ActiveSupport::Logger.const_get(log_level.to_s.upcase) if log_level
55
+
52
56
  tagged_logging_supported = Rails::VERSION::MAJOR >= 7 || Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1
53
57
  logger = ::ActiveSupport::TaggedLogging.new(logger) if tagged_logging_supported
54
58
 
@@ -61,10 +65,15 @@ module Logtail
61
65
  options[:ingesting_port] ||= options[:telemetry_port] if options[:telemetry_port].present?
62
66
  options[:ingesting_scheme] ||= options[:telemetry_scheme] if options[:telemetry_scheme].present?
63
67
 
64
- if ENV['LOGTAIL_SKIP_LOGS'].blank? && !Rails.env.test?
65
- io_device = Logtail::LogDevices::HTTP.new(source_token, options)
66
- else
68
+ if ENV['LOGTAIL_SKIP_LOGS'].present? || Rails.env.test?
69
+ io_device = STDOUT
70
+ elsif source_token.blank?
71
+ # Don't break booting the app without the token, e.g. for `assets:precompile` in a Docker build
72
+ @blank_source_token = true
67
73
  io_device = STDOUT
74
+ else
75
+ io_device = Logtail::LogDevices::HTTP.new(source_token, options)
76
+ @better_stack_logger_created = true
68
77
  end
69
78
 
70
79
  logger = self.create_logger(io_device)
@@ -79,9 +88,16 @@ module Logtail
79
88
  end
80
89
 
81
90
  # For Rails 8.1 and above, subscribe to the event system
82
- Rails.event.subscribe(Logtail::Integrations::Rails::EventLogSubscriber.new(logger)) if Rails.respond_to?(:event)
91
+ Logtail::Integrations::Rails::EventLogSubscriber.subscribe(logger) if Rails.respond_to?(:event)
83
92
 
84
93
  logger
85
94
  end
95
+
96
+ class << self
97
+ # What {create_default_logger} did, Logtail::Frameworks::Rails::Railtie warns about it once the app has booted
98
+ #
99
+ # @private
100
+ attr_reader :blank_source_token, :better_stack_logger_created
101
+ end
86
102
  end
87
103
  end
@@ -13,6 +13,26 @@ module Logtail
13
13
  Logtail::Config.instance.logger = Proc.new { ::Rails.logger }
14
14
  end
15
15
 
16
+ # `config.logtail.enabled = false` turns the integration off. The environment files are loaded
17
+ # by now, and Rails hasn't set up Rails.logger from config.logger yet.
18
+ initializer(:logtail_enabled, after: :load_environment_config, before: :initialize_logger) do |app|
19
+ next if app.config.logtail.enabled?
20
+
21
+ # Before the :logtail initializer below picks the middlewares
22
+ Integrations::Rails.enabled = false
23
+
24
+ # The setup docs create the logger in config/application.rb, i.e. in every environment. Log to the
25
+ # default log file instead, with a Logtail::Logger so that `logger.info("message", key: value)` works.
26
+ if app.config.logger.is_a?(Logtail::Logger)
27
+ log_file = app.paths["log"].first
28
+ FileUtils.mkdir_p(File.dirname(log_file))
29
+ # Opened like Rails opens it: Logger only writes its "# Logfile created" header into files it creates
30
+ file = File.open(log_file, "a")
31
+ file.binmode
32
+ app.config.logger = Logtail::Logger.create_logger(file)
33
+ end
34
+ end
35
+
16
36
  # Must be loaded after initializers so that we respect any Logtail configuration set
17
37
  initializer(:logtail, before: :build_middleware_stack, after: :load_config_initializers) do
18
38
  Integrations::Rails.integrate!
@@ -23,6 +43,31 @@ module Logtail
23
43
  config.app_middleware.use middleware_class
24
44
  end
25
45
  end
46
+
47
+ # Warns about problems with the logger create_default_logger created, unless the integration is turned off.
48
+ # Registered here, so that it runs after the app's own after_initialize blocks, which may still broadcast to it.
49
+ initializer(:logtail_logger_check, after: :load_config_initializers) do |app|
50
+ app.config.after_initialize do
51
+ next unless Integrations::Rails.enabled?
52
+
53
+ if Logtail::Logger.blank_source_token
54
+ Kernel.warn("Logtail: the source token passed to Logtail::Logger.create_default_logger is blank, logging to STDOUT instead of sending logs to Better Stack.")
55
+ next
56
+ end
57
+ next unless Logtail::Logger.better_stack_logger_created
58
+
59
+ # A `config.logger = ...` line after config/application.rb, like the one generated in
60
+ # config/environments/production.rb, replaces the Better Stack logger without any error
61
+ loggers = ::Rails.logger.respond_to?(:broadcasts) ? ::Rails.logger.broadcasts : [::Rails.logger]
62
+ next if loggers.any? { |logger| logger.is_a?(Logtail::Logger) }
63
+
64
+ # Before Rails 7.1, broadcasting extends the logger with an anonymous module, which hides the target
65
+ extended_modules = ::Rails.logger.singleton_class.included_modules - ::Rails.logger.class.included_modules
66
+ next if extended_modules.any? { |mod| mod.name.nil? }
67
+
68
+ Kernel.warn("Logtail: Rails.logger isn't the Better Stack logger created by Logtail::Logger.create_default_logger, and doesn't broadcast to it, so your logs don't reach Better Stack. Most likely config.logger is set again later, e.g. in config/environments/production.rb.")
69
+ end
70
+ end
26
71
  end
27
72
  end
28
73
  end
@@ -1,7 +1,7 @@
1
1
  module Logtail
2
2
  module Integrations
3
3
  module Rails
4
- VERSION = "0.2.14"
4
+ VERSION = "0.3.0"
5
5
  end
6
6
  end
7
7
  end
data/lib/logtail-rails.rb CHANGED
@@ -39,11 +39,18 @@ module Logtail
39
39
  def self.integrate!
40
40
  return false if !enabled?
41
41
 
42
+ # Log the status Rails responds with when the app raises, the way ShowExceptions
43
+ # determines it: from config.action_dispatch.rescue_responses, 500 by default.
44
+ Logtail::Integrations::Rack::HTTPEvents.status_for_exception = lambda do |exception|
45
+ ::ActionDispatch::ExceptionWrapper.new(nil, exception).status_code
46
+ end
47
+
42
48
  ActionController.integrate!
43
49
  ActionDispatch.integrate!
44
50
  ActionView.integrate!
45
51
  ActiveRecord.integrate!
46
52
  RackLogger.integrate!
53
+ filter_parameters_in_urls(::Rails.application.config.filter_parameters)
47
54
  end
48
55
 
49
56
  def self.enabled=(value)
@@ -65,6 +72,16 @@ module Logtail
65
72
  @middlewares ||= [Logtail::Integrations::Rack::HTTPContext, SessionContext, Logtail::Integrations::Rack::UserContext,
66
73
  Logtail::Integrations::Rack::HTTPEvents, Logtail::Integrations::Rails::ErrorEvent].select(&:enabled?)
67
74
  end
75
+
76
+ # Filters the app's filter_parameters from the query strings and the Referer and Location
77
+ # URLs that Rack::HTTPEvents logs, unless the app customised its query_string_filters.
78
+ # Procs are left out: they rewrite values, while query string filters match names.
79
+ def self.filter_parameters_in_urls(filter_parameters)
80
+ http_events = Logtail::Integrations::Rack::HTTPEvents
81
+ return if http_events.query_string_filters != http_events::DEFAULT_QUERY_STRING_FILTERS
82
+
83
+ http_events.query_string_filters = filter_parameters.reject { |filter| filter.is_a?(Proc) }
84
+ end
68
85
  end
69
86
  end
70
87
  end
@@ -27,8 +27,8 @@ Gem::Specification.new do |spec|
27
27
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.add_runtime_dependency "logtail", "~> 0.1", ">= 0.1.14"
31
- spec.add_runtime_dependency "logtail-rack", "~> 0.1"
30
+ spec.add_runtime_dependency "logtail", "~> 0.1", ">= 0.1.21"
31
+ spec.add_runtime_dependency "logtail-rack", "~> 0.2", ">= 0.2.9"
32
32
 
33
33
  spec.add_runtime_dependency 'activerecord', '>= 5.0.0'
34
34
  spec.add_runtime_dependency 'railties', '>= 5.0.0'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logtail-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.14
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Better Stack
@@ -18,7 +18,7 @@ dependencies:
18
18
  version: '0.1'
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 0.1.14
21
+ version: 0.1.21
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -28,21 +28,27 @@ dependencies:
28
28
  version: '0.1'
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 0.1.14
31
+ version: 0.1.21
32
32
  - !ruby/object:Gem::Dependency
33
33
  name: logtail-rack
34
34
  requirement: !ruby/object:Gem::Requirement
35
35
  requirements:
36
36
  - - "~>"
37
37
  - !ruby/object:Gem::Version
38
- version: '0.1'
38
+ version: '0.2'
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: 0.2.9
39
42
  type: :runtime
40
43
  prerelease: false
41
44
  version_requirements: !ruby/object:Gem::Requirement
42
45
  requirements:
43
46
  - - "~>"
44
47
  - !ruby/object:Gem::Version
45
- version: '0.1'
48
+ version: '0.2'
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 0.2.9
46
52
  - !ruby/object:Gem::Dependency
47
53
  name: activerecord
48
54
  requirement: !ruby/object:Gem::Requirement