Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rails.logger.tagged will execute block as many times as the number of tagged loggers in the broadcast #49745

Open
davidstosik opened this issue Oct 23, 2023 · 6 comments · May be fixed by #49771
Assignees
Milestone

Comments

@davidstosik
Copy link
Contributor

davidstosik commented Oct 23, 2023

Steps to reproduce

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails", github: "rails/rails", branch: "main"
  gem "debug"
end

require "active_support"
require "active_support/core_ext/object/blank"
require "minitest/autorun"

module ActiveSupport
  class BugTest < Minitest::Test
    def test_tagged_broadcast
      logger1_io = StringIO.new
      logger2_io = StringIO.new

      logger = BroadcastLogger.new(TaggedLogging.new(Logger.new(logger1_io)))
      logger.broadcast_to(TaggedLogging.new(Logger.new(logger2_io)))

      run_counter = 0

      logger.tagged("TEST") do
        logger.info("Doing something")
        run_counter += 1
      end

      # require "debug";binding.b
      assert_equal 1, run_counter                                  # Actual: 2
      assert_equal "[TEST] Doing something\n", logger1_io.string   # Actual: "[TEST] Doing something\nDoing something\n"
      assert_equal "[TEST] Doing something\n", logger2_io.string   # Actual: "Doing something\n[TEST] Doing something\n"
    end
  end
end

Expected behavior

When I do Rails.logger.tagged("MY TAG") { do_something }, I expect do_something to be called once, regardless of how many loggers Rails is broadcasting to.

I also expect my log to be tagged and displayed in each logger. (I should see "[TEST] Doing something" once in the first log sink, and once in the second log sink.)

I don't know how common this is, but I've seen many applications (starting with Shopify), passing rather long blocks to #tagged, expecting all logs produced by the code in that block to be tagged. This becomes a problem if we for example do database operations in that block.

Actual behavior

The block passed to tagged is run as many times as there are loggers broadcasted to.
The log messages get multiplied too (obviously), and only one for each logger is tagged.

System configuration

Rails version: HEAD
Ruby version: 3.2.1


Still working on a graceful but quick solution to handle this. Suggestions welcome!

@davidstosik davidstosik changed the title Rails.logger.tagged will execute block as many times as I have tagged loggers in the broadcast Rails.logger.tagged will execute block as many times as the number of tagged loggers in the broadcast Oct 23, 2023
@davidstosik
Copy link
Contributor Author

davidstosik commented Oct 23, 2023

Here's an idea: implement a "recursive" tagged method in BroadcastLogger:

module ActiveSupport
  class BroadcastLogger
    # ...

    def tagged(*tags, &block)
      if block_given?
        recursive_tagged(broadcasts, *tags, &block)
      else
        loggers = broadcasts.map do |logger|
          if logger.respond_to?(:tagged)
            logger.tagged(*tags)
          else
            logger
          end
        end
        # probably could use Andrew's deep_dup
        self.class.new(*loggers).tap do |broadcast_logger|
          broadcast_logger.formatter = formatter
          broadcast_logger.progname = progname
        end
      end
    end

    private

      def recursive_tagged(loggers, *tags, &block)
        return block.call if loggers.empty?
  
        if loggers.first.respond_to?(:tagged)
          loggers.first.tagged(*tags) do
            recursive_tagged(loggers[1..-1], *tags, &block)
          end
        else # skip logger if it doesn't support tagging
          recursive_tagged(loggers[1..-1], *tags, &block)
        end
      end
  end
end

Note: I just realized this doesn't work when tagged is initially called without a block (it's supposed to return a new logger with those tags attached).

@davidstosik
Copy link
Contributor Author

davidstosik commented Oct 23, 2023

For the record, I tried a brand new Rails 7.1.1 application:

rails new testlog --minimal
cd testlog
vim config/environments/development.rb

Add this:

  loggers = [
    STDOUT,
    "log/development.log",
  ].map do |output|
    ActiveSupport::Logger.new(output)
      .tap  { |logger| logger.formatter = ::Logger::Formatter.new }
      .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
  end
  config.logger = ActiveSupport::BroadcastLogger.new(*loggers)
