Ougai Structured Logging for Ruby

repository·main·Indexed 42 days ago

https://github.com/tilfin/ougai

A structured logging system for Ruby that produces JSON logs compatible with Node.js tools like Bunyan and Pino. Ougai extends the standard Ruby Logger to handle messages, structured data, and exceptions seamlessly, featuring child loggers for inherited context, before_log hooks for dynamic data injection, and a TRACE log level.

Tokens
6.7K
Snippets
29
Records
33
Agent score
51%

What's inside Ougai

  1. View pretty logs with Bunyan or Pino

    main

    Since Ougai produces JSON compatible with Bunyan and Pino, you can pipe your log output to these Node.js tools to view human-readable, colorized logs.

    1. Install Bunyan via npm: npm install -g bunyan

    2. Pipe your log file to the command: cat output.log | bunyan

    $ cat output.log | bunyan
  2. Integrate Ougai with other services and libraries

    main

    Ougai can be integrated with various Ruby ecosystem tools and log forwarding services. Common integration patterns include:

    • Rails: Use as a Rails logger and combine with Lograge for request logging.
    • Sidekiq: Customize the Sidekiq logger to use Ougai.
    • Rack: Use as a Rack logger.
    • ServerEngine: Use as a ServerEngine logger.
    • Log Forwarding: Forward logs to services like Fluentd, Logentries, or Loggly.
  3. Install Ougai

    main

    You can install Ougai using either Bundler or by installing the gem directly.

    Using Bundler: Add gem 'ougai' to your Gemfile and run bundle.

    Using Gem: Run gem install ougai in your terminal.

    # In Gemfile
    gem 'ougai'
    $ bundle
    # or
    $ gem install ougai
  4. Use a human-readable formatter for console output

    main

    By default, Ougai produces structured logs. To make logs human-readable in a console environment, you can use the Ougai::Formatters::Readable formatter. This requires the amazing_print gem to be present in your environment.

    1. Add gem 'amazing_print' to your Gemfile and run bundle.
    2. Assign an instance of Ougai::Formatters::Readable.new to the formatter accessor of your Ougai::Logger instance.
    require 'ougai'
    
    logger = Ougai::Logger.new(STDOUT)
    logger.formatter = Ougai::Formatters::Readable.new
  5. Pino Formatter Output Schema

    main

    The Ougai::Formatters::Pino formatter produces JSON logs following the Pino specification. When a log is emitted, the following fields are included in the top-level JSON object:

    • name: The application name (or progname if provided).
    • hostname: The hostname of the machine.
    • pid: The current process ID ($$).
    • level: The numeric log level (converted via to_level).
    • time: The timestamp (converted to milliseconds since epoch via convert_time).
    • v: The version of the log format (fixed at 1).
    • msg: The log message (extracted from the :err object if present).
    • stack: The error stack trace, formatted with indentation.
    • type: Defaults to 'Error' if an :err object is present.

    Error Handling Behavior

    If the log data contains an :err key, the formatter flattens it:

    1. The :err key is removed from the data.
    2. data[:msg] is set to the error message.
    3. data[:type] is set to 'Error' if not already present.
    4. data[:stack] is constructed using the error name, message, and the indented stack trace.
  6. Implement a custom Ougai formatter

    main

    To create a custom log formatter in Ougai, you must inherit from Ougai::Formatters::Base and override the _call method. The call method is the public entrypoint that handles data normalization (ensuring data is a Hash), while _call is where you implement your specific formatting logic.

    _call expects the following arguments:

    • severity: The log level (e.g., INFO, ERROR).
    • time: The timestamp of the log event.
    • progname: The program name.
    • data: A Hash containing the log message and any custom metadata.
    class MyCustomFormatter < Ougai::Formatters::Base
      def _call(severity, time, progname, data)
        # Implement custom formatting logic here
        # data is guaranteed to be a Hash
      end
    end
  7. How the JSON formatter serializes data

    main

    The dump(data) method in the JSON formatter handles the transformation of log data into its final string representation.

    1. If jsonize is false, it returns the raw data object.
    2. If jsonize is true, it calls convert_time(data) to ensure timestamps are correctly formatted.
    3. It then uses Ougai::Serializer.for_json to serialize the data into a JSON string.
    4. If with_newline is true, it appends a newline character to the end of the string.
  8. Add custom fields to all logs with `with_fields`

    main

    You can set with_fields on a logger to ensure specific keys are included in every log entry.

    • Overriding: If a log entry specifies a field that is also in with_fields, the log entry's value overrides the with_fields value.
    • Arrays: If the field type is an Array, the values are merged using concat and uniq.
    • Hashes: If the field type is a Hash, the values are merged recursively.
    logger.with_fields = { version: '1.1.0', user: { name: 'Taro' } }
    logger.debug(user: { age: 19 }) 
    # Result: version is '1.1.0', user is { name: 'Taro', age: 19 }
  9. Broadcast logs to multiple targets

    main

    Use Ougai::Logger.broadcast(target_logger) to propagate all log calls from a primary logger to a secondary logger. This is useful for sending errors to a specific file while sending all logs to STDOUT.

    Note that setting the level on the primary logger also propagates that level change to the broadcasted logger.

    logger = Ougai::Logger.new(STDOUT)
    logger.level = Logger::INFO
    
    error_logger = Ougai::Logger.new('./error.log')
    error_logger.level = Logger::ERROR
    
    logger.extend Ougai::Logger.broadcast(error_logger)
    
    logger.info('Hello!')    # Only to STDOUT
    logger.error('Failed!') # To both STDOUT and error.log
  10. Use `before_log` hooks to inject dynamic data

    main

    You can assign a lambda to before_log to run a process before every log output. This is useful for injecting dynamic context like Thread IDs.

    • The lambda receives the data hash as an argument.
    • Returning false from the lambda will cancel the log and prevent output.
    • The before_log hook of a child logger runs before the parent's hook.
    logger.before_log = lambda do |data|
      data[:thread_id] = Thread.current.object_id.to_s(36)
    end
    
    logger.debug('on main thread')
  11. Create child loggers

    main

    Use logger.child(with_fields) to create a new logger instance that inherits settings from the parent but includes additional with_fields.

    • Inheritance: Child loggers inherit the parent's level. Setting a child's level lower than the parent's will not take effect.
    • Merging: Child fields are merged with parent fields. For Hash types, merging is recursive. For Array types, they are merged via concat and uniq.
    • Block usage: You can pass a block to child to yield a temporary child logger that is cleaned up after the block exits.
    logger = Ougai::Logger.new(STDOUT)
    logger.with_fields = { app: 'yourapp' }
    
    child_logger = logger.child({ kind: 'logic' })
    child_logger.info('Created a user', name: 'Mike')
    
    logger.child({ mode: 'processed' }) do |gcc_logger|
      gcc_logger.info('Temporary child')
    end
  12. Initialize Ougai::Logger

    main

    Ougai::Logger is a subclass of the standard Ruby Logger. You can initialize it by passing an IO object (like $stdout) to the constructor. All arguments passed to Ougai::Logger.new are passed through to the standard ::Logger.

    require 'ougai'
    
    logger = Ougai::Logger.new($stdout)