├── .travis.yml ├── lib ├── chalk-log │ ├── version.rb │ ├── errors.rb │ ├── logger.rb │ ├── utils.rb │ └── layout.rb └── chalk-log.rb ├── test ├── _lib │ ├── fake.rb │ └── fake │ │ └── event.rb ├── unit │ ├── _lib.rb │ └── chalk-log │ │ └── utils.rb ├── functional │ ├── _lib.rb │ ├── log.rb │ └── formatting.rb └── _lib.rb ├── .gitignore ├── Gemfile ├── History.txt ├── Rakefile ├── config.yaml ├── LICENSE.txt ├── chalk-log.gemspec └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1.0 6 | script: rake test 7 | -------------------------------------------------------------------------------- /lib/chalk-log/version.rb: -------------------------------------------------------------------------------- 1 | module Chalk 2 | module Log 3 | VERSION = '0.1.3' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/_lib/fake.rb: -------------------------------------------------------------------------------- 1 | module Critic::Fake 2 | Dir[File.expand_path('../fake/*.rb', __FILE__)].each do |file| 3 | require_relative(file) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/unit/_lib.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../_lib', __FILE__) 2 | 3 | module Critic::Unit 4 | module Stubs 5 | end 6 | 7 | class Test < Critic::Test 8 | include Stubs 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/functional/_lib.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../_lib', __FILE__) 2 | 3 | module Critic::Functional 4 | module Stubs 5 | end 6 | 7 | class Test < Critic::Test 8 | include Stubs 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .tddium* 19 | -------------------------------------------------------------------------------- /test/_lib/fake/event.rb: -------------------------------------------------------------------------------- 1 | class Critic::Fake::Event 2 | attr_reader :data, :time, :level 3 | 4 | def initialize(opts) 5 | @data = opts.fetch(:data) 6 | @time = opts.fetch(:time, Time.new(1979,4,9)) 7 | @level = opts.fetch(:level, 1) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # Execute bundler hook if present 2 | ['~/.', '/etc/'].any? do |file| 3 | File.lstat(path = File.expand_path(file + 'bundle-gemfile-hook')) rescue next 4 | eval(File.read(path), binding, path); break true 5 | end || source('https://rubygems.org/') 6 | 7 | gemspec 8 | gem 'pry' 9 | -------------------------------------------------------------------------------- /lib/chalk-log/errors.rb: -------------------------------------------------------------------------------- 1 | module Chalk::Log 2 | # Base error class 3 | class Error < StandardError; end 4 | # Thrown when you call a layout with the wrong arguments. (It gets 5 | # swallowed and printed by the fault handling in layout.rb, though.) 6 | class InvalidArguments < Error; end 7 | end 8 | -------------------------------------------------------------------------------- /test/_lib.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'minitest/autorun' 5 | require 'minitest/spec' 6 | require 'mocha/setup' 7 | 8 | module Critic 9 | require_relative '_lib/fake' 10 | 11 | class Test < ::MiniTest::Spec 12 | def setup 13 | # Put any stubs here that you want to apply globally 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | === 0.1.0 2014-05-25 2 | 3 | * Started being more strict with arguments passed. In particular, 4 | stopped accepting nil or boolean trailing arguments. 5 | * Switched to using Chalk::Config, eliminating Chalk::Log::Config. 6 | * You now disable Chalk::Log by setting either of `configatron.chalk.log.disabled || LSpace[:'chalk.log.disabled']`. `ENV["CHALK_NOLOG"]` and `LSpace[:logging_disabled] are now ignored. 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'bundler/setup' 3 | require 'chalk-rake/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | task :default do 7 | sh 'rake -T' 8 | end 9 | 10 | Rake::TestTask.new do |t| 11 | t.libs = ["lib"] 12 | # t.warning = true 13 | t.verbose = true 14 | t.test_files = FileList['test/**/*.rb'].reject do |file| 15 | file.end_with?('_lib.rb') || file.include?('/_lib/') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/unit/chalk-log/utils.rb: -------------------------------------------------------------------------------- 1 | require_relative '../_lib' 2 | require 'chalk-log' 3 | 4 | class Critic::Unit::Utils < Critic::Unit::Test 5 | describe '.explode_nested_hash' do 6 | it 'explodes nested keys' do 7 | hash = {foo: {bar: {baz: 'zom'}, zero: 'hello'}, hi: 'there'} 8 | exploded = Chalk::Log::Utils.explode_nested_hash(hash) 9 | assert_equal({"foo_bar_baz"=>"zom", "foo_zero"=>"hello", "hi"=>"there"}, 10 | exploded) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | chalk: 2 | log: 3 | # The default log level 4 | default_level: 'INFO' 5 | 6 | # Whether to enable tagging (with timestamp, PID, action_id). 7 | tagging: true 8 | 9 | # Whether to enable tagging with PID (can be subsumed by 10 | # `tagging: false`) 11 | pid: true 12 | 13 | # Whether to enable tagging with timestamp (can be subsumed by 14 | # `tagging: false`). This option is set automatically in 15 | # lib/chalk-log.rb. 16 | timestamp: null 17 | 18 | # Whether to show logs at all 19 | disabled: false 20 | 21 | # Whether to remove gem lines from backtraces 22 | compress_backtraces: false 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Stripe 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /chalk-log.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'chalk-log/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'chalk-log' 8 | gem.version = Chalk::Log::VERSION 9 | gem.authors = ['Stripe'] 10 | gem.email = ['oss@stripe.com'] 11 | gem.description = %q{Extends classes with a `log` method} 12 | gem.summary = %q{Chalk::Log makes any class loggable. It provides a logger that can be used for both structured and unstructured log.} 13 | gem.homepage = 'https://github.com/stripe/chalk-log' 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ['lib'] 19 | gem.required_ruby_version = '>= 1.9.3' 20 | gem.add_dependency 'chalk-config' 21 | gem.add_dependency 'logging' 22 | gem.add_dependency 'lspace' 23 | gem.add_development_dependency 'rake' 24 | gem.add_development_dependency 'minitest', '~> 3.2.0' 25 | gem.add_development_dependency 'mocha' 26 | gem.add_development_dependency 'chalk-rake' 27 | end 28 | -------------------------------------------------------------------------------- /lib/chalk-log/logger.rb: -------------------------------------------------------------------------------- 1 | # Thin wrapper over Logging::Logger. This is the per-class object 2 | # instantiated by the `log` method. 3 | class Chalk::Log::Logger 4 | attr_reader :backend 5 | 6 | # Initialization of the logger backend. It does the actual creation 7 | # of the various logger methods. Will be called automatically upon 8 | # your first `log` method call. 9 | def self.init 10 | Chalk::Log::LEVELS.each do |level| 11 | define_method(level) do |*data, &blk| 12 | return if logging_disabled? 13 | @backend.send(level, data, &blk) 14 | end 15 | end 16 | end 17 | 18 | # The level this logger is set to. 19 | def level 20 | @backend.level 21 | end 22 | 23 | # Set the maximum log level. 24 | # 25 | # @param level [Fixnum|String|Symbol] A valid Logging::Logger level, e.g. :debug, 0, 'DEBUG', etc. 26 | def level=(level) 27 | @backend.level = level 28 | end 29 | 30 | # Create a new logger, and auto-initialize everything. 31 | def initialize(name) 32 | # It's generally a bad pattern to auto-init, but we want 33 | # Chalk::Log to be usable anytime during the boot process, which 34 | # requires being a little bit less explicit than we usually like. 35 | Chalk::Log.init 36 | @backend = ::Logging::Logger.new(name) 37 | if level = configatron.chalk.log.default_level 38 | @backend.level = level 39 | end 40 | end 41 | 42 | # Check whether logging has been globally turned off, either through 43 | # configatron or LSpace. 44 | def logging_disabled? 45 | configatron.chalk.log.disabled || LSpace[:'chalk.log.disabled'] 46 | end 47 | 48 | def with_contextual_info(contextual_info={}, &blk) 49 | unless blk 50 | raise ArgumentError.new("Must pass a block to #{__method__}") 51 | end 52 | unless contextual_info.is_a?(Hash) 53 | raise TypeError.new( 54 | "contextual_info must be a Hash, but got #{contextual_info.class}" 55 | ) 56 | end 57 | existing_context = LSpace[:'chalk.log.contextual_info'] || {} 58 | LSpace.with( 59 | :'chalk.log.contextual_info' => contextual_info.merge(existing_context), 60 | &blk 61 | ) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/chalk-log/utils.rb: -------------------------------------------------------------------------------- 1 | module Chalk::Log::Utils 2 | # Nicely formats a backtrace: 3 | # 4 | # ```ruby 5 | # format_backtrace(['line1', 'line2']) 6 | # #=> line1 7 | # line2 8 | # ``` 9 | # 10 | # (Used internally when `Chalk::Log` is formatting exceptions.) 11 | # 12 | # TODO: add autotruncating of backtraces. 13 | def self.format_backtrace(backtrace) 14 | if configatron.chalk.log.compress_backtraces 15 | backtrace = compress_backtrace(backtrace) 16 | backtrace << '(Disable backtrace compression by setting configatron.chalk.log.compress_backtraces = false.)' 17 | end 18 | 19 | " " + backtrace.join("\n ") 20 | end 21 | 22 | # Explodes a nested hash to just have top-level keys. This is 23 | # generally useful if you have something that knows how to parse 24 | # kv-pairs. 25 | # 26 | # ```ruby 27 | # explode_nested_hash(foo: {bar: 'baz', bat: 'zom'}) 28 | # #=> {'foo_bar' => 'baz', 'foo_bat' => 'zom'} 29 | # ``` 30 | def self.explode_nested_hash(hash, prefix=nil) 31 | exploded = {} 32 | 33 | hash.each do |key, value| 34 | new_prefix = prefix ? "#{prefix}_#{key}" : key.to_s 35 | 36 | if value.is_a?(Hash) 37 | exploded.merge!(self.explode_nested_hash(value, new_prefix)) 38 | else 39 | exploded[new_prefix] = value 40 | end 41 | end 42 | 43 | exploded 44 | end 45 | 46 | # Compresses a backtrace, omitting gem lines (unless they appear 47 | # before any application lines). 48 | def self.compress_backtrace(backtrace) 49 | compressed = [] 50 | gemdir = Gem.dir 51 | 52 | hit_application = false 53 | # This isn't currently read by anything, but we could easily use 54 | # it to limit the number of leading gem lines. 55 | leading_lines = 0 56 | gemlines = 0 57 | backtrace.each do |line| 58 | if line.start_with?(gemdir) 59 | # If we're in a gem, always increment the counter. Record the 60 | # first three lines if we haven't seen any application lines 61 | # yet. 62 | if !hit_application 63 | compressed << line 64 | leading_lines += 1 65 | else 66 | gemlines += 1 67 | end 68 | elsif gemlines > 0 69 | # If we were in a gem and now are not, record the number of 70 | # lines skipped. 71 | compressed << "<#{gemlines} #{gemlines == 1 ? 'line' : 'lines'} omitted>" 72 | compressed << line 73 | hit_application = true 74 | gemlines = 0 75 | else 76 | # If we're in the application, always record the line. 77 | compressed << line 78 | hit_application = true 79 | end 80 | end 81 | 82 | compressed 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chalk::Log 2 | 3 | `Chalk::Log` adds a logger object to any class, which can be used for 4 | unstructured or semi-structured logging. Use it as follows: 5 | 6 | ```ruby 7 | class A 8 | include Chalk::Log 9 | end 10 | 11 | A.log.info('hello', key: 'value') 12 | #=> [2013-06-18 22:18:28.314756] [64682] hello: key="value" 13 | ``` 14 | 15 | The output is both human-digestable and easily parsed by log indexing 16 | systems such as [Splunk](http://www.splunk.com/) or 17 | [Logstash](http://logstash.net/). 18 | 19 | It can also pretty-print exceptions for you: 20 | 21 | ```ruby 22 | module A; include Chalk::Log; end 23 | begin; raise "hi"; rescue => e; end 24 | A.log.error('Something went wrong', e) 25 | #=> Something went wrong: hi (RuntimeError) 26 | # (irb):8:in `irb_binding' 27 | # /Users/gdb/.rbenv/versions/1.9.3-p362/lib/ruby/1.9.1/irb/workspace.rb:80:in `eval# /Users/gdb/.rbenv/versions/1.9.3-p362/lib/ruby/1.9.1/irb/workspace.rb:80:in `evaluate' 28 | # /Users/gdb/.rbenv/versions/1.9.3-p362/lib/ruby/1.9.1/irb/context.rb:254:in `evaluate' 29 | # /Users/gdb/.rbenv/versions/1.9.3-p362/lib/ruby/1.9.1/irb.rb:159:in `block (2 levels) in eval_input' 30 | # [...] 31 | ``` 32 | 33 | The log methods accept a message and/or an exception and/or an info 34 | hash (if multiple are passed, they must be provided in that 35 | order). The log methods will never throw an exception, but will 36 | instead print an log message indicating they had a fault. 37 | 38 | ## Overview 39 | 40 | Including `Chalk::Log` creates a `log` method as both a class an 41 | instance method, which returns a class-specific logger. 42 | 43 | By default, it tags loglines with auxiliary information: a 44 | microsecond-granularity timestamp, the PID, and an action_id (which 45 | should tie together all lines for a single logical action in your 46 | system, such as a web request). 47 | 48 | You can turn off tagging, or just turn off timestamping, through 49 | appropriate configatron settings (see [config.yaml](/config.yaml)). 50 | 51 | There are also two `LSpace` dynamic settings available: 52 | 53 | - `LSpace[:action_id]`: Set the action_id dynamically for this action. (This is used automatically by things like `Chalk::Web` which have a well-defined action.) 54 | - `LSpace[:'chalk.log.disabled']`: Disable all logging. 55 | 56 | You can use `LSpace` settings as follows: 57 | 58 | ```ruby 59 | class A; include Chalk::Log; end 60 | foo = A.new 61 | 62 | LSpace.with(action_id: 'request-123') do 63 | foo.log.info('Test') 64 | #=> [2014-05-26 01:12:28.485822] [47325|request-123] Test 65 | end 66 | ``` 67 | 68 | ## Log methods 69 | 70 | `Chalk::Log` provides five log levels: 71 | 72 | debug, info, warn, error, fatal 73 | 74 | ## Inheritance 75 | 76 | `Chalk::Log` makes a heroic effort to ensure that inclusion chaining 77 | works, so you can do things like: 78 | 79 | ```ruby 80 | module A 81 | include Chalk::Log 82 | end 83 | 84 | module B 85 | include A 86 | end 87 | 88 | class C 89 | include B 90 | end 91 | ``` 92 | 93 | and still have `C.log` and `C.new.log` work. (Normally you'd expect 94 | for the class-method version to be left behind.) 95 | 96 | ## Best practices 97 | 98 | - You should never use string interpolation in your log 99 | message. Instead, always use the structured logging keys. So for 100 | example: 101 | 102 | ```ruby 103 | # Bad 104 | log.info("Just printed #{lines.length} lines") 105 | # Good 106 | log.info("Printed", lines: lines.length) 107 | ``` 108 | 109 | - Don't end messages with a punctuation -- `Chalk::Log` will 110 | automatically add a colon if an info hash is provided; if not, it's 111 | fine to just end without trailing punctutaion. Case in point 112 | 113 | - In most projects, you'll find most of your classes start including 114 | `Chalk::Log` -- it's pretty cheap to add it, and it's quite 115 | lightweight to use. (In contrast, there's no good way to autoinclude 116 | it, since that would likely break many classes which aren't 117 | expecting a magical `log` method to appear.) 118 | 119 | ## Limitations 120 | 121 | `Chalk::Log` is not very configurable. Our usage at Stripe tends to be 122 | fairly opinionated, so there hasn't been much demand for increased 123 | configurability. We would be open to making it less rigid, 124 | however. (In any case, under the hood `Chalk::Log` is just using the 125 | `logging` gem, so if the need arises it wouldn't be hard to acquire 126 | the full flexibility of `logging`.) 127 | 128 | # Contributors 129 | 130 | - Greg Brockman 131 | - Andreas Fuchs 132 | - Andy Brody 133 | - Anurag Goel 134 | - Evan Broder 135 | - Nelson Elhage 136 | - Brian Krausz 137 | - Christian Anderson 138 | - Jeff Balogh 139 | - Jeremy Hoon 140 | - Julia Evans 141 | - Russell Davis 142 | - Steven Noble 143 | -------------------------------------------------------------------------------- /lib/chalk-log.rb: -------------------------------------------------------------------------------- 1 | require 'logging' 2 | require 'lspace' 3 | require 'set' 4 | 5 | require 'chalk-config' 6 | require 'chalk-log/version' 7 | 8 | # Include `Chalk::Log` in a class or module to make that class (and 9 | # all subclasses / includees / extendees) loggable. This creates a 10 | # class and instance `log` method which you can call from within your 11 | # loggable class. 12 | # 13 | # Loggers are per-class and can be manipulated as you'd expect: 14 | # 15 | # ```ruby 16 | # class A 17 | # include Chalk::Log 18 | # 19 | # log.level = 'DEBUG' 20 | # log.debug('Now you see me!') 21 | # log.level = 'INFO' 22 | # log.debug('Now you do not!') 23 | # end 24 | # ``` 25 | # 26 | # You shouldn't need to directly access any of the methods on 27 | # `Chalk::Log` itself. 28 | module Chalk::Log 29 | require 'chalk-log/errors' 30 | require 'chalk-log/logger' 31 | require 'chalk-log/layout' 32 | require 'chalk-log/utils' 33 | 34 | # The set of available log methods. (Changing these is not currently 35 | # a supported interface, though if the need arises it'd be easy to 36 | # add.) 37 | LEVELS = [:debug, :info, :warn, :error, :fatal].freeze 38 | 39 | @included = Set.new 40 | 41 | # Method which goes through heroic efforts to ensure that the whole 42 | # inclusion hierarchy has their `log` accessors. 43 | def self.included(other) 44 | if other == Object 45 | raise "You have attempted to `include Chalk::Log` onto Object. This is disallowed, since otherwise it might shadow any `log` method on classes that weren't expecting it (including, for example, `configatron.chalk.log`)." 46 | end 47 | 48 | # Already been through this ordeal; no need to repeat it. (There 49 | # shouldn't be any semantic harm to doing so, just a potential 50 | # performance hit.) 51 | return if @included.include?(other) 52 | @included << other 53 | 54 | # Make sure to define the .log class method 55 | other.extend(ClassMethods) 56 | 57 | # If it's a module, we need to make sure both inclusion/extension 58 | # result in virally carrying Chalk::Log inclusion downstream. 59 | if other.instance_of?(Module) 60 | other.class_eval do 61 | included = method(:included) 62 | extended = method(:extended) 63 | 64 | define_singleton_method(:included) do |other| 65 | other.send(:include, Chalk::Log) 66 | included.call(other) 67 | end 68 | 69 | define_singleton_method(:extended) do |other| 70 | other.send(:include, Chalk::Log) 71 | extended.call(other) 72 | end 73 | end 74 | end 75 | end 76 | 77 | # Public-facing initialization method for all `Chalk::Log` 78 | # state. Unlike most other Chalk initializers, this will be 79 | # automatically run (invoked on first logger instantiation). It is 80 | # idempotent. 81 | def self.init 82 | return if @init 83 | @init = true 84 | 85 | # Load relevant configatron stuff 86 | Chalk::Config.register(File.expand_path('../../config.yaml', __FILE__), 87 | raw: true) 88 | 89 | # The assumption is you'll pipe your logs through something like 90 | # [Unilog](https://github.com/stripe/unilog) in production, which 91 | # does its own timestamping. 92 | Chalk::Config.register_raw(chalk: {log: {timestamp: STDERR.tty?}}) 93 | 94 | ::Logging.init(*LEVELS) 95 | ::Logging.logger.root.add_appenders( 96 | ::Logging.appenders.stderr(layout: layout) 97 | ) 98 | 99 | Chalk::Log::Logger.init 100 | end 101 | 102 | # The default layout to use for the root `Logging::Logger`. 103 | def self.layout 104 | @layout ||= Chalk::Log::Layout.new 105 | end 106 | 107 | # Home of the backend `log` method people call; included *and* 108 | # extended everywhere that includes Chalk::Log. 109 | module ClassMethods 110 | # The backend `log` method exposed to everyone. (In practice, the 111 | # method people call directly is one wrapper above this.) 112 | # 113 | # Sets a `@__chalk_log` variable to hold the logger instance. 114 | def log 115 | @__chalk_log ||= Chalk::Log::Logger.new(self.name) 116 | end 117 | end 118 | 119 | # Make the `log` method inheritable. 120 | include ClassMethods 121 | 122 | # The technique here is a bit tricky. The same `log` implementation 123 | # defined on any class needs to be callable by either an instance or 124 | # class. (See the "correctly make the end class loggable when it has 125 | # already included loggable" test for why. In particular, someone 126 | # may have already included me, and then clobbered the class 127 | # implementations by extending me.) Hence we do this "defer to 128 | # class, unless I am a class" logic. 129 | log = instance_method(:log) 130 | define_method(:log) do 131 | if self.kind_of?(Class) 132 | log.bind(self).call 133 | else 134 | self.class.log 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/chalk-log/layout.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'set' 3 | require 'time' 4 | 5 | # The layout backend for the Logging::Logger. 6 | # 7 | # Accepts a message and/or an exception and/or an info 8 | # hash (if multiple are passed, they must be provided in that 9 | # order) 10 | class Chalk::Log::Layout < ::Logging::Layout 11 | # Formats an event, and makes a heroic effort to tell you if 12 | # something went wrong. (Logging will otherwise silently swallow any 13 | # exceptions that get thrown.) 14 | # 15 | # @param event provided by the Logging::Logger 16 | def format(event) 17 | begin 18 | begin 19 | begin 20 | do_format(event) 21 | rescue StandardError => e 22 | # Single fault! 23 | error!('[Chalk::Log fault: Could not format message] ', e) 24 | end 25 | rescue StandardError => e 26 | # Double fault! 27 | "[Chalk::Log fault: Double fault while formatting message. This means we couldn't even report the error we got while formatting.] #{e.message}\n" 28 | end 29 | rescue StandardError => e 30 | # Triple fault! 31 | "[Chalk::Log fault: Triple fault while formatting message. This means we couldn't even report the error we got while reporting the original error.]\n" 32 | end 33 | end 34 | 35 | # Formats a hash for logging. This is provided for (rare) use outside of log 36 | # methods; you can pass a hash directly to log methods and this formatting 37 | # will automatically be applied. 38 | # 39 | # @param hash [Hash] The hash to be formatted 40 | def format_hash(hash) 41 | hash.map {|k, v| display(k, v)}.join(' ') 42 | end 43 | 44 | private 45 | 46 | def do_format(event) 47 | data = event.data 48 | time = event.time 49 | level = event.level 50 | 51 | # Data provided by blocks may not be arrays yet 52 | data = [data] unless data.kind_of?(Array) 53 | info = data.pop if data.last.kind_of?(Hash) 54 | error = data.pop if data.last.kind_of?(Exception) 55 | message = data.pop if data.last.kind_of?(String) 56 | 57 | if data.length > 0 58 | raise Chalk::Log::InvalidArguments.new("Invalid leftover arguments: #{data.inspect}") 59 | end 60 | 61 | pid = Process.pid 62 | 63 | pretty_print( 64 | time: timestamp_prefix(time), 65 | level: Chalk::Log::LEVELS[level], 66 | span: span.to_s, 67 | message: message, 68 | error: error, 69 | info: (info && info.merge(contextual_info || {})) || contextual_info, 70 | pid: pid 71 | ) 72 | end 73 | 74 | def pretty_print(spec) 75 | message = build_message(spec[:message], spec[:info], spec[:error]) 76 | message = tag(message, spec[:time], spec[:pid], spec[:span]) 77 | message 78 | end 79 | 80 | def build_message(message, info, error) 81 | # Make sure we're not mutating the message that was passed in 82 | if message 83 | message = message.dup 84 | end 85 | 86 | if message && (info || error) 87 | message << ':' 88 | end 89 | 90 | if info 91 | message << ' ' if message 92 | message ||= '' 93 | info!(message, info) 94 | end 95 | 96 | if error 97 | message << ' ' if message 98 | message ||= '' 99 | error!(message, error) 100 | end 101 | 102 | message ||= '' 103 | message << "\n" 104 | message 105 | end 106 | 107 | # Displaying info hash 108 | 109 | def info!(message, info) 110 | message << format_hash(info.merge(contextual_info || {})) 111 | end 112 | 113 | def display(key, value) 114 | begin 115 | value = json(value) 116 | rescue StandardError 117 | value = "#{value.inspect} [JSON-FAILED]" 118 | end 119 | 120 | # Non-numeric simple strings don't need quotes. 121 | if value =~ /\A"\w*[A-Za-z]\w*"\z/ && 122 | !['"true"', '"false"', '"null"'].include?(value) 123 | value = value[1...-1] 124 | end 125 | 126 | "#{key}=#{value}" 127 | end 128 | 129 | # Displaying backtraces 130 | 131 | def error!(message, error) 132 | backtrace = error.backtrace || ['[no backtrace]'] 133 | message << display(:error_class, error.class.to_s) << " " 134 | message << display(:error, error.to_s) 135 | message << "\n" 136 | message << Chalk::Log::Utils.format_backtrace(backtrace) 137 | message << "\n" 138 | message 139 | end 140 | 141 | def json(value) 142 | # Use an Array (and trim later) because Ruby's JSON generator 143 | # requires an array or object. 144 | wrapped = [value] 145 | 146 | # We may alias the raw JSON generation method. We don't care about 147 | # emiting raw HTML tags heres, so no need to use the safe 148 | # generation method. 149 | if JSON.respond_to?(:unsafe_generate) 150 | dumped = JSON.unsafe_generate(wrapped) 151 | else 152 | dumped = JSON.generate(wrapped) 153 | end 154 | 155 | res = dumped[1...-1] # strip off the brackets we added while array-ifying 156 | 157 | # Bug 6566 in ruby 2.0 (but not 2.1) allows generate() to return an invalid 158 | # string when given invalid unicode input. Manually check for it. 159 | unless res.valid_encoding? 160 | raise ArgumentError.new("invalid byte sequence in UTF-8") 161 | end 162 | 163 | res 164 | end 165 | 166 | def contextual_info 167 | LSpace[:'chalk.log.contextual_info'] 168 | end 169 | 170 | def span 171 | LSpace[:span] || LSpace[:action_id] 172 | end 173 | 174 | def tag(message, time, pid, span) 175 | return message unless configatron.chalk.log.tagging 176 | 177 | metadata = [] 178 | metadata << pid if configatron.chalk.log.pid 179 | metadata << span if span.length > 0 180 | prefix = "[#{metadata.join('|')}] " if metadata.length > 0 181 | 182 | if configatron.chalk.log.timestamp 183 | prefix = "[#{time}] #{prefix}" 184 | end 185 | 186 | out = '' 187 | message.split("\n").each do |line| 188 | out << prefix << line << "\n" 189 | end 190 | 191 | out 192 | end 193 | 194 | def timestamp_prefix(now) 195 | now_fmt = now.strftime("%Y-%m-%d %H:%M:%S") 196 | ms_fmt = sprintf("%06d", now.usec) 197 | "#{now_fmt}.#{ms_fmt}" 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /test/functional/log.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../_lib', __FILE__) 2 | 3 | require 'chalk-log' 4 | 5 | module Critic::Functional 6 | class LogTest < Test 7 | def self.add_appender 8 | unless @appender 9 | @appender = ::Logging.appenders.string_io( 10 | 'test-stringio', 11 | layout: Chalk::Log.layout 12 | ) 13 | ::Logging.logger.root.add_appenders(@appender) 14 | end 15 | @appender 16 | end 17 | 18 | before do 19 | Chalk::Log.init 20 | end 21 | 22 | describe 'when a class has included Log' do 23 | it 'instances are loggable' do 24 | class MyClass 25 | include Chalk::Log 26 | end 27 | 28 | MyClass.new.log.info("Hi!") 29 | end 30 | 31 | it 'the class is loggable' do 32 | class YourClass 33 | include Chalk::Log 34 | end 35 | 36 | YourClass.log.info("Hi!") 37 | end 38 | end 39 | 40 | describe 'including a loggable module into another' do 41 | describe 'the inclusions are straightline' do 42 | it 'make the includee loggable' do 43 | module LogTestA 44 | include Chalk::Log 45 | end 46 | 47 | module LogTestB 48 | include LogTestA 49 | end 50 | 51 | assert(LogTestB < Chalk::Log) 52 | assert(LogTestB.respond_to?(:log)) 53 | end 54 | 55 | it 'preserves any custom include logic prior to Log inclusion' do 56 | module CustomLogTestA 57 | def self.dict=(dict) 58 | @dict = dict 59 | end 60 | 61 | def self.dict 62 | @dict 63 | end 64 | 65 | def self.included(other) 66 | dict['included'] = true 67 | end 68 | 69 | include Chalk::Log 70 | end 71 | 72 | dict = {} 73 | CustomLogTestA.dict = dict 74 | 75 | module CustomLogTestB 76 | include CustomLogTestA 77 | end 78 | 79 | assert(CustomLogTestB < Chalk::Log) 80 | assert(CustomLogTestB.respond_to?(:log)) 81 | assert_equal(true, dict['included']) 82 | end 83 | 84 | # TODO: it'd be nice if this weren't true, but I'm not sure 85 | # how to get a hook when a method is overriden. 86 | it 'custom include logic after Log inclusion clobbers the default include logic' do 87 | module CustomLogTestC 88 | def self.dict=(dict) 89 | @dict = dict 90 | end 91 | 92 | def self.dict 93 | @dict 94 | end 95 | 96 | include Chalk::Log 97 | 98 | def self.included(other) 99 | dict['included'] = true 100 | end 101 | end 102 | 103 | dict = {} 104 | CustomLogTestC.dict = dict 105 | 106 | module CustomLogTestD 107 | include CustomLogTestC 108 | end 109 | 110 | assert(CustomLogTestD < Chalk::Log) 111 | assert(!CustomLogTestD.respond_to?(:log)) 112 | assert_equal(true, dict['included']) 113 | end 114 | end 115 | end 116 | 117 | describe 'extending a Log module into another' do 118 | describe 'the inclusions are straightline' do 119 | it 'make the extendee loggable' do 120 | module ExtendLogTestA 121 | include Chalk::Log 122 | end 123 | 124 | module ExtendLogTestB 125 | extend ExtendLogTestA 126 | end 127 | 128 | assert(ExtendLogTestB < Chalk::Log) 129 | assert(ExtendLogTestB.respond_to?(:log)) 130 | end 131 | end 132 | end 133 | 134 | describe 'when a class is loggable' do 135 | class MyLog 136 | include Chalk::Log 137 | end 138 | 139 | it 'log.warn works' do 140 | msg = 'msg' 141 | # For some reason this isn't working: 142 | MyLog.log.backend.expects(:warn).once 143 | MyLog.log.warn(msg) 144 | end 145 | 146 | it 'log.info works' do 147 | msg = 'msg' 148 | MyLog.log.backend.expects(:info).once 149 | MyLog.log.info(msg) 150 | end 151 | 152 | it 'accepts blocks' do 153 | class LogTestE 154 | include Chalk::Log 155 | end 156 | LogTestE.log.level = "INFO" 157 | 158 | LogTestE.log.debug { assert(false, "DEBUG block called when at INFO level") } 159 | called = false 160 | LogTestE.log.info { called = true; "" } 161 | assert(called, "INFO block not called at INFO level") 162 | end 163 | end 164 | 165 | class TestLogInstanceMethods < Test 166 | include Chalk::Log 167 | 168 | before do 169 | TestLogInstanceMethods.log.level = 'INFO' 170 | Chalk::Log.init 171 | @appender = LogTest.add_appender 172 | end 173 | 174 | def assert_logged(expected_string, *args) 175 | assert_includes(@appender.sio.string, expected_string, *args) 176 | end 177 | 178 | it 'accepts blocks on instance methods' do 179 | called = false 180 | log.debug { assert(false, "DEBUG block called at INFO") } 181 | log.info { called = true; "" } 182 | assert(called, "INFO block not called at INFO level") 183 | end 184 | 185 | describe 'when contextual info is set' do 186 | 187 | it 'adds contextual information to `.info` log lines' do 188 | log.with_contextual_info(key: 'value') {log.info("message")} 189 | assert_logged("key=value") 190 | end 191 | 192 | it 'nests contexts' do 193 | log.with_contextual_info(top_key: "top_value") do 194 | log.info("top message") 195 | log.with_contextual_info(inner_key: "inner_value") do 196 | log.info("inner message") 197 | end 198 | end 199 | %w{top_key=top_value inner_key=inner_value}.each do |pair| 200 | assert_logged(pair) 201 | end 202 | end 203 | 204 | it 'requires a block' do 205 | exn = assert_raises(ArgumentError) do 206 | log.with_contextual_info(i_am_not: "passing a block") 207 | end 208 | assert_includes(exn.message, "Must pass a block") 209 | end 210 | 211 | it 'requires its argument must be a hash' do 212 | exn = assert_raises(TypeError) do 213 | log.with_contextual_info('not a hash') {} 214 | end 215 | assert_includes(exn.message, 'must be a Hash') 216 | end 217 | end 218 | end 219 | 220 | describe 'when chaining includes and extends' do 221 | it 'correctly make the end class loggable' do 222 | module Base1 223 | include Chalk::Log 224 | end 225 | 226 | class Child1 227 | extend Base1 228 | end 229 | 230 | Child1.log.info("Hello!") 231 | assert(true) 232 | end 233 | 234 | it 'correctly make the end class loggable when chaining an include and extend' do 235 | module Base2 236 | include Chalk::Log 237 | end 238 | 239 | module Middle2 240 | extend Base2 241 | end 242 | 243 | class Child2 244 | include Middle2 245 | end 246 | 247 | Child2.log.info("Hello!") 248 | assert(true) 249 | end 250 | 251 | it 'correctly make the end class loggable when chaining an extend and an extend' do 252 | module Base3 253 | include Chalk::Log 254 | end 255 | 256 | module Middle3 257 | extend Base3 258 | end 259 | 260 | class Child3 261 | extend Middle3 262 | end 263 | 264 | Child3.log.info("Hello!") 265 | assert(true) 266 | end 267 | 268 | it 'correctly make the end class loggable when it has already included loggable' do 269 | module Base4 270 | include Chalk::Log 271 | end 272 | 273 | module Middle4 274 | extend Base4 275 | end 276 | 277 | class Child4 278 | include Chalk::Log 279 | extend Middle4 280 | end 281 | 282 | Child4.log.info("Hello!") 283 | assert(true) 284 | end 285 | end 286 | 287 | it 'correctly makes a module loggable' do 288 | module Base5 289 | include Chalk::Log 290 | end 291 | 292 | Base5.log.info("Hello!") 293 | assert(true) 294 | end 295 | end 296 | end 297 | -------------------------------------------------------------------------------- /test/functional/formatting.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../_lib', __FILE__) 2 | 3 | require 'chalk-log' 4 | 5 | module Critic::Functional 6 | class LogTest < Test 7 | def enable_timestamp 8 | configatron.unlock! do 9 | configatron.chalk.log.timestamp = true 10 | end 11 | end 12 | 13 | def disable_timestamp 14 | configatron.unlock! do 15 | configatron.chalk.log.timestamp = false 16 | end 17 | end 18 | 19 | def disable_pid 20 | configatron.unlock! do 21 | configatron.chalk.log.pid = false 22 | end 23 | end 24 | 25 | def disable_tagging 26 | configatron.unlock! do 27 | configatron.chalk.log.tagging = false 28 | end 29 | end 30 | 31 | before do 32 | Chalk::Log.init 33 | Process.stubs(:pid).returns(9973) 34 | configatron.temp_start 35 | disable_timestamp 36 | end 37 | 38 | after do 39 | configatron.temp_end 40 | end 41 | 42 | class MyClass 43 | include Chalk::Log 44 | end 45 | 46 | describe 'when called without arguments' do 47 | it 'does not loop infinitely' do 48 | MyClass.log.info 49 | end 50 | end 51 | 52 | describe 'when called with a message' do 53 | it 'does not mutate the input' do 54 | canary = "'hello, world!'" 55 | baseline = canary.dup 56 | MyClass.log.info(canary) 57 | assert_equal(baseline, canary) 58 | end 59 | end 60 | 61 | describe 'layout' do 62 | before do 63 | @layout = MyClass::Layout.new 64 | end 65 | 66 | def layout(opts) 67 | event = Critic::Fake::Event.new(opts) 68 | formatted = @layout.format(event) 69 | 70 | # Everything should end with a newline, but they're annoying 71 | # to have to test elsewhere, so strip it away. 72 | assert_equal("\n", formatted[-1], "Layout did not end with a newline: #{formatted.inspect}") 73 | formatted.chomp 74 | end 75 | 76 | it 'log entry from info' do 77 | rendered = layout(data: ["A Message", {:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 78 | assert_equal('[9973] A Message: key1=ValueOne key2=["An","Array"]', rendered) 79 | end 80 | 81 | it 'logs the action_id correctly' do 82 | LSpace.with(action_id: 'action') do 83 | rendered = layout(data: ["A Message", {:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 84 | assert_equal('[9973|action] A Message: key1=ValueOne key2=["An","Array"]', rendered) 85 | end 86 | end 87 | 88 | it 'logs timestamp correctly' do 89 | enable_timestamp 90 | LSpace.with(action_id: 'action') do 91 | rendered = layout(data: ["A Message", {:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 92 | assert_equal('[1979-04-09 00:00:00.000000] [9973|action] A Message: key1=ValueOne key2=["An","Array"]', rendered) 93 | end 94 | end 95 | 96 | it 'logs without pid correctly' do 97 | disable_pid 98 | LSpace.with(action_id: 'action') do 99 | rendered = layout(data: ["A Message", {:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 100 | assert_equal('[action] A Message: key1=ValueOne key2=["An","Array"]', rendered) 101 | end 102 | end 103 | 104 | it 'log from info hash without a message' do 105 | rendered = layout(data: [{:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 106 | assert_equal('[9973] key1=ValueOne key2=["An","Array"]', rendered) 107 | end 108 | 109 | it 'renders [no backtrace] as appropriate' do 110 | rendered = layout(data: ["Another Message", StandardError.new('msg')]) 111 | assert_equal("[9973] Another Message: error_class=StandardError error=msg\n[9973] [no backtrace]", rendered) 112 | end 113 | 114 | it 'renders when given error and info hash' do 115 | rendered = layout(data: ["Another Message", StandardError.new('msg'), {:key1 => "ValueOne", :key2 => ["An", "Array"]}]) 116 | assert_equal(%Q{[9973] Another Message: key1=ValueOne key2=["An","Array"] error_class=StandardError error=msg\n[9973] [no backtrace]}, rendered) 117 | end 118 | 119 | it 'renders an error with a backtrace' do 120 | error = StandardError.new('msg') 121 | backtrace = ["a fake", "backtrace"] 122 | error.set_backtrace(backtrace) 123 | 124 | rendered = layout(data: ["Yet Another Message", error]) 125 | assert_equal("[9973] Yet Another Message: error_class=StandardError error=msg\n[9973] a fake\n[9973] backtrace", rendered) 126 | end 127 | 128 | it 'renders an error passed alone' do 129 | error = StandardError.new('msg') 130 | backtrace = ["a fake", "backtrace"] 131 | error.set_backtrace(backtrace) 132 | 133 | rendered = layout(data: [error]) 134 | assert_equal("[9973] error_class=StandardError error=msg\n[9973] a fake\n[9973] backtrace", rendered) 135 | end 136 | 137 | it 'handles bad unicode' do 138 | rendered = layout(data: [{:key1 => "ValueOne", :key2 => "\xC3"}]) 139 | assert_equal("[9973] key1=ValueOne key2=\"\\xC3\" [JSON-FAILED]", rendered) 140 | end 141 | 142 | it 'allows disabling tagging' do 143 | enable_timestamp 144 | disable_tagging 145 | 146 | LSpace.with(action_id: 'action') do 147 | rendered = layout(data: [{:key1 => "ValueOne", :key2 => "Value Two"}]) 148 | assert_equal(%Q{key1=ValueOne key2="Value Two"}, rendered) 149 | end 150 | end 151 | 152 | it 'logs spans correctly' do 153 | enable_timestamp 154 | TestSpan = Struct.new(:action_id, :span_id, :parent_id) do 155 | def to_s 156 | sprintf("%s %s>%s", 157 | action_id, 158 | parent_id.to_s(16).rjust(16,'0'), 159 | span_id.to_s(16).rjust(16,'0')) 160 | end 161 | end 162 | LSpace.with(span: TestSpan.new("action", 0, 0)) do 163 | rendered = layout(data: ["llamas"]) 164 | assert_equal('[1979-04-09 00:00:00.000000] [9973|action 0000000000000000>0000000000000000] llamas', rendered) 165 | end 166 | LSpace.with(span: TestSpan.new("action", 2, 0)) do 167 | rendered = layout(data: ["llamas"]) 168 | assert_equal('[1979-04-09 00:00:00.000000] [9973|action 0000000000000000>0000000000000002] llamas', rendered) 169 | end 170 | LSpace.with(span: TestSpan.new("action", 0, 123)) do 171 | rendered = layout(data: ["llamas"]) 172 | assert_equal('[1979-04-09 00:00:00.000000] [9973|action 000000000000007b>0000000000000000] llamas', rendered) 173 | end 174 | LSpace.with(span: TestSpan.new("action", 2, 123)) do 175 | rendered = layout(data: ["llamas"]) 176 | assert_equal('[1979-04-09 00:00:00.000000] [9973|action 000000000000007b>0000000000000002] llamas', rendered) 177 | end 178 | end 179 | 180 | describe 'faults' do 181 | it 'shows an appropriate error if the invalid arguments are provided' do 182 | rendered = layout(data: ['foo', nil]) 183 | 184 | lines = rendered.split("\n") 185 | assert_equal('[Chalk::Log fault: Could not format message] error_class="Chalk::Log::InvalidArguments" error="Invalid leftover arguments: [\"foo\", nil]"', lines[0]) 186 | assert(lines.length > 1) 187 | end 188 | 189 | it 'handles single faults' do 190 | e = StandardError.new('msg') 191 | @layout.expects(:do_format).raises(e) 192 | rendered = layout(data: ['hi']) 193 | 194 | lines = rendered.split("\n") 195 | assert_equal('[Chalk::Log fault: Could not format message] error_class=StandardError error=msg', lines[0]) 196 | assert(lines.length > 1) 197 | end 198 | 199 | it 'handles double-faults' do 200 | e = StandardError.new('msg') 201 | def e.to_s; raise 'Time to double-fault'; end 202 | 203 | @layout.expects(:do_format).raises(e) 204 | rendered = layout(data: ['hi']) 205 | 206 | lines = rendered.split("\n") 207 | assert_match(/Chalk::Log fault: Double fault while formatting message/, lines[0]) 208 | assert_equal(1, lines.length, "Lines: #{lines.inspect}") 209 | end 210 | 211 | it 'handles triple-faults' do 212 | e = StandardError.new('msg') 213 | def e.to_s 214 | f = StandardError.new('double') 215 | def f.to_s; raise 'Time to triple fault'; end 216 | raise f 217 | end 218 | 219 | @layout.expects(:do_format).raises(e) 220 | rendered = layout(data: ['hi']) 221 | 222 | lines = rendered.split("\n") 223 | assert_match(/Chalk::Log fault: Triple fault while formatting message/, lines[0]) 224 | assert_equal(1, lines.length, "Lines: #{lines.inspect}") 225 | end 226 | end 227 | end 228 | end 229 | end 230 | --------------------------------------------------------------------------------