Skip to content

Instantly share code, notes, and snippets.

@nateberkopec
Created January 21, 2026 20:34
Show Gist options
  • Select an option

  • Save nateberkopec/329709e800101f5ce8d180b1eb8bbf68 to your computer and use it in GitHub Desktop.

Select an option

Save nateberkopec/329709e800101f5ce8d180b1eb8bbf68 to your computer and use it in GitHub Desktop.
Puma JSON logging demo - response to issue #3865

Puma JSON Logging Demo

This demo shows how to achieve JSON-formatted boot messages from Puma using existing configuration options. This is in response to GitHub Issue #3865.

Summary

Puma already supports custom log formatting via two DSL options:

  1. log_formatter - A block that transforms each log message string
  2. custom_logger - A custom logger object with a write(str) method

Using these options, you can get JSON output for all Puma boot messages without any changes to Puma itself.

Demo Configurations

1. Basic JSON (puma_json_basic.rb)

The simplest approach - wraps each message in a JSON object:

log_formatter do |str|
  JSON.generate({
    timestamp: Time.now.utc.iso8601(3),
    pid: Process.pid,
    message: str.strip
  })
end

Output:

{"timestamp":"2026-01-21T20:28:15.597Z","pid":83692,"message":"Puma starting in cluster mode..."}
{"timestamp":"2026-01-21T20:28:15.598Z","pid":83692,"message":"* Puma version: 7.2.0 (\"On The Corner\")"}

2. Structured JSON (puma_json_structured.rb)

Parses Puma's boot messages and extracts structured data:

custom_logger StructuredJsonLogger.new($stdout)

Output:

{"timestamp":"2026-01-21T20:29:05.395Z","pid":84032,"event":"puma_starting","mode":"cluster"}
{"timestamp":"2026-01-21T20:29:05.395Z","pid":84032,"event":"version_info","component":"puma","version":"7.2.0 (\"On The Corner\")"}
{"timestamp":"2026-01-21T20:29:05.395Z","pid":84032,"event":"config","setting":"min_threads","value":1}
{"timestamp":"2026-01-21T20:29:05.396Z","pid":84032,"event":"listening","address":"http://0.0.0.0:9292"}
{"timestamp":"2026-01-21T20:29:05.398Z","pid":84061,"event":"worker_booted","worker":0,"worker_pid":84061,"boot_time_seconds":0.0,"phase":0}

3. semantic_logger Integration (puma_semantic_logger.rb)

Uses the semantic_logger gem for enterprise-grade structured logging:

require 'semantic_logger'
SemanticLogger.add_appender(io: $stdout, formatter: :json)
custom_logger SemanticLoggerAdapter.new(SemanticLogger['Puma'])

Output:

{"host":"server.local","application":"Semantic Logger","timestamp":"2026-01-21T20:30:35.864997Z","level":"info","level_index":2,"pid":85667,"thread":"376","name":"Puma","message":"Puma starting in cluster mode..."}

4. Stdlib Logger (puma_stdlib_json.rb)

Uses Ruby's built-in Logger with a JSON formatter (no external dependencies):

custom_logger JsonLogger.new($stdout)

Running the Demos

cd tmp/json_logging_demo

# Basic JSON formatter
bundle exec puma -C puma_json_basic.rb

# Structured JSON with message parsing
bundle exec puma -C puma_json_structured.rb

# Single mode (no workers)
bundle exec puma -C puma_json_single.rb

# With semantic_logger (requires: gem install semantic_logger)
ruby -I../../lib -rpuma/cli -e 'Puma::CLI.new(["--config", "puma_semantic_logger.rb"]).run'

# Stdlib Logger approach
bundle exec puma -C puma_stdlib_json.rb

Key Points

  1. Each line is valid JSON - This is what makes log ingestion pipelines happy. Each boot message becomes a separate JSON object on its own line.

  2. The limitation - Puma generates boot messages as individual text strings, not as structured data. So you can't get a single JSON object with all config in one message (like the OP's ideal example) without modifying Puma internals.

  3. What IS possible today:

    • Each log line as valid JSON ✓
    • Timestamp, PID, level fields ✓
    • Message parsing to extract structured data ✓
    • Integration with logging libraries ✓
  4. What would require Puma changes:

    • Consolidated boot message (all config in one JSON object)
    • Native structured logging with typed fields from the start

Recommendation for Issue #3865

The OP can achieve JSON-formatted boot messages TODAY using log_formatter or custom_logger. The structured parser approach (puma_json_structured.rb) gets close to what was requested, parsing boot messages into typed events.

If Puma wants to go further, it could:

  1. Document these existing options better
  2. Add a built-in JSON formatter option (e.g., log_format :json)
  3. Refactor internal logging to use structured events (bigger change)
