Module: DatabaseLogger
- Defined in:
- lib/middleware/database_logger.rb
Overview
DatabaseLogger is redis-rb middleware for command logging and capture.
Provides detailed Redis command logging for development and debugging. Familia uses the redis-rb gem (v4.8.1 to <6.0), which internally uses RedisClient infrastructure for middleware. Users work with Redis.new connections and Redis:: exceptions - RedisClient is an implementation detail.
User-Facing API
Enable via Familia configuration: Familia.enable_database_logging = true
Familia automatically calls RedisClient.register(DatabaseLogger) internally.
Critical: Uses super not yield for middleware chaining
Internal: RedisClient Middleware Architecture
RedisClient middlewares are modules that are included into the
RedisClient::Middlewares class, which inherits from BasicMiddleware.
The middleware chain works through Ruby's method lookup and super.
Middleware Chain Flow (Internal)
# Internal registration order (last registered is called first):
RedisClient.register(DatabaseLogger) # Called second (internal)
RedisClient.register(DatabaseCommandCounter) # Called first (internal)
# Execution flow when client.call('SET', 'key', 'value') is invoked:
DatabaseCommandCounter.call(cmd, config) { |result| ... }
└─> super # Implicitly passes block to next middleware
└─> DatabaseLogger.call(cmd, config)
└─> super # Implicitly passes block to next middleware
└─> BasicMiddleware.call(cmd, config)
└─> yield command # Executes actual Redis command
└─> Returns result
← result flows back up
← result flows back up
← result flows back up
← result flows back up
Critical Implementation Detail: super vs yield
MUST use super to properly chain middlewares. Using yield breaks
the chain because it executes the original block directly, bypassing other
middlewares in the chain.
# ✅ CORRECT - Chains to next middleware
def call(command, config)
result = super # Calls next middleware, block passes implicitly
result
end
# ❌ WRONG - Breaks middleware chain
def call(command, config)
result = yield # Executes block directly, skips other middlewares!
result
end
When super is called:
- Ruby automatically passes the block to the next method in the chain
- The next middleware's
callmethod executes - Eventually reaches
BasicMiddleware.callwhich doesyield command - The actual Redis command executes
- Results flow back up through each middleware
Usage Examples
rubocop:disable ThreadSafety/ClassInstanceVariable
Constant Summary collapse
- CommandMessage =
Data structure for captured command metadata
Data.define(:command, :μs, :timeline) do alias_method :to_a, :deconstruct def inspect cmd, duration, timeline = to_a format('%.6f %4dμs > %s', timeline, duration, cmd) end end
Class Attribute Summary collapse
-
.commands ⇒ Array<CommandMessage>
readonly
Gets the captured commands for testing purposes.
-
.logger ⇒ Logger?
Gets/sets the logger instance used by DatabaseLogger.
-
.max_commands ⇒ Integer
Gets/sets the maximum number of commands to capture.
-
.process_start ⇒ Float
readonly
Gets the timestamp when DatabaseLogger was loaded.
-
.sample_rate ⇒ Float?
Gets/sets the sampling rate for logging.
-
.structured_logging ⇒ Boolean
Gets/sets structured logging mode.
Class Method Summary collapse
-
.append_command(message) ⇒ Array<CommandMessage>
private
Appends a command message to the captured commands array.
-
.capture_commands { ... } ⇒ Array<CommandMessage>
Captures commands in a block and returns them.
-
.clear_commands ⇒ nil
Clears the captured commands array.
-
.index ⇒ Integer
Gets the current count of captured commands.
-
.now_in_μs ⇒ Integer
(also: now_in_microseconds)
Returns the current time in microseconds.
-
.should_log? ⇒ Boolean
private
Determines if this command should be logged based on sampling rate.
Instance Method Summary collapse
-
#call(command, config) ⇒ Object
Logs the Redis command and its execution time.
-
#call_once(command, config) ⇒ Object
Handle call_once for commands requiring dedicated connection handling:.
-
#call_pipelined(commands, config) ⇒ Array
Handle pipelined commands (including MULTI/EXEC transactions).
Class Attribute Details
.commands ⇒ Array<CommandMessage> (readonly)
Gets the captured commands for testing purposes.
159 160 161 |
# File 'lib/middleware/database_logger.rb', line 159 def commands @commands end |
.logger ⇒ Logger?
Gets/sets the logger instance used by DatabaseLogger.
118 119 120 |
# File 'lib/middleware/database_logger.rb', line 118 def logger @logger end |
.max_commands ⇒ Integer
Gets/sets the maximum number of commands to capture.
122 123 124 |
# File 'lib/middleware/database_logger.rb', line 122 def max_commands @max_commands end |
.process_start ⇒ Float (readonly)
Gets the timestamp when DatabaseLogger was loaded.
163 164 165 |
# File 'lib/middleware/database_logger.rb', line 163 def process_start @process_start end |
.sample_rate ⇒ Float?
Command capture is unaffected - only logger output is sampled. This means tests can still verify commands while production logs stay clean.
Gets/sets the sampling rate for logging. Controls what percentage of commands are logged to reduce noise.
155 156 157 |
# File 'lib/middleware/database_logger.rb', line 155 def sample_rate @sample_rate end |
.structured_logging ⇒ Boolean
Gets/sets structured logging mode. When enabled, outputs Redis commands with structured key=value context instead of formatted string output.
137 138 139 |
# File 'lib/middleware/database_logger.rb', line 137 def structured_logging @structured_logging end |
Class Method Details
.append_command(message) ⇒ Array<CommandMessage>
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Appends a command message to the captured commands array.
When the array reaches max_commands capacity, the oldest command is removed before adding the new one.
209 210 211 212 213 214 215 216 |
# File 'lib/middleware/database_logger.rb', line 209 def append_command() # We can throw away commands and not worry about thread race conditions # since no one is going to mind if the command list is +/- a few # commands. Unlike how we care about the order that the commands # appear in the list, we don't care about exact count when trimming. @commands.shift if @commands.size >= @max_commands @commands << # this is threadsafe thanks to Concurrent::Array end |
.capture_commands { ... } ⇒ Array<CommandMessage>
Captures commands in a block and returns them. This is useful for testing to see what commands were executed.
189 190 191 192 193 |
# File 'lib/middleware/database_logger.rb', line 189 def capture_commands clear_commands yield @commands.to_a end |
.clear_commands ⇒ nil
Clears the captured commands array.
Thread-safe via mutex to ensure test isolation.
170 171 172 173 174 175 |
# File 'lib/middleware/database_logger.rb', line 170 def clear_commands @commands_mutex.synchronize do @commands.clear end nil end |
.index ⇒ Integer
Gets the current count of captured commands.
197 198 199 |
# File 'lib/middleware/database_logger.rb', line 197 def index @commands.size end |
.now_in_μs ⇒ Integer Also known as: now_in_microseconds
Returns the current time in microseconds. This is used to measure the duration of Redis commands.
222 223 224 |
# File 'lib/middleware/database_logger.rb', line 222 def now_in_μs Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) end |
.should_log? ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Determines if this command should be logged based on sampling rate.
Uses deterministic modulo-based sampling for consistent behavior. Thread-safe via atomic counter increment.
234 235 236 237 238 239 240 241 242 |
# File 'lib/middleware/database_logger.rb', line 234 def should_log? return true if @sample_rate.nil? return false if @logger.nil? # Deterministic sampling: every Nth command where N = 1/sample_rate # e.g., 0.1 = every 10th, 0.01 = every 100th sample_interval = (1.0 / @sample_rate).to_i (@sample_counter.increment % sample_interval).zero? end |
Instance Method Details
#call(command, config) ⇒ Object
Commands are always captured for testing. Logging only occurs when DatabaseLogger.logger is set and sampling allows it.
Logs the Redis command and its execution time.
This method is part of the RedisClient middleware chain. It MUST use super
instead of yield to properly chain with other middlewares.
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/middleware/database_logger.rb', line 256 def call(command, config) block_start = DatabaseLogger.now_in_μs result = super # CRITICAL: Must use super, not yield, to chain middlewares block_duration = DatabaseLogger.now_in_μs - block_start # We intentionally use two different codepaths for getting the # time, although they will almost always be so similar that the # difference is negligible. lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6) msgpack = CommandMessage.new(command.join(' '), block_duration, lifetime_duration) DatabaseLogger.append_command(msgpack) # Dual-mode logging with sampling if DatabaseLogger.should_log? if DatabaseLogger.structured_logging && DatabaseLogger.logger duration_ms = (block_duration / 1000.0).round(2) db_num = if config.respond_to?(:db) config.db elsif config.is_a?(Hash) config[:db] end DatabaseLogger.logger.trace( "Redis command cmd=#{command.first} args=#{command[1..-1].inspect} " \ "duration_μs=#{block_duration} duration_ms=#{duration_ms} " \ "timeline=#{lifetime_duration} db=#{db_num} index=#{DatabaseLogger.index}" ) elsif DatabaseLogger.logger # Existing formatted output = format('[%s] %s', DatabaseLogger.index, msgpack.inspect) DatabaseLogger.logger.trace() end end # Notify instrumentation hooks if defined?(Familia::Instrumentation) duration_ms = (block_duration / 1000.0).round(2) db_num = if config.respond_to?(:db) config.db elsif config.is_a?(Hash) config[:db] end conn_id = if config.respond_to?(:custom) config.custom&.dig(:id) elsif config.is_a?(Hash) config.dig(:custom, :id) end Familia::Instrumentation.notify_command( command.first, duration_ms, full_command: command, db: db_num, connection_id: conn_id, ) end result end |
#call_once(command, config) ⇒ Object
Handle call_once for commands requiring dedicated connection handling:
- Blocking commands (BLPOP, BRPOP, BRPOPLPUSH)
- Pub/sub operations (SUBSCRIBE, PSUBSCRIBE)
- Commands requiring connection affinity
- Explicit non-pooled command execution
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 |
# File 'lib/middleware/database_logger.rb', line 389 def call_once(command, config) block_start = DatabaseLogger.now_in_μs result = yield # CRITICAL: For call_once, yield is correct (not chaining) block_duration = DatabaseLogger.now_in_μs - block_start lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6) msgpack = CommandMessage.new(command.join(' '), block_duration, lifetime_duration) DatabaseLogger.append_command(msgpack) # Dual-mode logging with sampling if DatabaseLogger.should_log? if DatabaseLogger.structured_logging && DatabaseLogger.logger duration_ms = (block_duration / 1000.0).round(2) db_num = if config.respond_to?(:db) config.db elsif config.is_a?(Hash) config[:db] end DatabaseLogger.logger.trace( "Redis command_once cmd=#{command.first} args=#{command[1..-1].inspect} " \ "duration_μs=#{block_duration} duration_ms=#{duration_ms} " \ "timeline=#{lifetime_duration} db=#{db_num} index=#{DatabaseLogger.index}" ) elsif DatabaseLogger.logger = format('[%s] %s', DatabaseLogger.index, msgpack.inspect) DatabaseLogger.logger.trace() end end result end |
#call_pipelined(commands, config) ⇒ Array
Handle pipelined commands (including MULTI/EXEC transactions)
Captures MULTI/EXEC and shows you the full transaction. The WATCH and EXISTS appear separately because they're executed as individual commands before the transaction starts.
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'lib/middleware/database_logger.rb', line 324 def call_pipelined(commands, config) block_start = DatabaseLogger.now_in_μs results = yield # CRITICAL: For call_pipelined, yield is correct (not chaining) block_duration = DatabaseLogger.now_in_μs - block_start lifetime_duration = (Time.now.to_f - DatabaseLogger.process_start).round(6) # Log the entire pipeline as a single operation cmd_string = commands.map { |cmd| cmd.join(' ') }.join(' | ') msgpack = CommandMessage.new(cmd_string, block_duration, lifetime_duration) DatabaseLogger.append_command(msgpack) # Dual-mode logging with sampling if DatabaseLogger.should_log? if DatabaseLogger.structured_logging && DatabaseLogger.logger duration_ms = (block_duration / 1000.0).round(2) db_num = if config.respond_to?(:db) config.db elsif config.is_a?(Hash) config[:db] end DatabaseLogger.logger.trace( "Redis pipeline commands=#{commands.size} duration_μs=#{block_duration} " \ "duration_ms=#{duration_ms} timeline=#{lifetime_duration} " \ "db=#{db_num} index=#{DatabaseLogger.index}" ) elsif DatabaseLogger.logger = format('[%s] %s', DatabaseLogger.index, msgpack.inspect) DatabaseLogger.logger.trace() end end # Notify instrumentation hooks if defined?(Familia::Instrumentation) duration_ms = (block_duration / 1000.0).round(2) db_num = if config.respond_to?(:db) config.db elsif config.is_a?(Hash) config[:db] end conn_id = if config.respond_to?(:custom) config.custom&.dig(:id) elsif config.is_a?(Hash) config.dig(:custom, :id) end Familia::Instrumentation.notify_pipeline( commands.size, duration_ms, db: db_num, connection_id: conn_id ) end results end |