Start Rails server, browse to the root, and receive this error.
Puma caught this error: no implicit conversion of String into Integer (TypeError)
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/request_id.rb:28:in `[]='
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/request_id.rb:28:in `block in call'
<internal:kernel>:90:in `tap'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/request_id.rb:28:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/method_override.rb:28:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/runtime.rb:24:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/activesupport-7.1.1/lib/active_support/cache/strategy/local_cache_middleware.rb:29:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/server_timing.rb:59:in `block in call'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/server_timing.rb:24:in `collect_events'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/server_timing.rb:58:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/executor.rb:14:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/static.rb:25:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/sendfile.rb:114:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/actionpack-7.1.1/lib/action_dispatch/middleware/host_authorization.rb:141:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/railties-7.1.1/lib/rails/engine.rb:529:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/configuration.rb:272:in `call'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/request.rb:100:in `block in handle_request'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/thread_pool.rb:378:in `with_force_shutdown'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/request.rb:99:in `handle_request'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/server.rb:443:in `process_client'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/server.rb:241:in `block in run'
/Users/sto/.gem/ruby/3.2.1/gems/puma-6.4.0/lib/puma/thread_pool.rb:155:in `block in spawn_thread'

(If I change my code to broadcast to a single logger, then it works.)


Here:

@app.call(env).tap { |_status, headers, _body| headers[@header] = req.request_id }

@app.call(env) usually returns an Array which first item is an HTTP return code (eg. 200), but if I have 2 loggers in my BroadcastLogger, it will return an array like this:

[
  [200, ..., ...], # Rack::Headers and Rack::BodyProxy
  [200, ..., ...] # Rack::Headers and Rack::BodyProxy
]

This is caused by this line:

logger.tagged(*compute_tags(request)) { call_app(request, env) }

(In the current state of things, tagged will execute the block, and the call_app within it, as many times as the number of loggers in the broadcast.)

@rafaelfranca rafaelfranca added this to the 7.1.2 milestone Oct 23, 2023
@davidstosik
Copy link
Contributor Author

davidstosik commented Oct 24, 2023

Here's an executable test case reproducing the error I described manually in my previous comment.
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  gem "rails", github: "rails/rails", branch: "main"
end

require "action_controller/railtie"

class TestApp < Rails::Application
  config.root = __dir__
  config.hosts << "example.org"
  config.secret_key_base = "secret_key_base"
  routes.draw do
    get "/" => "test#index"
  end
end

class TestController < ActionController::Base
  include Rails.application.routes.url_helpers

  def index
    render plain: "Home"
  end
end

# Run Rails' initialize_logger initializer, defined here:
# https://github.com/rails/rails/blob/1f8f2fb9f5c4aa7250281e0bdd386129e06248a9/railties/lib/rails/application/bootstrap.rb#L35-L68
Rails.application.initializers
  .find { _1.name == :initialize_logger }
  .run(Rails.application)

require "minitest/autorun"
require "rack/test"

class BugTest < Minitest::Test
  include Rack::Test::Methods

  def test_returns_success
    get "/"
    assert last_response.ok?

    Rails.logger.broadcast_to(
      ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
    )

    get "/"
    assert last_response.ok?
  end

  private
    def app
      Rails.application
    end
end
And the output.
# Running:

Started GET "/" for 127.0.0.1 at 2023-10-24 09:58:29 +0900
Started GET "/" for 127.0.0.1 at 2023-10-24 09:58:29 +0900
E

Error:
BugTest#test_returns_success:
TypeError: no implicit conversion of String into Integer
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/request_id.rb:28:in `[]='
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/request_id.rb:28:in `block in call'
    <internal:kernel>:90:in `tap'
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/request_id.rb:28:in `call'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/method_override.rb:28:in `call'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/runtime.rb:24:in `call'
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/executor.rb:14:in `call'
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/static.rb:25:in `call'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-3.0.8/lib/rack/sendfile.rb:114:in `call'
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/actionpack/lib/action_dispatch/middleware/host_authorization.rb:141:in `call'
    /Users/sto/.gem/ruby/3.2.1/bundler/gems/rails-1f8f2fb9f5c4/railties/lib/rails/engine.rb:529:in `call'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-test-2.1.0/lib/rack/test.rb:360:in `process_request'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-test-2.1.0/lib/rack/test.rb:163:in `custom_request'
    /Users/sto/.gem/ruby/3.2.1/gems/rack-test-2.1.0/lib/rack/test.rb:112:in `get'
    /opt/rubies/3.2.1/lib/ruby/3.2.0/forwardable.rb:240:in `get'
    reproduce2.rb:51:in `test_returns_success'

@eileencodes
Copy link
Member

eileencodes commented Oct 24, 2023

hey @Edouard-chin I saw that you mentioned you saw this one on the other issue so I assigned this to you too. Let us know if you need more information.

@Edouard-chin
Copy link
Member

Perfect, thanks @eileencodes 👍

@davidstosik
Copy link
Contributor Author

@Edouard-chin Hi there! 👋🏻

Just a quick comment to mention that I noticed around the end of my day earlier, that #silence will have the same issues as #tagged.

Might want to consider what #flush does as well.

@Edouard-chin Edouard-chin linked a pull request Oct 24, 2023 that will close this issue
Edouard-chin added a commit to Edouard-chin/rails that referenced this issue Oct 25, 2023
- ### Context

  The Tagged Logging functionality has been a source of a few
  issues over the years, especially when combined with the
  broadcasting feature.
  Initializating a Tagged Logger wasn't very intuitive:

  ```ruby
    logger = Logger.new(STDOUT)
    tagged_logger = ActiveSupport::TaggedLogging.new(logger)
    # Would expect the `tagged_logger` to be an instance of `AS::TaggedLogging`
    # but it's a clone of the `logger`.

    tagged_logger.formatter = ->(_, _, _, message) do
      { message: message }
    end
    # Modifies the formatter to output JSON formatted logs.
    # This breaks tagged logging.
  ```

  I believe the main reason of those issues is because tagged logging
  is implemented at the wrong level.

  ### Solution

  I made a proposal on the Ruby logger upstream in ruby/logger#90 to help
  solve this but it hasn't been reviewed yet. So I thought about adding
  it here for now.
  The TL;DR is to decouple formatting and adding extra information to
  logs (which is what tagged logging does).

  ### Deprecation

  Since TaggedLogging will no longer access the formatter, there is a
  few things I'd like to deprecate (such as setting a default
  formatter https://github.com/rails/rails/blob/d68e43922bc11829c52ad9f736ad5549fc97631b/activesupport/lib/active_support/tagged_logging.rb#L124)
  but doing so in this PR would increase the size of the diff
  significantly and would maybe distract for PR reviews.

  Another thing that I believe should be deprecated is
  `ActiveSupport::TaggedLogging.new`. Adding tagging
  functionality to a logger should be done using
  a more ruby approach such as `logger.extend(AS::TaggedLogging)`.

  Fix rails#49757
  Fix rails#49745
  Fix rails#46084
  Fix rails#44668

I made a propose on the Ruby logger upstream in ruby/logger#90,
  but it hasn't been reviewed it.
@rafaelfranca rafaelfranca modified the milestones: 7.1.2, 7.1.3 Nov 10, 2023
@rafaelfranca rafaelfranca modified the milestones: 7.1.3, 7.1.4 Jan 16, 2024
Edouard-chin added a commit to Edouard-chin/rails that referenced this issue Jan 17, 2024
- ### Context

  The Tagged Logging functionality has been a source of a few
  issues over the years, especially when combined with the
  broadcasting feature.
  Initializating a Tagged Logger wasn't very intuitive:

  ```ruby
    logger = Logger.new(STDOUT)
    tagged_logger = ActiveSupport::TaggedLogging.new(logger)
    # Would expect the `tagged_logger` to be an instance of `AS::TaggedLogging`
    # but it's a clone of the `logger`.

    tagged_logger.formatter = ->(_, _, _, message) do
      { message: message }
    end
    # Modifies the formatter to output JSON formatted logs.
    # This breaks tagged logging.
  ```

  I believe the main reason of those issues is because tagged logging
  is implemented at the wrong level.

  ### Solution

  I made a proposal on the Ruby logger upstream in ruby/logger#90 to help
  solve this but it hasn't been reviewed yet. So I thought about adding
  it here for now.
  The TL;DR is to decouple formatting and adding extra information to
  logs (which is what tagged logging does).

  ### Deprecation

  Since TaggedLogging will no longer access the formatter, there is a
  few things I'd like to deprecate (such as setting a default
  formatter https://github.com/rails/rails/blob/d68e43922bc11829c52ad9f736ad5549fc97631b/activesupport/lib/active_support/tagged_logging.rb#L124)
  but doing so in this PR would increase the size of the diff
  significantly and would maybe distract for PR reviews.

  Another thing that I believe should be deprecated is
  `ActiveSupport::TaggedLogging.new`. Adding tagging
  functionality to a logger should be done using
  a more ruby approach such as `logger.extend(AS::TaggedLogging)`.

  Fix rails#49757
  Fix rails#49745
  Fix rails#46084
  Fix rails#44668

I made a propose on the Ruby logger upstream in ruby/logger#90,
  but it hasn't been reviewed it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants