-
Notifications
You must be signed in to change notification settings - Fork 424
Rails Integration for DynamoDB Profiling
Thomas Witt edited this page Aug 13, 2025
·
1 revision
Inspired by the riak integration, I wanted to have better overviews for my non-Activerecord app about what queries are actually being issued:
# config/initializers/dynamodb_profiler.rb
# Instrument Aws::DynamoDB::Client calls so they appear in Rack::MiniProfiler's SQL Summary.
# Works transparently with aws-record because aws-record uses Aws::DynamoDB::Client internally.
begin
require 'rack-mini-profiler'
rescue LoadError
# Rack::MiniProfiler not present; no-op.
end
begin
require 'aws-sdk-dynamodb'
rescue LoadError
# AWS SDK not present; no-op. The patch only applies when Aws::DynamoDB::Client exists.
end
require 'json'
if defined?(::Rack::MiniProfiler) && defined?(::Aws::DynamoDB::Client)
Rails.application.config.after_initialize do
# Avoid double-prepend on reload
module RackMiniProfilerDynamoDB
module Instrumentation
# @param params [Hash] Request parameters for DynamoDB get_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def get_item(params = {}, options = {})
profile_dynamodb('get_item', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB put_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def put_item(params = {}, options = {})
profile_dynamodb('put_item', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB update_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def update_item(params = {}, options = {})
profile_dynamodb('update_item', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB delete_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def delete_item(params = {}, options = {})
profile_dynamodb('delete_item', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB query.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def query(params = {}, options = {})
profile_dynamodb('query', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB scan.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def scan(params = {}, options = {})
profile_dynamodb('scan', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB batch_get_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def batch_get_item(params = {}, options = {})
profile_dynamodb('batch_get_item', params) { super }
end
# @param params [Hash] Request parameters for DynamoDB batch_write_item.
# @param options [Hash] Additional AWS SDK options for the call.
# @return [Seahorse::Client::Response] DynamoDB response object.
def batch_write_item(params = {}, options = {})
profile_dynamodb('batch_write_item', params) { super }
end
private
# Profiles a DynamoDB operation so it shows up in Rack::MiniProfiler's SQL panel.
#
# - Gates off active MiniProfiler request (no overhead when disabled).
# - Measures elapsed time using a monotonic clock.
# - Records the "SQL" via Rack::MiniProfiler.record_sql (this is what populates SQL Summary).
# - Also wraps in a `step` so entries appear in the timeline.
#
# @param operation [String] DynamoDB operation name (e.g., 'query', 'get_item').
# @param params [Hash] The exact params hash sent to AWS SDK (symbols or strings).
# @yieldreturn [Seahorse::Client::Response] The AWS SDK response.
# @return [Seahorse::Client::Response] The AWS SDK response (unchanged).
def profile_dynamodb(operation, params = {}, &blk)
return yield unless mp_should_measure?
base_label = build_ddb_label(operation, params)
# Wrap in a step for the timeline view AND record SQL timing
::Rack::MiniProfiler.step("SQL: #{base_label}") do
start_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = blk.call
elapsed_ms = mp_elapsed_ms(start_mono)
# Append response size to the recorded SQL line
size = ddb_response_size_bytes(response)
sql_text = "#{base_label} [#{size} bytes]"
# Record as SQL timing (shows in SQL Summary)
begin
::Rack::MiniProfiler.record_sql(sql_text, elapsed_ms, nil)
rescue => e
Rails.logger.debug { "DynamoDB profiling record_sql failed: #{e.class}: #{e.message}" }
end
response
end
rescue => e
# Preserve original exceptions from the AWS SDK.
raise e
end
# True if MiniProfiler is currently measuring this request.
#
# @return [Boolean]
def mp_should_measure?
c = ::Rack::MiniProfiler.current
!!(c && c.measure && c.current_timer)
end
# Converts a monotonic start time to elapsed milliseconds.
#
# @param start_mono [Float] Monotonic clock reading captured before the operation.
# @return [Float] Elapsed time in milliseconds with 0.1ms precision.
def mp_elapsed_ms(start_mono)
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_mono) * 1000.0).round(1)
end
# Builds a concise, informative label for the operation.
# Includes operation, table name (if provided), key info for item ops,
# and Query/Scan expressions with placeholder substitution.
#
# @param operation [String]
# @param params [Hash]
# @return [String]
def build_ddb_label(operation, params)
# Support symbol and string keys (SDK accepts both).
label = "#{operation}"
case operation
when 'query', 'scan'
names = params[:expression_attribute_names] || params['ExpressionAttributeNames']
values = params[:expression_attribute_values] || params['ExpressionAttributeValues']
key_cond = params[:key_condition_expression] || params['KeyConditionExpression']
filter = params[:filter_expression] || params['FilterExpression']
parts = []
parts << "#{substitute_expressions(key_cond, names, values)}" if key_cond
parts << "#{substitute_expressions(filter, names, values)}" if filter
label += " (#{parts.join('; ')})" if parts.any?
idx = params[:index_name] || params['IndexName']
label += " [idx: #{idx}]" if idx
when 'get_item', 'update_item', 'delete_item'
key = params[:key] || params['Key']
if key && key.respond_to?(:map)
key_info = key.map do |k, v|
raw = extract_attr_value(v)
"#{k}=#{truncate_value(raw)}"
end.join(', ')
label += " (#{key_info})" unless key_info.empty?
end
when 'batch_get_item'
reqs = params[:request_items] || params['RequestItems']
if reqs && reqs.respond_to?(:values)
all_keys = []
reqs.each do |_table, config|
keys = Array((config[:keys] || config['Keys']))
keys.each do |key|
# Extract the hash key value (usually the first key)
key_str = key.map { |k, v| "#{k}=#{truncate_value(extract_attr_value(v))}" }.join(',')
all_keys << key_str
end
end
total = all_keys.size
if total <= 10 && total.positive?
# Show actual keys if 10 or fewer
label += " [#{all_keys.join(', ')}]"
elsif total.positive?
# Show count if more than 10
label += " [#{total} keys]"
end
end
when 'batch_write_item'
reqs = params[:request_items] || params['RequestItems']
if reqs && reqs.respond_to?(:values)
all_ops = []
reqs.each do |_table, operations|
Array(operations).each do |op|
if op[:put_request] || op['PutRequest']
item = (op[:put_request] || op['PutRequest'])[:item] || (op[:put_request] || op['PutRequest'])['Item']
if item && (key = item[:hash_key] || item['hash_key'])
all_ops << "put:#{truncate_value(extract_attr_value(key))}"
else
all_ops << 'put'
end
elsif op[:delete_request] || op['DeleteRequest']
key = (op[:delete_request] || op['DeleteRequest'])[:key] || (op[:delete_request] || op['DeleteRequest'])['Key']
if key && (hash_key = key[:hash_key] || key['hash_key'])
all_ops << "del:#{truncate_value(extract_attr_value(hash_key))}"
else
all_ops << 'del'
end
end
end
end
total = all_ops.size
if total <= 10 && total.positive?
# Show actual operations if 10 or fewer
label += " [#{all_ops.join(', ')}]"
elsif total.positive?
# Show count if more than 10
label += " [#{total} operations]"
end
end
end
label
rescue => e
Rails.logger.debug { "DynamoDB profiling label build failed: #{e.class}: #{e.message}" }
"DynamoDB.#{operation}"
end
# Replaces ExpressionAttributeNames and ExpressionAttributeValues in an expression
# so the profiler shows meaningful text.
#
# @param expr [String, nil]
# @param names [Hash, nil]
# @param values [Hash, nil]
# @return [String]
def substitute_expressions(expr, names, values)
return '' unless expr
s = expr.to_s.dup
if names.respond_to?(:each)
names.each { |placeholder, real| s.gsub!(placeholder.to_s, real.to_s) }
end
if values.respond_to?(:each)
values.each do |placeholder, typed|
raw = extract_attr_value(typed)
s.gsub!(placeholder.to_s, raw.inspect)
end
end
s
rescue => e
Rails.logger.debug { "DynamoDB profiling expression substitution failed: #{e.class}: #{e.message}" }
expr.to_s
end
# Extracts the raw value from a DynamoDB typed attribute (e.g., {"S"=>"abc"}) or returns the input.
#
# @param val [Object]
# @return [Object]
def extract_attr_value(val)
if val.is_a?(Hash) && val.size == 1
val.values.first
else
val
end
end
# Computes approximate response size in bytes by JSON-encoding response.to_h.
#
# @param response [Object]
# @return [Integer]
def ddb_response_size_bytes(response)
h = response.respond_to?(:to_h) ? response.to_h : {}
JSON.generate(h).bytesize
rescue => e
Rails.logger.debug { "DynamoDB profiling size calc failed: #{e.class}: #{e.message}" }
0
end
# Allocation-light truncation that avoids ActiveSupport.
#
# @param val [Object]
# @param max [Integer]
# @return [String]
def truncate_value(val, max = 60)
s = val.to_s
return s if s.length <= max
s[0, max - 3] + '...'
end
end
end
unless ::Aws::DynamoDB::Client.ancestors.include?(RackMiniProfilerDynamoDB::Instrumentation)
::Aws::DynamoDB::Client.prepend(RackMiniProfilerDynamoDB::Instrumentation)
end
Rails.logger.info 'Rack::MiniProfiler DynamoDB profiling enabled'
end
end