# frozen_string_literal: true
# Simple Rack app for demo
run ->(env) { [200, { 'content-type' => 'text/plain' }, ['Hello World']] }
# frozen_string_literal: true
# Basic JSON log formatter - wraps each message in JSON
# This is the simplest approach using Puma's built-in log_formatter
require 'json'
workers 2
threads 1, 5
log_formatter do |str|
JSON.generate({
timestamp: Time.now.utc.iso8601(3),
pid: Process.pid,
message: str.strip
})
end
# frozen_string_literal: true
# Single-mode JSON log formatter (no workers)
require 'json'
threads 1, 5
log_formatter do |str|
JSON.generate({
timestamp: Time.now.utc.iso8601(3),
pid: Process.pid,
message: str.strip
})
end
# frozen_string_literal: true
# Structured JSON logger using custom_logger
# Parses Puma's boot messages and creates more structured output
require 'json'
class StructuredJsonLogger
# Patterns to parse Puma boot messages and extract structured data
BOOT_PATTERNS = {
/^Puma starting in (\w+) mode/ => ->(m) { { event: 'puma_starting', mode: m[1] } },
/^\* Puma version: (.+)$/ => ->(m) { { event: 'version_info', component: 'puma', version: m[1] } },
/^\* Ruby version: (.+)$/ => ->(m) { { event: 'version_info', component: 'ruby', version: m[1] } },
/^\*\s+Min threads: (\d+)$/ => ->(m) { { event: 'config', setting: 'min_threads', value: m[1].to_i } },
/^\*\s+Max threads: (\d+)$/ => ->(m) { { event: 'config', setting: 'max_threads', value: m[1].to_i } },
/^\*\s+Environment: (.+)$/ => ->(m) { { event: 'config', setting: 'environment', value: m[1] } },
/^\*\s+Master PID: (\d+)$/ => ->(m) { { event: 'config', setting: 'master_pid', value: m[1].to_i } },
/^\*\s+PID: (\d+)$/ => ->(m) { { event: 'config', setting: 'pid', value: m[1].to_i } },
/^\*\s+Workers: (.+)$/ => ->(m) { { event: 'config', setting: 'workers', value: m[1] } },
/^\*\s+Restarts: (.+)$/ => ->(m) { { event: 'config', setting: 'restarts', value: m[1] } },
/^\* Preloading application$/ => ->(_) { { event: 'preload_start' } },
/^\* Listening on (.+)$/ => ->(m) { { event: 'listening', address: m[1] } },
/^Use Ctrl-C to stop$/ => ->(_) { { event: 'ready' } },
/^- Worker (\d+) \(PID: (\d+)\) booted in ([\d.]+)s, phase: (\d+)$/ => ->(m) {
{ event: 'worker_booted', worker: m[1].to_i, worker_pid: m[2].to_i, boot_time_seconds: m[3].to_f, phase: m[4].to_i }
},
/^=== puma shutdown: (.+) ===$/ => ->(m) { { event: 'shutdown', time: m[1] } },
/^- Goodbye!$/ => ->(_) { { event: 'goodbye' } },
/^- Gracefully shutting down workers\.\.\.$/ => ->(_) { { event: 'shutting_down_workers' } }
}.freeze
def initialize(output = $stdout)
@output = output
end
def write(str)
msg = str.to_s.strip
return if msg.empty?
structured = parse_message(msg)
json = JSON.generate({
timestamp: Time.now.utc.iso8601(3),
pid: Process.pid,
**structured
})
@output.puts json
@output.flush
end
private
def parse_message(msg)
BOOT_PATTERNS.each do |pattern, handler|
if (match = msg.match(pattern))
return handler.call(match)
end
end
# Default: unrecognized message
{ event: 'log', message: msg }
end
end
workers 2
threads 1, 5
# Use a passthrough formatter so custom_logger gets raw messages
log_formatter { |str| str }
custom_logger StructuredJsonLogger.new($stdout)
# frozen_string_literal: true
# Integration with semantic_logger gem for structured JSON logging
#
# Requires: gem install semantic_logger
#
# semantic_logger is a full-featured structured logging library that
# supports JSON output, log levels, named loggers, and more.
begin
require 'semantic_logger'
rescue LoadError
abort "This config requires semantic_logger. Run: gem install semantic_logger"
end
# Configure semantic_logger to output JSON to stdout
SemanticLogger.default_level = :info
SemanticLogger.add_appender(io: $stdout, formatter: :json)
# Create a Puma-specific logger
puma_logger = SemanticLogger['Puma']
# Custom logger adapter that sends Puma messages to semantic_logger
class SemanticLoggerAdapter
def initialize(logger)
@logger = logger
end
def write(str)
msg = str.to_s.strip
return if msg.empty?
# Parse message to determine log level
level = case msg
when /^!|^ERROR|^FATAL/ then :error
when /^%/ then :debug # Puma debug messages start with %
else :info
end
@logger.send(level, msg)
end
end
workers 2
threads 1, 5
# Use passthrough formatter since semantic_logger handles formatting
log_formatter { |str| str }
custom_logger SemanticLoggerAdapter.new(puma_logger)
# frozen_string_literal: true
# Using Ruby's stdlib Logger with a JSON formatter
# No external dependencies required
require 'json'
require 'logger'
# Create a Logger that outputs JSON
class JsonLogger < Logger
def initialize(*)
super
self.formatter = method(:json_formatter)
end
def write(str)
info(str.strip)
end
private
def json_formatter(severity, time, _progname, msg)
JSON.generate({
timestamp: time.utc.iso8601(3),
level: severity.downcase,
pid: Process.pid,
message: msg
}) + "\n"
end
end
workers 2
threads 1, 5
log_formatter { |str| str }
custom_logger JsonLogger.new($stdout)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment