├── .rspec ├── spec ├── fixtures │ └── test-app │ │ ├── app │ │ ├── views │ │ │ └── test │ │ │ │ ├── basic.html.erb │ │ │ │ └── access.html.erb │ │ └── controllers │ │ │ └── test_controller.rb │ │ └── config │ │ ├── logcraft_config.rb │ │ └── routes.rb ├── rails_helper.rb ├── spec_helper.rb ├── logcraft │ ├── rails │ │ ├── request_id_logger_spec.rb │ │ ├── action_controller │ │ │ └── log_subscriber_spec.rb │ │ ├── active_record │ │ │ └── log_subscriber_spec.rb │ │ └── request_logger_spec.rb │ ├── log_context_helper_spec.rb │ └── log_layout_spec.rb ├── logcraft_spec.rb └── integration │ └── integration_spec.rb ├── lib ├── logcraft │ ├── version.rb │ ├── rails │ │ ├── active_record.rb │ │ ├── action_controller.rb │ │ ├── request_id_logger.rb │ │ ├── active_record │ │ │ └── log_subscriber.rb │ │ ├── action_controller │ │ │ └── log_subscriber.rb │ │ └── request_logger.rb │ ├── rspec │ │ ├── helpers.rb │ │ └── matchers.rb │ ├── log_context_helper.rb │ ├── rails.rb │ ├── rspec.rb │ ├── log_layout.rb │ └── railtie.rb ├── rails_extensions │ └── action_dispatch │ │ └── debug_exceptions.rb └── logcraft.rb ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── gemfiles ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── logcraft.gemspec ├── CHANGELOG.md ├── Rakefile ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/fixtures/test-app/app/views/test/basic.html.erb: -------------------------------------------------------------------------------- 1 | dummy view for testing template render logging 2 | -------------------------------------------------------------------------------- /spec/fixtures/test-app/app/views/test/access.html.erb: -------------------------------------------------------------------------------- 1 | dummy view for testing template render logging 2 | -------------------------------------------------------------------------------- /lib/logcraft/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | VERSION = "3.0.0.rc" 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | bundle exec rake generate_rails_app 10 | -------------------------------------------------------------------------------- /spec/fixtures/test-app/config/logcraft_config.rb: -------------------------------------------------------------------------------- 1 | config.logcraft.global_context = {custom: 'data'} 2 | config.logcraft.unhandled_errors.log_level = :error 3 | config.logcraft.unhandled_errors.log_errors_handled_by_rails = false 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | .ruby-version 13 | Gemfile.lock 14 | spec/test-app 15 | -------------------------------------------------------------------------------- /lib/logcraft/rails/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | module ActiveRecord 6 | autoload :LogSubscriber, 'logcraft/rails/active_record/log_subscriber' 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/logcraft/rails/action_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | module ActionController 6 | autoload :LogSubscriber, 'logcraft/rails/action_controller/log_subscriber' 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in logcraft.gemspec 6 | gemspec 7 | 8 | group :test do 9 | gem 'rails', '~> 8.0.0' 10 | gem 'rspec-rails', '~> 8.0' 11 | gem 'sqlite3', '~> 2.0' 12 | gem 'net-smtp', require: false 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/test-app/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 3 | get '/access' => "test#access" 4 | get '/basic' => "test#basic" 5 | get '/sql' => "test#sql" 6 | get '/error' => "test#error" 7 | end 8 | -------------------------------------------------------------------------------- /lib/logcraft/rspec/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module RSpec 5 | module Helpers 6 | def log_output_is_expected 7 | expect(log_output) 8 | end 9 | 10 | def log_output 11 | @log_output.clone.readlines 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in logcraft.gemspec 6 | gemspec path: '..' 7 | 8 | group :test do 9 | gem 'rails', '~> 7.1.0' 10 | gem 'rspec-rails', '~> 6.0' 11 | gem 'sqlite3', '~> 1.4' 12 | gem 'net-smtp', require: false 13 | end 14 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in logcraft.gemspec 6 | gemspec path: '..' 7 | 8 | group :test do 9 | gem 'rails', '~> 7.2.0' 10 | gem 'rspec-rails', '~> 6.0' 11 | gem 'sqlite3', '~> 1.4' 12 | gem 'net-smtp', require: false 13 | end 14 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in logcraft.gemspec 6 | gemspec path: '..' 7 | 8 | group :test do 9 | gem 'rails', '~> 8.0.0' 10 | gem 'rspec-rails', '~> 8.0' 11 | gem 'sqlite3', '~> 2.0' 12 | gem 'net-smtp', require: false 13 | end 14 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'logcraft/railtie' 5 | require File.expand_path('../test-app/config/environment', __FILE__) 6 | require 'rspec/rails' 7 | 8 | RSpec.configure do |config| 9 | config.before :all, type: :request do 10 | host! 'localhost' 11 | end 12 | end 13 | 14 | -------------------------------------------------------------------------------- /lib/logcraft/log_context_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module LogContextHelper 5 | def within_log_context(context = {}) 6 | Logging.mdc.push context 7 | yield 8 | ensure 9 | Logging.mdc.pop 10 | end 11 | 12 | def add_to_log_context(context) 13 | Logging.mdc.update context 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/fixtures/test-app/app/controllers/test_controller.rb: -------------------------------------------------------------------------------- 1 | class TestController < ApplicationController 2 | def access 3 | end 4 | 5 | def basic 6 | logger.info message: 'test message', data: 12345 7 | end 8 | 9 | def sql 10 | ActiveRecord::Base.connection.query 'SELECT 1' 11 | head :ok 12 | end 13 | 14 | def error 15 | raise 'Unhandled error' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "logcraft" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/logcraft/rails/request_id_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | class RequestIdLogger 6 | include LogContextHelper 7 | 8 | def initialize(app) 9 | @app = app 10 | end 11 | 12 | def call(env) 13 | within_log_context request_id: env['action_dispatch.request_id'] do 14 | @app.call env 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/logcraft/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'action_controller' 4 | require 'action_controller/log_subscriber' 5 | 6 | module Logcraft 7 | module Rails 8 | autoload :ActionController, 'logcraft/rails/action_controller' 9 | autoload :ActiveRecord, 'logcraft/rails/active_record' 10 | autoload :RequestIdLogger, 'logcraft/rails/request_id_logger' 11 | autoload :RequestLogger, 'logcraft/rails/request_logger' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logcraft' 4 | require 'logcraft/rspec' 5 | require 'active_support' 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/logcraft/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logging' 4 | require 'rspec/logging_helper' 5 | require_relative 'rspec/helpers' 6 | require_relative 'rspec/matchers' 7 | require_relative 'log_layout' 8 | 9 | RSpec.configure do |config| 10 | config.include Logcraft::RSpec::Helpers 11 | config.before(:suite) do 12 | Logging.appenders.string_io('__logcraft_stringio__', layout: Logging.logger.root.appenders.first&.layout || Logcraft::LogLayout.new) 13 | config.capture_log_messages to: '__logcraft_stringio__' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails_extensions/action_dispatch/debug_exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActionDispatch 4 | class DebugExceptions 5 | 6 | private 7 | 8 | def log_error_with_logcraft(request, wrapper) 9 | logger = logger(request) 10 | exception = wrapper.exception 11 | config = Rails.configuration.logcraft.unhandled_errors 12 | logger.public_send config.log_level, exception if config.log_errors_handled_by_rails || !handled_by_rails?(exception) 13 | end 14 | 15 | alias_method :original_log_error, :log_error 16 | alias_method :log_error, :log_error_with_logcraft 17 | 18 | def handled_by_rails?(exception) 19 | ActionDispatch::ExceptionWrapper.rescue_responses.key? exception.class.name 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/logcraft/rails/request_id_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Logcraft::Rails::RequestIdLogger do 4 | describe '#call' do 5 | subject(:call) { middleware.call env } 6 | 7 | let(:middleware) { described_class.new app } 8 | let(:env) { {'action_dispatch.request_id' => 'unique request ID'} } 9 | let(:app) do 10 | ->(env) do 11 | Logcraft.logger('Application').info 'test message' 12 | "app result for #{env['action_dispatch.request_id']}" 13 | end 14 | end 15 | 16 | it 'calls the next middleware in the stack with the environment and returns the results' do 17 | expect(call).to eq 'app result for unique request ID' 18 | end 19 | 20 | it 'adds the request ID to the log context' do 21 | expect { call }.to log message: 'test message', 22 | request_id: 'unique request ID' 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} 14 | strategy: 15 | matrix: 16 | include: 17 | - ruby: '3.2' 18 | rails: '7.1' 19 | - ruby: '3.3' 20 | rails: '7.2' 21 | - ruby: '3.4' 22 | rails: '8.0' 23 | 24 | env: 25 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby }} 33 | bundler-cache: true 34 | - name: Generate Rails app 35 | run: bin/setup 36 | - name: Run unit tests 37 | run: bundle exec rake spec:unit 38 | - name: Run integration tests 39 | run: bundle exec rake spec:integration 40 | -------------------------------------------------------------------------------- /lib/logcraft.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logging' 4 | require 'ostruct' 5 | 6 | require 'logcraft/version' 7 | require 'logcraft/railtie' if defined? Rails 8 | 9 | module Logcraft 10 | autoload :LogContextHelper, 'logcraft/log_context_helper' 11 | autoload :LogLayout, 'logcraft/log_layout' 12 | autoload :Rails, 'logcraft/rails' 13 | 14 | extend LogContextHelper 15 | 16 | def self.initialize(log_level: :info, global_context: {}, layout_options: {}) 17 | Logging.logger.root.appenders = Logging.appenders.stdout layout: LogLayout.new(global_context, layout_options) 18 | Logging.logger.root.level = log_level 19 | end 20 | 21 | def self.logger(name, level = nil) 22 | Logging::Logger[name].tap do |logger| 23 | logger.level = level if level 24 | logger.instance_variable_set :@logdev, OpenStruct.new(dev: STDOUT) 25 | logger.define_singleton_method :dup do 26 | super().tap do |logger_copy| 27 | Logging::Logger.define_log_methods logger_copy 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Zoltan Ormandi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /logcraft.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/logcraft/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "logcraft" 7 | spec.version = Logcraft::VERSION 8 | spec.authors = ["Zoltan Ormandi"] 9 | spec.email = ["zoltan.ormandi@gmail.com"] 10 | 11 | spec.summary = "A zero-configuration structured logging solution for pure Ruby or Ruby on Rails projects." 12 | spec.homepage = "https://github.com/zormandi/logcraft" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 2.6.0" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = "https://github.com/zormandi/logcraft" 18 | spec.metadata["changelog_uri"] = "https://github.com/zormandi/logcraft/blob/main/CHANGELOG.md" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(__dir__) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor|Rakefile)}) 25 | end 26 | end 27 | spec.bindir = "exe" 28 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 29 | spec.require_paths = ["lib"] 30 | 31 | spec.add_dependency "logging", "~> 2.0" 32 | spec.add_dependency "multi_json", "~> 1.14" 33 | 34 | spec.add_development_dependency "bundler", "~> 2.0" 35 | spec.add_development_dependency "rake", ">= 12.0" 36 | spec.add_development_dependency "rspec", "~> 3.0" 37 | end 38 | -------------------------------------------------------------------------------- /spec/logcraft_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Logcraft do 4 | it 'has a version number' do 5 | expect(Logcraft::VERSION).not_to be nil 6 | end 7 | 8 | it 'includes the LogContextHelper methods' do 9 | expect(Logcraft).to be_a Logcraft::LogContextHelper 10 | end 11 | 12 | describe '.logger' do 13 | subject(:logger) { Logcraft.logger 'TestLogger' } 14 | 15 | before(:all) { Logging.init unless Logging.initialized? } 16 | 17 | it 'returns a Logging logger with the specified name and the default log level' do 18 | default_log_level = Logging.logger.root.level 19 | expect(logger).to be_a Logging::Logger 20 | expect(logger.name).to eq 'TestLogger' 21 | expect(logger.level).to eq default_log_level 22 | end 23 | 24 | context 'when a log level is specified' do 25 | subject(:logger) { Logcraft.logger 'TestLogger', :fatal } 26 | 27 | it 'returns a logger with the specified log level' do 28 | expect(logger.level).to eq Logging::LEVELS['fatal'] 29 | end 30 | 31 | it 'returns a logger which can be duplicated with the same log level' do 32 | logger_copy = logger.dup 33 | expect(logger_copy).to respond_to :debug 34 | expect(logger_copy.level).to eq logger.level 35 | end 36 | end 37 | 38 | it 'returns a logger which reports to ActiveSupport that it logs to STDOUT so ActiveRecord does not append a new console logger' do 39 | expect(ActiveSupport::Logger.logger_outputs_to?(logger, STDERR, STDOUT)).to be true 40 | end 41 | 42 | it 'returns a logger which can be duplicated' do 43 | logger_copy = logger.dup 44 | expect(logger_copy).to respond_to :debug 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/logcraft/rails/active_record/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | module ActiveRecord 6 | class LogSubscriber < ActiveSupport::LogSubscriber 7 | def sql(event) 8 | ::ActiveRecord::Base.logger.debug { log_message_from(event) } 9 | end 10 | 11 | private 12 | 13 | def log_message_from(event) 14 | basic_message_from(event).tap do |message| 15 | params = params_from event 16 | message[:params] = params if params.any? 17 | end 18 | end 19 | 20 | def basic_message_from(event) 21 | { 22 | message: "SQL - #{event.payload[:name] || 'Query'} (#{event.duration.round}ms)", 23 | sql: event.payload[:sql], 24 | duration: (event.duration * 1_000_000).round, 25 | duration_ms: event.duration.round, 26 | duration_sec: (event.duration / 1000.0).round(5) 27 | } 28 | end 29 | 30 | def params_from(event) 31 | return {} if event.payload.fetch(:binds, []).empty? 32 | 33 | params = event.payload[:binds] 34 | values = type_casted_values_from event 35 | param_value_pairs = params.zip(values).map do |param, value| 36 | [param.name, value_of(param, value)] 37 | end 38 | 39 | Hash[param_value_pairs] 40 | rescue NoMethodError 41 | params 42 | end 43 | 44 | def type_casted_values_from(event) 45 | binds = event.payload[:type_casted_binds] 46 | binds.respond_to?(:call) ? binds.call : binds 47 | end 48 | 49 | def value_of(param, value) 50 | param.type.binary? ? '-binary data-' : value 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/logcraft/log_context_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Logcraft::LogContextHelper do 4 | describe '#within_log_context' do 5 | let(:context_logging_test_class) do 6 | Class.new do 7 | include Logcraft::LogContextHelper 8 | 9 | def log_within_context 10 | logger = Logcraft.logger 'TestLogger' 11 | within_log_context data: 'context' do 12 | logger.info 'test1' 13 | end 14 | logger.info 'test2' 15 | end 16 | end 17 | end 18 | 19 | it 'runs the passed block within the specified log context' do 20 | context_logging_test_class.new.log_within_context 21 | 22 | expect(log_output).to include_log_message message: 'test1', data: 'context' 23 | expect(log_output).to include_log_message message: 'test2' 24 | expect(log_output).not_to include_log_message message: 'test2', data: 'context' 25 | end 26 | end 27 | 28 | describe '#add_to_log_context' do 29 | let(:context_logging_test_class) do 30 | Class.new do 31 | include Logcraft::LogContextHelper 32 | 33 | def log_with_context_added_beforehand 34 | logger = Logcraft.logger 'TestLogger' 35 | within_log_context do 36 | add_to_log_context data: 'context' 37 | logger.info 'test1' 38 | end 39 | logger.info 'test2' 40 | end 41 | end 42 | end 43 | 44 | it 'adds the specified params to the current log context' do 45 | context_logging_test_class.new.log_with_context_added_beforehand 46 | 47 | expect(log_output).to include_log_message message: 'test1', data: 'context' 48 | expect(log_output).to include_log_message message: 'test2' 49 | expect(log_output).not_to include_log_message message: 'test2', data: 'context' 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/logcraft/rspec/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | RSpec::Matchers.define :include_log_message do |expected_message| 6 | chain :at_level, :log_level 7 | 8 | match do |actual| 9 | actual.any? { |log_line| includes? log_line, expected_message } 10 | end 11 | 12 | def includes?(log_line, expected) 13 | actual = JSON.parse log_line, symbolize_names: true 14 | expected = normalize_expectation expected 15 | RSpec::Matchers::BuiltIn::Include.new(expected).matches? actual 16 | end 17 | 18 | def normalize_expectation(expected) 19 | result = case expected 20 | when String 21 | {message: expected} 22 | when Hash 23 | expected 24 | else 25 | raise ArgumentError, 'Log expectation must be either a String or a Hash' 26 | end 27 | result[:level] = log_level_string(log_level) unless log_level.nil? 28 | result 29 | end 30 | 31 | def log_level_string(log_level) 32 | return 'WARN' if log_level == :warning 33 | log_level.to_s.upcase 34 | end 35 | 36 | failure_message do |actual| 37 | error_message = "expected log output\n\t'#{actual.join('')}'\nto include log message\n\t'#{expected_message}'" 38 | error_message += " at #{log_level} level" if log_level 39 | error_message 40 | end 41 | end 42 | 43 | RSpec::Matchers.define :log do 44 | supports_block_expectations 45 | chain :at_level, :log_level 46 | 47 | failure_message do 48 | error_message = "expected operation to log '#{expected}'" 49 | error_message += " at #{log_level} level" if log_level 50 | "#{error_message}\n\nactual log output:\n#{log_output.join('')}" 51 | end 52 | 53 | match do |operation| 54 | raise 'log matcher only supports block expectations' unless operation.is_a? Proc 55 | log_output_is_expected.not_to include_log_message(expected) 56 | operation.call 57 | log_output_is_expected.to include_log_message(expected).at_level(log_level) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/logcraft/rails/action_controller/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | module ActionController 6 | class LogSubscriber < ActiveSupport::LogSubscriber 7 | def process_action(event) 8 | request = event.payload[:request] 9 | status = event.payload[:status] 10 | logger.info message: '%s %s - %i (%s)' % [request.method, request.filtered_path, status, Rack::Utils::HTTP_STATUS_CODES[status]], 11 | http: { 12 | method: request.method, 13 | status_code: status, 14 | url: request.filtered_path, 15 | url_details: { 16 | path: request.path, 17 | params: params_to_log(request), 18 | }, 19 | referer: request.referer, 20 | }.compact, 21 | network: { 22 | client: { 23 | ip: request.remote_ip, 24 | } 25 | }, 26 | duration: event.duration.round, 27 | db: { 28 | duration: event.payload[:db_runtime].to_f.round(1), 29 | duration_percentage: (event.payload[:db_runtime] / event.duration * 100).round(1), 30 | queries: event.payload[:queries_count].to_i, 31 | cached_queries: event.payload[:cached_queries_count].to_i, 32 | }, 33 | view: { 34 | duration: event.payload[:view_runtime].to_f.round(1), 35 | duration_percentage: (event.payload[:view_runtime] / event.duration * 100).round(1), 36 | } 37 | end 38 | 39 | private 40 | 41 | def params_to_log(request) 42 | request.filtered_parameters 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.2.1] - 2023-12-19 8 | ### Fixed 9 | - Fixed an issue where logs were duplicated (once in JSON, once in plain text) when running the Rails console. 10 | (https://github.com/zormandi/logcraft/issues/7) 11 | - Fixed an issue that prevented using Logcraft and Sentry-Rails together with the default configuration. 12 | (https://github.com/zormandi/logcraft/issues/6) 13 | 14 | ## [2.2] - 2023-10-23 15 | ### Added 16 | - Added support for Rails 7.1. 17 | 18 | ## [2.1] - 2023-06-13 19 | ### Added 20 | - Added support for custom log formatters. 21 | 22 | ## [2.0.1] - 2022-10-07 23 | ### Fixed 24 | - Fixed a bug where request log tracing didn't work with the DataDog integration. Had to move up logging 25 | the request to the point where the middleware is done processing instead of where we originally had it; 26 | at the time when the response body was closed. We lost some precision in terms of measuring request duration 27 | but some context (e.g. DataDog active trace) would not be available otherwise. 28 | 29 | ## [2.0.0] - 2022-07-31 30 | ### Added 31 | - Added the option to change the log level or suppress logging of unhandled errors which are, in fact, 32 | handled by Rails (e.g. 404 Not Found). 33 | 34 | ### Changed 35 | - The initial context is now fully dynamic; it can be either a Hash or a lambda/Proc returning a Hash. 36 | Using a Hash with lambda values is no longer supported. 37 | - Renamed `initial_context` configuration setting to `global_context` everywhere. 38 | 39 | ### Fixed 40 | - Fixed a bug where the request ID was missing from the access log. 41 | 42 | ### Added 43 | - The provided RSpec matchers can now take other matchers as part of the log expectation. 44 | 45 | ## [1.0.0.rc] - 2022-06-26 46 | ### Added 47 | - Logcraft was rewritten from the ground up, based on its predecessor: [Ezlog](https://github.com/emartech/ezlog). 48 | -------------------------------------------------------------------------------- /spec/integration/integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Rails log output', type: :request do 6 | describe 'access log' do 7 | it 'contains all relevant information about the request in a single line' do 8 | get '/access' 9 | 10 | expect(log_output.length).to eq 1 11 | log_output_is_expected.to include_log_message logger: include(name: 'AccessLog'), 12 | message: 'GET /access - 200 (OK)', 13 | request_id: match(/[\w-]+/) 14 | end 15 | end 16 | 17 | describe 'manual logging' do 18 | it 'contains the custom log message in a structured format with all fields' do 19 | expect { get '/basic' }.to log logger: include(name: 'Application'), 20 | message: 'test message', 21 | data: 12345 22 | end 23 | 24 | it 'contains the id of the request automatically' do 25 | expect { get '/basic', headers: {'X-Request-Id': 'test-request-id'} }.to log request_id: 'test-request-id' 26 | end 27 | 28 | it 'contains the custom initial context set in the application configuration' do 29 | expect { get '/basic' }.to log custom: 'data' 30 | end 31 | end 32 | 33 | describe 'database query logs' do 34 | around do |spec| 35 | original_log_level = ActiveRecord::Base.logger.level 36 | ActiveRecord::Base.logger.level = :debug 37 | spec.run 38 | ActiveRecord::Base.logger.level = original_log_level 39 | end 40 | 41 | it 'contains log messages for SQL queries' do 42 | expect { get '/sql' }.to log logger: include(name: 'Application'), 43 | sql: 'SELECT 1' 44 | end 45 | end 46 | 47 | describe 'unhandled error logging' do 48 | it 'contains the unhandled error log message in a single line and structured format at the configured log level' do 49 | expect { get '/error' }.to log logger: include(name: 'Application'), 50 | level: 'ERROR', 51 | message: 'Unhandled error' 52 | expect(log_output.size).to eq 2 53 | end 54 | 55 | it 'can be configured to suppress logs for errors handled by Rails' do 56 | expect { get '/not_found' }.not_to log message: /No route matches.*/ 57 | expect(log_output.size).to eq 1 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/logcraft/log_layout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Logcraft 6 | class LogLayout < Logging::Layout 7 | JSON_FORMATTER = ->(event) { MultiJson.dump(event) + "\n" }.freeze 8 | LOGGING_LEVEL_FORMATTER = ->(level) { Logging::LNAMES[level] }.freeze 9 | 10 | def initialize(global_context = {}, options = {}) 11 | @global_context = global_context 12 | @formatter = options.fetch :formatter, JSON_FORMATTER 13 | @level_formatter = options.fetch :level_formatter, LOGGING_LEVEL_FORMATTER 14 | end 15 | 16 | def format(event) 17 | log_entry = background_of(event).merge evaluated_global_context, 18 | dynamic_log_context, 19 | message_from(event.data) 20 | @formatter.call log_entry 21 | end 22 | 23 | private 24 | 25 | def background_of(event) 26 | { 27 | 'timestamp' => event.time.iso8601(3), 28 | 'level' => @level_formatter.call(event.level), 29 | 'logger' => { 30 | 'name' => event.logger, 31 | 'thread_id' => Thread.current.native_thread_id, 32 | 'thread_name' => Thread.current.name, 33 | 'process_id' => Process.pid 34 | }.compact, 35 | 'hostname' => Socket.gethostname, 36 | } 37 | end 38 | 39 | def evaluated_global_context 40 | if @global_context.respond_to? :call 41 | @global_context.call 42 | else 43 | @global_context 44 | end 45 | end 46 | 47 | def dynamic_log_context 48 | Logging.mdc.context 49 | end 50 | 51 | def message_from(payload) 52 | case payload 53 | when Hash 54 | format_hash payload 55 | when Exception 56 | {'message' => payload.message, 'error' => format_exception(payload)} 57 | else 58 | {'message' => payload} 59 | end 60 | end 61 | 62 | def format_hash(hash) 63 | hash.transform_values { |v| v.is_a?(Exception) ? format_exception(v) : v } 64 | end 65 | 66 | def format_exception(exception) 67 | error_hash = {'class' => exception.class.name, 'message' => exception.message} 68 | error_hash['stack'] = exception.backtrace.first(20) if exception.backtrace 69 | error_hash['cause'] = format_cause(exception.cause) if exception.cause 70 | error_hash 71 | end 72 | 73 | def format_cause(cause) 74 | cause = cause.cause while cause.cause 75 | format_exception cause 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | def add_logcraft_options_to_application_configuration 7 | project_root = File.dirname __FILE__ 8 | Dir.chdir(project_root + '/spec') do 9 | app_config = File.readlines 'test-app/config/application.rb' 10 | modified_config = app_config.each_with_object([]) do |line, config| 11 | config << line 12 | if line.include? 'config.load_defaults' 13 | logcraft_config = File.readlines 'fixtures/test-app/config/logcraft_config.rb' 14 | config.concat logcraft_config 15 | end 16 | end 17 | File.write 'test-app/config/application.rb', modified_config.join 18 | end 19 | end 20 | 21 | def customize_development_environment_configuration 22 | project_root = File.dirname __FILE__ 23 | Dir.chdir(project_root + '/spec/test-app') do 24 | FileUtils.rm_rf 'config/initializers/assets.rb' 25 | app_config = File.readlines 'config/environments/development.rb' 26 | modified_config = app_config.each_with_object([]) do |line, config| 27 | line = " config.active_record.query_log_tags_enabled = false\n" if line.include? 'config.active_record.query_log_tags_enabled' 28 | config << line unless sprockets_configuration? line 29 | end 30 | File.write 'config/environments/development.rb', modified_config.join 31 | end 32 | end 33 | 34 | def sprockets_configuration?(line) 35 | line.include? 'config.assets' 36 | end 37 | 38 | desc 'Generate sample Rails app for acceptance testing' 39 | task :generate_rails_app do 40 | project_root = File.dirname __FILE__ 41 | Dir.chdir(project_root + '/spec') do 42 | FileUtils.rm_rf 'test-app' 43 | system 'rails new test-app --database=sqlite3 --skip-gemfile --skip-git --skip-keeps'\ 44 | '--skip-action-mailbox --skip-action-text --skip-active-storage --skip-puma --skip-action-cable'\ 45 | '--skip-sprockets --skip-spring --skip-listen --skip-javascript --skip-turbolinks --skip-jbuilder --skip-test'\ 46 | '--skip-system-test --skip-bootsnap --skip-bundle --skip-webpack-install' 47 | FileUtils.cp_r 'fixtures/test-app/.', 'test-app', remove_destination: true 48 | add_logcraft_options_to_application_configuration 49 | customize_development_environment_configuration 50 | end 51 | end 52 | 53 | namespace :spec do 54 | desc 'Run RSpec unit tests' 55 | RSpec::Core::RakeTask.new(:unit) do |t| 56 | t.exclude_pattern = 'spec/integration/*_spec.rb' 57 | end 58 | 59 | desc 'Run RSpec integration tests' 60 | RSpec::Core::RakeTask.new(:integration) do |t| 61 | t.pattern = 'spec/integration/*_spec.rb' 62 | end 63 | end 64 | 65 | desc 'Run all RSpec examples' 66 | task spec: [:'spec:unit', :'spec:integration'] 67 | 68 | task default: :spec 69 | -------------------------------------------------------------------------------- /spec/logcraft/rails/action_controller/log_subscriber_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support' 4 | 5 | RSpec.describe Logcraft::Rails::ActionController::LogSubscriber do 6 | before do 7 | allow(ActiveSupport::LogSubscriber).to receive(:logger).and_return Logcraft.logger('AccessLog') 8 | end 9 | 10 | describe '#process_action' do 11 | subject(:trigger_event) { described_class.new.process_action(event) } 12 | 13 | let(:request) do 14 | double 'Request', 15 | method: 'GET', 16 | filtered_path: '/widgets?x=1', 17 | path: '/widgets', 18 | referer: referer, 19 | remote_ip: '127.0.0.1', 20 | filtered_parameters: { x: '1' } 21 | end 22 | let(:referer) { 'https://example.test' } 23 | 24 | let(:event) do 25 | instance_double ActiveSupport::Notifications::Event, 26 | payload: { 27 | request: request, 28 | status: 200, 29 | db_runtime: 12.345, 30 | queries_count: 3, 31 | cached_queries_count: 1, 32 | view_runtime: 45.6789 33 | }, 34 | duration: 123.7 35 | end 36 | 37 | it 'logs a single structured message with request and timing details' do 38 | expect { trigger_event }.to log(message: 'GET /widgets?x=1 - 200 (OK)', 39 | http: { 40 | method: 'GET', 41 | status_code: 200, 42 | url: '/widgets?x=1', 43 | url_details: { path: '/widgets', params: { x: '1' } }, 44 | referer: 'https://example.test' 45 | }, 46 | network: { client: { ip: '127.0.0.1' } }, 47 | duration: 124, 48 | db: { 49 | duration: 12.3, 50 | duration_percentage: 10.0, 51 | queries: 3, 52 | cached_queries: 1 53 | }, 54 | view: { 55 | duration: 45.7, 56 | duration_percentage: 36.9 57 | }).at_level(:info) 58 | 59 | end 60 | 61 | context 'when request has no referer' do 62 | let(:referer) { nil } 63 | 64 | it 'does not include a referer value in the log' do 65 | expect { trigger_event }.to_not log http: include(referer: nil) 66 | 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/logcraft/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/railtie' 4 | 5 | module Logcraft 6 | class Railtie < ::Rails::Railtie 7 | config.logcraft = ActiveSupport::OrderedOptions.new 8 | config.logcraft.global_context = {} 9 | config.logcraft.layout_options = ActiveSupport::OrderedOptions.new 10 | 11 | config.logcraft.access_log = ActiveSupport::OrderedOptions.new 12 | config.logcraft.access_log.logger_name = 'AccessLog' 13 | config.logcraft.access_log.exclude_paths = [] 14 | config.logcraft.access_log.log_only_whitelisted_params = false 15 | config.logcraft.access_log.whitelisted_params = [:controller, :action] 16 | 17 | config.logcraft.unhandled_errors = ActiveSupport::OrderedOptions.new 18 | config.logcraft.unhandled_errors.log_level = :fatal 19 | config.logcraft.unhandled_errors.log_errors_handled_by_rails = true 20 | 21 | initializer 'logcraft.initialize' do |app| 22 | Logcraft.initialize log_level: app.config.log_level, 23 | global_context: app.config.logcraft.global_context, 24 | layout_options: app.config.logcraft.layout_options 25 | end 26 | 27 | initializer 'logcraft.configure_rails' do |app| 28 | require 'rails_extensions/action_dispatch/debug_exceptions' 29 | app.config.middleware.insert_before ::Rails::Rack::Logger, 30 | Logcraft::Rails::RequestLogger, 31 | Logcraft.logger(config.logcraft.access_log.logger_name), 32 | config.logcraft.access_log 33 | app.config.middleware.delete ::Rails::Rack::Logger 34 | app.config.middleware.insert_after ::ActionDispatch::RequestId, Logcraft::Rails::RequestIdLogger 35 | end 36 | 37 | config.before_configuration do |app| 38 | app.config.logger = if defined? ActiveSupport::BroadcastLogger 39 | ActiveSupport::BroadcastLogger.new Logcraft.logger('Application') 40 | else 41 | Logcraft.logger 'Application' 42 | end 43 | app.config.log_level = ENV['LOG_LEVEL'] || :info 44 | end 45 | 46 | config.after_initialize do 47 | detach_rails_log_subscribers 48 | attach_logcraft_log_subscribers 49 | end 50 | 51 | private 52 | 53 | def self.detach_rails_log_subscribers 54 | ::ActionController::LogSubscriber.detach_from :action_controller 55 | require 'action_view/log_subscriber' unless defined? ::ActionView::LogSubscriber 56 | ::ActionView::LogSubscriber.detach_from :action_view 57 | ::ActiveRecord::LogSubscriber.detach_from :active_record if defined? ::ActiveRecord 58 | end 59 | 60 | def self.attach_logcraft_log_subscribers 61 | Logcraft::Rails::ActiveRecord::LogSubscriber.attach_to :active_record if defined? ::ActiveRecord 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/logcraft/rails/request_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Logcraft 4 | module Rails 5 | class RequestLogger 6 | def initialize(app, logger, config) 7 | @app = app 8 | @logger = logger 9 | @config = config 10 | end 11 | 12 | def call(env) 13 | start_time = current_time_in_milliseconds 14 | request = ActionDispatch::Request.new env 15 | 16 | instrumentation_start request 17 | status, headers, body = @app.call env 18 | body = ::Rack::BodyProxy.new(body) { instrumentation_finish request } 19 | log_request request, status, start_time 20 | 21 | [status, headers, body] 22 | rescue Exception => ex 23 | instrumentation_finish request 24 | log_request request, status_for_error(ex), start_time 25 | raise 26 | ensure 27 | ActiveSupport::LogSubscriber.flush_all! 28 | end 29 | 30 | private 31 | 32 | def current_time_in_milliseconds 33 | Process.clock_gettime Process::CLOCK_MONOTONIC, :millisecond 34 | end 35 | 36 | def instrumentation_start(request) 37 | instrumenter = ActiveSupport::Notifications.instrumenter 38 | instrumenter.start 'request.action_dispatch', request: request 39 | end 40 | 41 | def instrumentation_finish(request) 42 | instrumenter = ActiveSupport::Notifications.instrumenter 43 | instrumenter.finish 'request.action_dispatch', request: request 44 | end 45 | 46 | def log_request(request, status, start_time) 47 | return if path_ignored? request 48 | 49 | end_time = current_time_in_milliseconds 50 | @logger.info message: '%s %s - %i (%s)' % [request.method, request.filtered_path, status, Rack::Utils::HTTP_STATUS_CODES[status]], 51 | remote_ip: request.remote_ip, 52 | method: request.method, 53 | path: request.filtered_path, 54 | params: params_to_log(request), 55 | response_status_code: status, 56 | duration: end_time - start_time, 57 | duration_sec: (end_time - start_time) / 1000.0 58 | end 59 | 60 | def path_ignored?(request) 61 | @config.exclude_paths.any? do |pattern| 62 | case pattern 63 | when Regexp 64 | pattern.match? request.path 65 | else 66 | pattern == request.path 67 | end 68 | end 69 | end 70 | 71 | def params_to_log(request) 72 | if @config.log_only_whitelisted_params 73 | request.filtered_parameters.slice *@config.whitelisted_params&.map(&:to_s) 74 | else 75 | request.filtered_parameters 76 | end 77 | end 78 | 79 | def status_for_error(error) 80 | ActionDispatch::ExceptionWrapper.status_code_for_exception error.class.name 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/logcraft/rails/active_record/log_subscriber_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | 5 | RSpec.describe Logcraft::Rails::ActiveRecord::LogSubscriber do 6 | before do 7 | allow(::ActiveRecord::Base).to receive(:logger).and_return Logcraft.logger('Application', :debug) 8 | end 9 | 10 | describe '#sql' do 11 | subject(:trigger_event) { described_class.new.sql event } 12 | let(:event) do 13 | instance_double ActiveSupport::Notifications::Event, 14 | payload: { 15 | name: 'User Load', 16 | sql: 'SELECT * FROM users' 17 | }, 18 | duration: 321.2357436 19 | end 20 | 21 | it 'logs the SQL query execution event at DEBUG level' do 22 | expect { trigger_event }.to log(message: 'SQL - User Load (321ms)', 23 | sql: 'SELECT * FROM users', 24 | duration: 321235744, 25 | duration_ms: 321, 26 | duration_sec: 0.32124).at_level(:debug) 27 | Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) 28 | end 29 | 30 | context 'when the payload has no name' do 31 | let(:event) do 32 | instance_double ActiveSupport::Notifications::Event, 33 | payload: { 34 | sql: 'SELECT 1' 35 | }, 36 | duration: 123 37 | end 38 | 39 | it 'logs the event as a manual query' do 40 | expect { trigger_event }.to log message: 'SQL - Query (123ms)' 41 | end 42 | end 43 | 44 | context 'when the query has parameters' do 45 | let(:event) do 46 | instance_double ActiveSupport::Notifications::Event, 47 | payload: { 48 | name: 'User Load', 49 | sql: 'SELECT * FROM users LIMIT $1', 50 | binds: [ActiveRecord::Relation::QueryAttribute.new('LIMIT', 51 | 1, 52 | instance_spy(ActiveModel::Type::Value, binary?: false))], 53 | type_casted_binds: [1] 54 | }, 55 | duration: 1.235 56 | end 57 | 58 | it 'logs the query parameters' do 59 | expect { trigger_event }.to log(params: {LIMIT: 1}).at_level(:debug) 60 | end 61 | 62 | context 'when there are binary parameters' do 63 | let(:event) do 64 | instance_double ActiveSupport::Notifications::Event, 65 | payload: { 66 | name: 'User Load', 67 | sql: 'SELECT * FROM users WHERE bytecode = $1', 68 | binds: [ActiveRecord::Relation::QueryAttribute.new('bytecode', 69 | 'some binary value', 70 | instance_spy(ActiveModel::Type::Value, binary?: true))], 71 | type_casted_binds: ['some binary value'] 72 | }, 73 | duration: 1.235 74 | end 75 | 76 | it "doesn't log the binary parameter's value" do 77 | expect { trigger_event }.to log(params: {bytecode: '-binary data-'}).at_level(:debug) 78 | end 79 | end 80 | 81 | context 'when param values are bound by a proc' do 82 | let(:event) do 83 | instance_double ActiveSupport::Notifications::Event, 84 | payload: { 85 | name: 'User Load', 86 | sql: 'SELECT * FROM users LIMIT $1', 87 | binds: [ActiveRecord::Relation::QueryAttribute.new('LIMIT', 88 | 1, 89 | instance_spy(ActiveModel::Type::Value, binary?: false))], 90 | type_casted_binds: -> { [1] } 91 | }, 92 | duration: 1.235 93 | end 94 | 95 | it 'logs the query parameter values correctly' do 96 | expect { trigger_event }.to log(params: {LIMIT: 1}).at_level(:debug) 97 | end 98 | end 99 | 100 | context 'when the parameter is an Array' do 101 | let(:event) do 102 | instance_double ActiveSupport::Notifications::Event, 103 | payload: { 104 | name: 'User Load', 105 | sql: 'SELECT * FROM users WHERE users.id IN ($1, $2, $3, $4)', 106 | binds: [1, 2, 3, 4], 107 | type_casted_binds: [1, 2, 3, 4] 108 | }, 109 | duration: 1.235 110 | end 111 | 112 | it 'logs the query parameter values correctly' do 113 | expect { trigger_event }.to log(params: [1, 2, 3, 4]).at_level(:debug) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via https://github.com/zormandi/logcraft. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /spec/logcraft/rails/request_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | 5 | RSpec.describe Logcraft::Rails::RequestLogger do 6 | let(:middleware) { described_class.new app, Logcraft.logger('AccessLog'), config } 7 | let(:config) { OpenStruct.new config_options } 8 | let(:config_options) do 9 | { 10 | log_only_whitelisted_params: false, 11 | whitelisted_params: [:controller, :action], 12 | exclude_paths: [] 13 | } 14 | end 15 | 16 | describe '#call' do 17 | subject(:call) do 18 | s, h, body_proxy = middleware.call env 19 | body_proxy.close 20 | [s, h, body_proxy] 21 | end 22 | 23 | let(:env) do 24 | { 25 | 'REQUEST_METHOD' => 'GET', 26 | 'QUERY_STRING' => 'test=true', 27 | 'PATH_INFO' => '/healthcheck', 28 | 'action_dispatch.remote_ip' => '127.0.0.1', 29 | 'rack.input' => StringIO.new('') 30 | } 31 | end 32 | 33 | let(:app) { double 'application' } 34 | let(:app_call) { -> { [status, headers, body] } } 35 | 36 | let(:status) { 200 } 37 | let(:headers) { double 'headers' } 38 | let(:body) { double 'body' } 39 | 40 | before do 41 | allow(app).to receive(:call).with(env) { app_call.call } 42 | end 43 | 44 | it 'calls the next middleware in the stack and returns the results' do 45 | s, h, b = call 46 | expect(s).to eq status 47 | expect(h).to eq headers 48 | expect(b).to be_a ::Rack::BodyProxy 49 | expect(b.instance_variable_get :@body).to eq body 50 | end 51 | 52 | it 'logs the request path and result as a message' do 53 | expect { call }.to log(message: 'GET /healthcheck?test=true - 200 (OK)').at_level :info 54 | end 55 | 56 | it 'logs additional information about the request including all parameters' do 57 | expect { call }.to log logger: include(name: 'AccessLog'), 58 | remote_ip: '127.0.0.1', 59 | method: 'GET', 60 | path: '/healthcheck?test=true', 61 | params: {test: 'true'}, 62 | response_status_code: 200 63 | end 64 | 65 | it 'logs the request duration' do 66 | allow(::Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC, :millisecond).and_return 2373390260, 67 | 2373390403 68 | expect { call }.to log duration: 143, 69 | duration_sec: 0.143 70 | end 71 | 72 | context 'when the request path is excluded from logging' do 73 | context 'if the ignored path is a string' do 74 | before { config.exclude_paths << '/healthcheck' } 75 | 76 | it 'does not log anything for that path' do 77 | call 78 | log_output_is_expected.to be_empty 79 | end 80 | 81 | it 'only ignores complete matches' do 82 | env['PATH_INFO'] = '/' 83 | call 84 | log_output_is_expected.not_to be_empty 85 | end 86 | end 87 | 88 | context 'if the ignored path is a regexp' do 89 | before { config.exclude_paths << %r(/he\w+ck) } 90 | 91 | it 'does not log anything for paths that match the ignored path' do 92 | call 93 | log_output_is_expected.to be_empty 94 | end 95 | end 96 | end 97 | 98 | context 'when the params contain sensitive information' do 99 | before do 100 | env['QUERY_STRING'] = 'password=test_pass' 101 | env['action_dispatch.parameter_filter'] = [:password] 102 | end 103 | 104 | it 'logs the request path with sensitive information filtered out' do 105 | expect { call }.to log message: 'GET /healthcheck?password=[FILTERED] - 200 (OK)' 106 | end 107 | 108 | it 'logs the request params with sensitive information filtered out' do 109 | expect { call }.to log params: {password: '[FILTERED]'} 110 | end 111 | end 112 | 113 | context 'when only whitelisted params should be logged' do 114 | before do 115 | config.log_only_whitelisted_params = true 116 | config.whitelisted_params << :allowed 117 | env['QUERY_STRING'] = 'allowed=1¬_allowed=2' 118 | end 119 | 120 | it 'logs only the whitelisted params' do 121 | expect { call }.to log params: {allowed: '1'} 122 | end 123 | 124 | context 'when the whitelisted params contain sensitive information' do 125 | before do 126 | env['action_dispatch.parameter_filter'] = [:allowed] 127 | end 128 | 129 | it 'logs only the whitelisted params with sensitive information filtered out' do 130 | expect { call }.to log params: {allowed: '[FILTERED]'} 131 | end 132 | end 133 | end 134 | 135 | context 'when the request raises an error' do 136 | let(:app_call) { -> { raise Exception, 'test error' } } 137 | 138 | it 'logs the request and reraises the error' do 139 | expect { call }.to raise_error(Exception, 'test error') 140 | .and log(message: 'GET /healthcheck?test=true - 500 (Internal Server Error)') 141 | end 142 | end 143 | 144 | context 'when there are log subscribers for request.action_dispatch events' do 145 | let(:called) { {times: 0} } 146 | 147 | before do 148 | ActiveSupport::Notifications.subscribe 'request.action_dispatch' do |name, started, finished| 149 | called[:times] += 1 150 | expect(name).to eq 'request.action_dispatch' 151 | expect(started).to be_within(1).of Time.now 152 | expect(finished).to be_within(1).of Time.now 153 | expect(finished).to be > started 154 | end 155 | end 156 | 157 | it 'dispatches the appropriate event to the subscribers' do 158 | call 159 | expect(called[:times]).to eq 1 160 | end 161 | 162 | context 'when the request raises an error' do 163 | let(:app_call) { -> { raise Exception, 'test error' } } 164 | 165 | it 'still dispatches the appropriate event to the subscribers' do 166 | begin; call; rescue Exception; nil; end 167 | expect(called[:times]).to eq 1 168 | end 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/logcraft/log_layout_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Logcraft::LogLayout do 4 | before(:all) { Logging.init unless Logging.initialized? } 5 | 6 | describe '#format' do 7 | subject(:log_line) { layout.format event } 8 | let(:log_line_hash) { JSON.parse log_line } 9 | 10 | let(:layout) { described_class.new context, options } 11 | let(:context) { {} } 12 | let(:options) { {} } 13 | let(:event) { Logging::LogEvent.new 'TestLogger', Logging::LEVELS['info'], event_data, false } 14 | let(:event_data) { '' } 15 | 16 | it 'includes the basic context of the event' do 17 | expect(log_line_hash).to include 'timestamp' => event.time.iso8601(3), 18 | 'level' => 'INFO', 19 | 'logger' => { 20 | 'name' => 'TestLogger', 21 | 'thread_id' => Thread.current.native_thread_id, 22 | 'process_id' => Process.pid, 23 | }, 24 | 'hostname' => Socket.gethostname 25 | end 26 | 27 | it 'includes the logging thread name if it has one' do 28 | Thread.current.name = 'test_thread' 29 | expect(log_line_hash['logger']).to include 'thread_name' => 'test_thread' 30 | Thread.current.name = nil 31 | end 32 | 33 | it 'ends with a new line' do 34 | expect(log_line).to end_with "\n" 35 | end 36 | 37 | describe 'event data formatting' do 38 | context 'when the event data is a String' do 39 | let(:event_data) { 'Hello, World!' } 40 | 41 | it 'contains the data in the message field' do 42 | expect(log_line_hash).to include 'message' => 'Hello, World!' 43 | end 44 | end 45 | 46 | context 'when the event data is a Hash' do 47 | let(:event_data) { {test: 'data', field: 'value'} } 48 | 49 | it 'contains the complete event data' do 50 | expect(log_line_hash).to include 'test' => 'data', 51 | 'field' => 'value' 52 | end 53 | 54 | context 'when one of the values is an exception' do 55 | let(:event_data) { {message: 'failure', error: StandardError.new('something went wrong')} } 56 | 57 | it 'contains the error context for the exception' do 58 | expect(log_line_hash).to include 'message' => 'failure', 59 | 'error' => { 60 | 'class' => 'StandardError', 61 | 'message' => 'something went wrong' 62 | } 63 | end 64 | end 65 | end 66 | 67 | context 'when the event data is an Exception' do 68 | let(:event_data) { StandardError.new 'error message' } 69 | let(:backtrace) { nil } 70 | 71 | before { event_data.set_backtrace backtrace } 72 | 73 | it 'contains the details of the exception in the message and error fields' do 74 | expect(log_line_hash).to include 'message' => 'error message', 75 | 'error' => { 76 | 'class' => 'StandardError', 77 | 'message' => 'error message' 78 | } 79 | end 80 | 81 | context 'when the exception contains a backtrace' do 82 | let(:backtrace) { ['file1:line1', 'file2:line2'] } 83 | 84 | it 'includes the backtrace in the error context' do 85 | expect(log_line_hash['error']).to include 'stack' => backtrace 86 | end 87 | 88 | context 'but the backtrace is long' do 89 | let(:backtrace) { 1.upto(25).map { |i| "file#{i}:line#{i}" } } 90 | 91 | it 'only includes the first 20 locations of the backtrace in the error context' do 92 | expect(log_line_hash['error']).to include 'stack' => backtrace.first(20) 93 | end 94 | end 95 | end 96 | 97 | context 'when the exception has an underlying cause' do 98 | let(:event_data) { nest_exception RuntimeError.new('original error'), StandardError.new('wrapping error') } 99 | 100 | it 'includes the underlying error in the cause field of the error context' do 101 | expect(log_line_hash).to include 'message' => 'wrapping error', 102 | 'error' => { 103 | 'class' => 'StandardError', 104 | 'message' => 'wrapping error', 105 | 'cause' => include( 106 | 'class' => 'RuntimeError', 107 | 'message' => 'original error' 108 | ) 109 | } 110 | end 111 | 112 | context 'when there are nested causes' do 113 | let(:event_data) do 114 | error = nest_exception RuntimeError.new('original error'), StandardError.new('inner wrapping error') 115 | nest_exception error, StandardError.new('outer wrapping error') 116 | end 117 | 118 | it 'includes only the final underlying cause' do 119 | expect(log_line_hash).to include 'message' => 'outer wrapping error', 120 | 'error' => { 121 | 'class' => 'StandardError', 122 | 'message' => 'outer wrapping error', 123 | 'cause' => include( 124 | 'class' => 'RuntimeError', 125 | 'message' => 'original error' 126 | ) 127 | } 128 | end 129 | end 130 | end 131 | 132 | def nest_exception(original_error, wrapping_error) 133 | begin 134 | raise original_error 135 | rescue 136 | raise wrapping_error 137 | end 138 | rescue => ex 139 | ex 140 | end 141 | end 142 | end 143 | 144 | context 'when a global context is provided upon initialization' do 145 | let(:context) { {context: 'data'} } 146 | 147 | it 'includes the global context fields' do 148 | expect(log_line_hash).to include 'context' => 'data' 149 | end 150 | 151 | context 'when the global context is callable (lambda or Proc)' do 152 | let(:context) { Proc.new { {custom_data: 'dynamic data'} } } 153 | 154 | it 'evaluates the lambda or Proc and includes the result' do 155 | expect(log_line_hash).to include 'custom_data' => 'dynamic data' 156 | end 157 | end 158 | end 159 | 160 | context 'when a formatter is provided as an option' do 161 | let(:options) { {formatter: ->(event) { YAML.dump event }} } 162 | let(:event_data) { 'Hello, World!' } 163 | 164 | it 'outputs the log event through the formatter' do 165 | expect(log_line).to eq <<~YAML 166 | --- 167 | timestamp: '#{event.time.iso8601(3)}' 168 | level: INFO 169 | logger: 170 | name: TestLogger 171 | thread_id: #{Thread.current.native_thread_id} 172 | process_id: #{Process.pid} 173 | hostname: #{Socket.gethostname} 174 | message: Hello, World! 175 | YAML 176 | end 177 | end 178 | 179 | context 'when a log level formatter is provided as an option' do 180 | let(:options) { {level_formatter: ->(level_number) { "level #{level_number}" }} } 181 | 182 | it 'includes the custom log level name' do 183 | expect(log_line_hash).to include 'level' => 'level 1' 184 | end 185 | end 186 | 187 | context 'when a log context is set' do 188 | before do 189 | Logging.mdc['customer_id'] = 'some_customer' 190 | Logging.mdc['request_id'] = 'the_request' 191 | end 192 | 193 | after { Logging.mdc.clear } 194 | 195 | it 'includes the log context fields' do 196 | expect(log_line_hash).to include 'customer_id' => 'some_customer', 197 | 'request_id' => 'the_request' 198 | end 199 | 200 | context 'when the event data contains fields that conflict with the log context' do 201 | let(:event_data) { {'customer_id' => 'overriden'} } 202 | 203 | it 'contains the message data, not the log context' do 204 | expect(log_line_hash).to include 'customer_id' => 'overriden' 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logcraft 2 | 3 | [![Build Status](https://github.com/zormandi/logcraft/actions/workflows/main.yml/badge.svg)](https://github.com/zormandi/logcraft/actions/workflows/main.yml) 4 | [![Gem Version](https://badge.fury.io/rb/logcraft.svg)](https://badge.fury.io/rb/logcraft) 5 | 6 | Logcraft is a zero-configuration structured logging library for pure Ruby or [Ruby on Rails](https://rubyonrails.org/) 7 | applications. It is the successor to [Ezlog](https://github.com/emartech/ezlog) with which it shares its ideals but is 8 | reimagined and reimplemented to be more versatile and much more thoroughly tested. 9 | 10 | Logcraft's purpose is threefold: 11 | 12 | 1. Make sure that our applications are logging in a concise and sensible manner; emitting no unnecessary "noise" but 13 | containing all relevant and necessary information (like timing or a request ID). 14 | 2. Make sure that all log messages are written to STDOUT in a machine-processable format (JSON). 15 | 3. Achieving the above goals should require no configuration in the projects where the library is used. 16 | 17 | Logcraft 3 supports: 18 | 19 | * [Ruby](https://www.ruby-lang.org) 3.2 and up (tested with 3.2, 3.3 and 3.4) 20 | * [Rails](https://rubyonrails.org/) 7.1 and up (tested with 7.1, 7.2 and 8.0) 21 | 22 | > [!NOTE] 23 | > If you're using Ruby 3.1 or earlier along with Rails 7.0 or earlier, then take a look at [Logcraft 2.2.1](https://github.com/zormandi/logcraft/tree/v2.2.1). 24 | 25 | ## Table of contents 26 | 27 | * [Installation](#installation) 28 | * [Rails](#rails) 29 | * [Non-Rails applications](#non-rails-applications) 30 | * [Usage](#usage) 31 | * [Structured logging](#structured-logging) 32 | * [Adding context information to log messages](#adding-context-information-to-log-messages) 33 | * [Rails logging](#rails-logging) 34 | * [The log level](#the-log-level) 35 | * [JSON serialization](#json-serialization) 36 | * [Configuration options](#configuration-options) 37 | * [Rails configuration](#rails-configuration) 38 | * [Non-Rails configuration](#non-rails-configuration) 39 | * [Integration with DataDog](#integration-with-datadog) 40 | * [RSpec support](#rspec-support) 41 | * [Development](#development) 42 | * [Contributing](#contributing) 43 | * [Disclaimer](#disclaimer) 44 | * [License](#license) 45 | * [Code of Conduct](#code-of-conduct) 46 | 47 | ## Installation 48 | 49 | ### Rails 50 | 51 | Add this line to your application's Gemfile: 52 | 53 | ```ruby 54 | gem 'logcraft' 55 | gem 'oj' # Optional, but recommended; see the "JSON serialization" section in the README 56 | ``` 57 | 58 | Although Logcraft sets up sensible defaults for all logging configuration settings, it leaves you the option to override 59 | these settings manually in the way you're used to; via Rails's configuration mechanism. Unfortunately the Rails new 60 | project generator automatically generates code for the production environment configuration that overrides some of these 61 | default settings. 62 | 63 | For Logcraft to work properly, you need to delete or comment out the logging configuration options in the generated 64 | `config/environments/production.rb` file. 65 | 66 | ### Non-Rails applications 67 | 68 | Add this line to your application's Gemfile: 69 | 70 | ```ruby 71 | gem 'logcraft' 72 | ``` 73 | 74 | and call 75 | 76 | ```ruby 77 | Logcraft.initialize 78 | ``` 79 | 80 | any time during your application's startup. 81 | 82 | ## Usage 83 | 84 | ### Structured logging 85 | 86 | Any loggers created by your application (including the `Rails.logger`) will automatically be configured to write 87 | messages in JSON format to the standard output. These loggers can handle a variety of message types: 88 | 89 | * String 90 | * Hash 91 | * Exception 92 | * any other object that can be coerced into a String 93 | 94 | The logger also automatically adds some basic information to all messages, such as: 95 | 96 | * name of the logger 97 | * timestamp 98 | * log level (as string) 99 | * hostname 100 | * PID 101 | 102 | Examples: 103 | 104 | ```ruby 105 | logger = Logcraft.logger 'Application' 106 | 107 | logger.info 'Log message' 108 | # => {"timestamp":"2022-06-26T17:52:57.845+02:00","level":"INFO","logger":"Application","hostname":"MacbookPro.local","pid":80422,"message":"Log message"} 109 | 110 | logger.info message: 'User logged in', user_id: 42 111 | # => {"timestamp":"2022-06-26T17:44:01.926+02:00","level":"INFO","logger":"Application","hostname":"MacbookPro.local","pid":80422,"message":"User logged in","user_id":42} 112 | 113 | logger.error error 114 | # Formatted for better readability (the original is a single line string): 115 | # => { 116 | # "timestamp": "2022-06-26T17:46:42.418+02:00", 117 | # "level": "ERROR", 118 | # "logger": "Application", 119 | # "hostname": "MacbookPro.local", 120 | # "pid": 80422, 121 | # "message": "wrapping error", 122 | # "error": { 123 | # "class": "StandardError", 124 | # "message": "wrapping error", 125 | # "backtrace": [...], 126 | # "cause": { 127 | # "class": "RuntimeError", 128 | # "message": "original error", 129 | # "backtrace": [...] 130 | # } 131 | # } 132 | # } 133 | 134 | # Any top-level field of the log message can be an Exception (so the error message can be customized): 135 | logger.warn message: 'An error occured', warning: error 136 | # => { 137 | # "timestamp": "2022-06-26T17:46:42.418+02:00", 138 | # "level": "WARN", 139 | # "logger": "Application", 140 | # "hostname": "MacbookPro.local", 141 | # "pid": 80422, 142 | # "message": "An error occured", 143 | # "warning": { 144 | # "class": "StandardError", 145 | # "message": "original error message", 146 | # "backtrace": [...] 147 | # } 148 | # } 149 | ``` 150 | 151 | #### Adding context information to log messages 152 | 153 | Logcraft provides two helper methods which can be used to add context information to log messages: 154 | 155 | * `within_log_context(context)`: Starts a new log context initialized with `context` and executes the provided block 156 | within that context. Once execution is finished, the log context is cleaned up and the previous context (if any) is 157 | reinstated. In practice, this means that every time we log something (within the block), the log message will include 158 | the information that's in the current context. This can be useful for storing request-specific information 159 | (request ID, user ID, ...) in the log context early on (for example in a middleware) and not have to worry about 160 | including it every time we want to log a message. 161 | 162 | Example: 163 | 164 | ```ruby 165 | within_log_context customer_id: 1234 do 166 | logger.info 'test 1' 167 | end 168 | logger.info 'test 2' 169 | 170 | #=> {...,"level":"INFO","customer_id":1234,"message":"test 1"} 171 | #=> {...,"level":"INFO","message":"test 2"} 172 | ``` 173 | 174 | * `add_to_log_context(context)`: Adds the provided `context` to the current log context but provides no mechanism for 175 | removing it later. Only use this method if you are sure that you're working within a specific log context and that it 176 | will be cleaned up later (e.g. by only using this method in a block passed to the previously explained 177 | `within_log_context` method). 178 | 179 | You can access these methods either in the global scope by calling them via `Logcraft.within_log_context` and 180 | `Logcraft.add_to_log_context` or locally by including the `Logcraft::LogContextHelper` module into your class/module. 181 | 182 | ### Rails logging 183 | 184 | Logcraft automatically configures Rails to provide you with structured logging capability via the `Rails.logger`. 185 | It also changes Rails's default logging configuration to be more concise and emit less "noise". 186 | 187 | In more detail: 188 | 189 | * The `Rails.logger` is set up to be a Logcraft logger with the name `Application`. 190 | * Rails's default logging of uncaught errors is modified and instead of spreading the error message across several 191 | lines, Logcraft logs every uncaught error in 1 line (per error), including the error's name and context (stack trace, 192 | etc.). 193 | * Most importantly, Rails's default request logging - which logs several lines per event during the processing of an 194 | action - is replaced by Logcraft's own access log middleware. The end result is an access log that 195 | * contains all relevant information (request ID, method, path, params, client IP, duration and 196 | response status code), and 197 | * has 1 log line per request, logged at the end of the request. 198 | 199 | Thanks to Mathias Meyer for writing [Lograge](https://github.com/roidrage/lograge), which inspired the solution. 200 | If Logcraft is not your cup of tea but you're looking for a way to tame Rails's logging then be sure to check out 201 | [Lograge](https://github.com/roidrage/lograge). 202 | 203 | ``` 204 | GET /welcome?subsession_id=34ea8596f9764f475f81158667bc2654 205 | 206 | With default Rails logging: 207 | 208 | Started GET "/welcome?subsession_id=34ea8596f9764f475f81158667bc2654" for 127.0.0.1 at 2022-06-26 18:07:08 +0200 209 | Processing by PagesController#welcome as HTML 210 | Parameters: {"subsession_id"=>"34ea8596f9764f475f81158667bc2654"} 211 | Rendering pages/welcome.html.haml within layouts/application 212 | Rendered pages/welcome.html.haml within layouts/application (5.5ms) 213 | Completed 200 OK in 31ms (Views: 27.3ms | ActiveRecord: 0.0ms) 214 | 215 | With Logcraft: 216 | {"timestamp":"2022-06-26T18:07:08.103+02:00","level":"INFO","logger":"AccessLog","hostname":"MacbookPro.local","pid":80908,"request_id":"9a43631b-284c-4677-9d08-9c1cc5c7d3a7","message":"GET /welcome?subsession_id=34ea8596f9764f475f81158667bc2654 - 200 (OK)","remote_ip":"127.0.0.1","method":"GET","path":"/welcome?subsession_id=34ea8596f9764f475f81158667bc2654","params":{"subsession_id":"34ea8596f9764f475f81158667bc2654","controller":"pages","action":"welcome"},"response_status_code":200,"duration":31,"duration_sec":0.031} 217 | 218 | Formatted for readability: 219 | { 220 | "timestamp": "2022-06-26T18:07:08.103+02:00", 221 | "level": "INFO", 222 | "logger": "AccessLog", 223 | "hostname": "MacbookPro.local", 224 | "pid": 80908, 225 | "request_id": "9a43631b-284c-4677-9d08-9c1cc5c7d3a7", 226 | "message": "GET /welcome?subsession_id=34ea8596f9764f475f81158667bc2654 - 200 (OK)", 227 | "remote_ip": "127.0.0.1", 228 | "method": "GET", 229 | "path": "/welcome?subsession_id=34ea8596f9764f475f81158667bc2654", 230 | "params": { 231 | "subsession_id": "34ea8596f9764f475f81158667bc2654", 232 | "controller": "pages", 233 | "action": "welcome" 234 | }, 235 | "response_status_code": 200, 236 | "duration": 31, 237 | "duration_sec": 0.031 238 | } 239 | ``` 240 | 241 | By default, Logcraft logs all request parameters as a hash (JSON object) under the `params` key. This is very convenient 242 | in a structured logging system and makes it easy to search for specific request parameter values e.g. in ElasticSearch 243 | (should you happen to store your logs there). Unfortunately, in some cases - such as when handling large forms - this 244 | can create quite a bit of noise and impact the searchability of your logs negatively or pose a security risk or data 245 | policy violation. You have the option to restrict the logging of certain parameters via configuration options (see the 246 | Configuration section). 247 | 248 | #### The log level 249 | 250 | The logger's log level is determined as follows (in order of precedence): 251 | 252 | * the log level set in the application's configuration (for Rails applications), 253 | * the `LOG_LEVEL` environment variable, or 254 | * `INFO` as the default log level if none of the above are set. 255 | 256 | The following log levels are available: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. 257 | 258 | #### JSON serialization 259 | 260 | Logcraft uses the [MultiJSON](https://github.com/intridea/multi_json) gem for serializing data to JSON, which in turn 261 | uses ActiveSupport's JSON encoder by default (unless you have some other JSON gem loaded in your project). 262 | However, ActiveSupport's JSON encoder has some quirks which you might not be aware of: 263 | 264 | ```ruby 265 | ActiveSupport::JSON.encode test: 'foo > bar' 266 | #=> "{\"test\":\"foo \\u003e bar\"}" 267 | 268 | {test: 'foo > bar'}.to_json 269 | #=> "{\"test\":\"foo \\u003e bar\"}" 270 | ``` 271 | 272 | I highly recommend using the [Oj](https://github.com/ohler55/oj) gem which - if present - will be automatically 273 | picked up by Logcraft, as it is significantly faster and will serialize your messages as you would expect. 274 | 275 | In a nutshell: 276 | 277 | ```ruby 278 | # With default ActiveSupport serialization 279 | Rails.logger.info 'foo > bar' 280 | #=> {...,"message":"foo \u003e bar"} 281 | 282 | # With Oj 283 | Rails.logger.info 'foo > bar' 284 | #=> {...,"message":"foo > bar"} 285 | ``` 286 | 287 | ## Configuration options 288 | 289 | ### Rails configuration 290 | 291 | Logcraft provides the following configuration options for Rails: 292 | 293 | | Option | Default value | Description | 294 | |-------------------------------------------------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| 295 | | logcraft.global_context | `{} \| lambda \| proc` | A global log context that will be included in every log message. Must be either a Hash or a lambda/proc returning a Hash. | 296 | | logcraft.layout_options.formatter | `lambda \| proc` | A custom formatter for the entire log event. Must return a single string (see examples for usage). | 297 | | logcraft.layout_options.level_formatter | `lambda \| proc` | A custom formatter for the log level specifically (see examples for usage). | 298 | | logcraft.access_log.logger_name | `'AccessLog'` | The name of the logger emitting access log messages. | 299 | | logcraft.access_log.exclude_paths | `[]` | A list of paths (array of strings or RegExps) not to include in the access log. | 300 | | logcraft.access_log.log_only_whitelisted_params | `false` | If `true`, the access log will only contain whitelisted parameters. | 301 | | logcraft.access_log.whitelisted_params | `[:controller, :action]` | The only parameters to be logged in the access log if whitelisting is enabled. | 302 | | logcraft.unhandled_errors.log_level | `:fatal` | The log level with which to log unhandled errors. Rails logs these with FATAL, by default. | 303 | | logcraft.unhandled_errors.log_errors_handled_by_rails | `true` | Whether or not to log unhandled errors which are actually handled by Rails (e.g. 404). For a detailed list, see `ActionDispatch::ExceptionWrapper.rescue_responses`. | 304 | 305 | Examples: 306 | 307 | ```ruby 308 | # Use these options in your Rails configuration files (e.g. config/application.rb or config/environments/*.rb) 309 | 310 | # Set up a global context you want to see in every log message 311 | config.logcraft.global_context = -> do 312 | { 313 | environment: ENV['RAILS_ENV'], 314 | timestamp_linux: Time.current.to_i # evaluated every time when emitting a log message 315 | } 316 | end 317 | 318 | # Set up a custom log formatter (e.g. output logs in YAML format in the development environment - config/environments/development.rb) 319 | config.logcraft.layout_options.formatter = ->(event) { YAML.dump event } 320 | # or just make the JSON more readable 321 | config.logcraft.layout_options.formatter = ->(event) { JSON.pretty_generate(event) + "\n----------------\n" } 322 | 323 | # Set up a custom log level formatter (e.g. Ougai-like numbers) 324 | config.logcraft.layout_options.level_formatter = ->(level_number) { (level_number + 2) * 10 } 325 | Rails.logger.error('Boom!') 326 | # => {...,"level":50,"message":"Boom!"} 327 | 328 | # Exclude healthcheck and monitoring URLs from your access log: 329 | config.logcraft.access_log.exclude_paths = ['/healthcheck', %r(/monitoring/.*)] 330 | 331 | # Make sure no sensitive data is logged by accident in the access log, so only log controller and action: 332 | config.logcraft.access_log.log_only_whitelisted_params = true 333 | ``` 334 | 335 | ### Non-Rails configuration 336 | 337 | The `global_context` and `layout_options` configuration options (see above) are available to non-Rails projects 338 | via Logcraft's initialization mechanism. You can also set the default log level this way. 339 | 340 | ```ruby 341 | Logcraft.initialize log_level: :info, global_context: {}, layout_options: {} 342 | ``` 343 | 344 | ## Integration with DataDog 345 | 346 | You can set up tracing with [DataDog](https://www.datadoghq.com/) by providing a global context to be included in 347 | every log message: 348 | 349 | ```ruby 350 | config.logcraft.global_context = -> do 351 | return {} unless Datadog::Tracing.enabled? 352 | 353 | correlation = Datadog::Tracing.correlation 354 | { 355 | dd: { 356 | trace_id: correlation.trace_id.to_s, 357 | span_id: correlation.span_id.to_s, 358 | env: correlation.env.to_s, 359 | service: correlation.service.to_s, 360 | version: correlation.version.to_s 361 | }, 362 | ddsource: 'ruby' 363 | } 364 | end 365 | ``` 366 | 367 | ## RSpec support 368 | 369 | Logcraft comes with built-in support for testing your logging activity using [RSpec](https://rspec.info/). 370 | To enable spec support for Logcraft, put this line in your `spec_helper.rb` or `rails_helper.rb`: 371 | 372 | ```ruby 373 | require 'logcraft/rspec' 374 | ``` 375 | 376 | What you get: 377 | 378 | * Helpers 379 | * `log_output` provides access to the complete log output (array of strings) in your specs 380 | * `log_output_is_expected` shorthand for writing expectations for the log output 381 | * Matchers 382 | * `include_log_message` matcher for expecting a certain message in the log output 383 | * `log` matcher for expecting an operation to log a certain message 384 | 385 | ```ruby 386 | # Check that the log output contains a certain message 387 | expect(log_output).to include_log_message message: 'Test message' 388 | log_output_is_expected.to include_log_message message: 'Test message' 389 | 390 | # Check that the message is not present in the logs before the operation but is present after it 391 | expect { operation }.to log message: 'Test message', 392 | user_id: 123456 393 | 394 | # RSpec's matchers can be used inside the expectation 395 | expect { get '/' }.to log message: 'GET / - 200 (OK)', 396 | request_id: match(/[\w-]+/), 397 | duration: be_within(100).of(100) 398 | 399 | # Expect a certain log level 400 | log_output_is_expected.to include_log_message(message: 'Test message').at_level(:info) 401 | expect { operation }.to log(message: 'Test message').at_level(:info) 402 | ``` 403 | 404 | ## Development 405 | 406 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. 407 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 408 | 409 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, 410 | update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag 411 | for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 412 | 413 | ## Contributing 414 | 415 | Bug reports and pull requests are welcome on GitHub at https://github.com/zormandi/logcraft. This project is intended 416 | to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the 417 | [code of conduct](https://github.com/zormandi/logcraft/blob/master/CODE_OF_CONDUCT.md). 418 | 419 | ## Disclaimer 420 | 421 | Logcraft is highly opinionated software and does in no way aim or claim to be useful for everyone. 422 | Use at your own discretion. 423 | 424 | ## License 425 | 426 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 427 | 428 | ## Code of Conduct 429 | 430 | Everyone interacting in the Logcraft project's codebases, issue trackers, chat rooms and mailing lists is expected 431 | to follow the [code of conduct](https://github.com/zormandi/logcraft/blob/master/CODE_OF_CONDUCT.md). 432 | --------------------------------------------------------------------------------