排查一次API数据异常时,如果没有完整记录请求者IP、请求时间和操作详情,往往需要在应用日志、代理日志和数据库日志之间来回比对。Ruby生态提供了轻量且灵活的方案,可以通过Rack中间件或框架回调把每次API调用固化成结构化审计日志。

审计日志不只是帮助事后追溯,还能用于频率限制、风控分析和接口调用统计。本文围绕Ruby服务端如何采集request.ip、时间戳、HTTP方法与路径、请求参数和操作结果,给出可落地的中间件与Rails实现。
一、审计日志的字段设计与脱敏策略
审计日志是否可用,取决于字段是否足够还原现场。除了请求者IP、时间和操作详情这三个核心要素,还应当补充HTTP方法、完整路径、响应状态码和耗时。如果应用存在登录态,还需要记录用户ID或调用方AppID,这样可以把一次API调用关联到具体主体。时间戳建议统一使用UTC的ISO 8601格式,避免服务器时区混乱造成跨系统比对困难。
操作详情并不等同于原始请求参数。直接把整个params写入日志虽然简单,但用户密码、token、支付卡号等敏感字段会随之进入日志系统,扩大泄露面。记录前应做脱敏,至少覆盖password、token、authorization、credit_card等常见键;对嵌套参数也需要递归处理。下面的方法生成一条结构化审计条目,并用sanitize_params过滤敏感值。
AUDIT_FIELDS = %w[timestamp ip method path params user_id action status duration_ms].freeze
def build_audit_entry(request:, user_id:, action:, status:, duration_ms:)
{
timestamp: Time.now.utc.iso8601(3),
ip: request.ip,
method: request.request_method,
path: request.fullpath,
params: sanitize_params(request.params),
user_id: user_id,
action: action,
status: status,
duration_ms: duration_ms
}
end
def sanitize_params(params)
sensitive = %w[password token authorization credit_card]
params.each_with_object({}) do |(k, v), acc|
acc[k] = sensitive.include?(k.to_s.downcase) ? "[FILTERED]" : v
end
end
上述示例只处理了顶层键,生产环境建议使用ActionController::Parameters的filtered_parameters,或者自行实现递归脱敏。字段集合可以根据团队约定调整,例如增加User-Agent、Referer、请求体大小等,但不要无限扩展,否则日志体积和写入开销会快速上升。
二、用Rack中间件统一采集请求信息
Rack中间件位于Web服务器与Ruby应用之间,所有符合Rack规范的框架都能直接使用,包括Rails、Sinatra和Grape。中间件最适合记录通用访问信息:请求者IP、请求时间、方法、路径、状态码和响应耗时。它不关心具体业务,只要请求进入就会执行。
下面是一个完整中间件实现。通过Process.clock_gettime获取单调时钟,避免系统时间调整影响耗时计算。请求结束后用Logger输出一行JSON,方便日志采集器解析。异常分支单独记录错误信息,并重新抛出异常,保证应用原有错误处理不被吞掉。
class ApiAuditMiddleware
def initialize(app, logger: Logger.new($stdout))
@app = app
@logger = logger
end
def call(env)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
request = Rack::Request.new(env)
entry = {
time: Time.now.utc.iso8601(3),
ip: request.ip,
method: request.request_method,
path: request.fullpath,
referer: request.referer,
user_agent: request.user_agent,
status: status,
duration_ms: duration_ms
}
@logger.info(entry.to_json)
[status, headers, body]
rescue StandardError => e
@logger.error({
time: Time.now.utc.iso8601(3),
ip: begin
Rack::Request.new(env).ip
rescue StandardError
"unknown"
end,
error: e.class.name,
message: e.message,
path: env["PATH_INFO"]
}.to_json)
raise
end
end
中间件注册也很简单。Rails应用可以在config/application.rb中写入config.middleware.insert_before 0, ApiAuditMiddleware;Sinatra项目则在入口文件中使用use ApiAuditMiddleware。需要注意,request.ip会根据Rack的受信代理列表处理X-Forwarded-For头,如果应用前面有Nginx或负载均衡器,应确认代理IP在信任范围内。不要直接读取X-Forwarded-For的第一个值,该头可以被客户端伪造。
三、在Rails控制器中记录操作详情
Rack中间件能记录方法、路径和状态,但无法自然知晓动作含义,例如是创建订单还是导出数据。操作详情更适合放在控制器层,利用框架路由已经解析出的控制器名和动作名,生成带有业务语义的审计条目。
Rails中可以通过around_action在动作执行前后计时。请求结束后,控制器上下文里已经能拿到controller_name、action_name、request.ip和response.status。下面的代码放在ApplicationController中,对所有API控制器生效。当前用户通过current_user辅助方法获取,未登录时为nil。
class ApplicationController < ActionController::API
around_action :audit_api_request
private
def audit_api_request
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
AuditLogger.log(
controller: controller_name,
action: action_name,
ip: request.ip,
user_id: current_user&.id,
params: request.filtered_parameters,
status: response.status,
duration_ms: duration_ms
)
end
end
如果不想侵入控制器,还可以订阅ActiveSupport::Notifications的process_action.action_controller事件。Rails在处理每个控制器动作时会发布该事件,payload中包含控制器、动作、路径、请求对象和状态码。订阅器可以放在初始化文件中,适合需要向现有项目无侵入加入审计日志的场景。
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
duration_ms = ((finished - started) * 1000).round(2)
AuditLogger.log(
time: Time.now.utc.iso8601(3),
ip: payload[:request].ip,
controller: payload[:controller],
action: payload[:action],
path: payload[:path],
status: payload[:status],
duration_ms: duration_ms
)
end
无论选择哪种方式,都要确保异常请求也能被记录。使用around_action时,如果动作抛异常,yield后的记录代码不会执行,因此建议配合ensure或全局异常处理中间件,为失败请求保留审计。也可以在Rack中间件中捕获状态码500的访问,但业务操作详情仍以控制器层记录为主。
四、审计日志的存储与检索建议
审计日志最终要能被快速检索,否则问题发生时捞不出来。单机开发阶段可以直接写入按天滚动的文件,使用Ruby标准库Logger配合JSON行格式。下面的配置会按大小滚动,并统一输出UTC时间,避免多人协作时时间口径不一致。
require "logger"
file_logger = Logger.new("log/api_audit.log", 10, 10 * 1024 * 1024)
file_logger.formatter = proc do |severity, datetime, _progname, msg|
"#{datetime.utc.iso8601(3)} #{severity} #{msg}\n"
end
def write_audit_log(logger, entry)
logger.info(entry.to_json)
end
多实例部署时,文件日志需要由Filebeat、Fluentd等采集器统一汇总到Elasticsearch或ClickHouse中。结构化JSON字段天然适合建立索引,通常会对请求者IP、时间、动作和状态码建立联合查询。保留周期可以根据业务要求设定,例如高风险接口保留180天,普通接口保留30天。日志内容应只增不改,至少做到应用层不可修改。
对于写入量大、查询要求高的系统,也可以将审计日志异步批量写入数据库表。表结构至少包含id、request_ip、requested_at、action、params_json、status_code、duration_ms。但需要权衡写入延迟与批量大小,避免审计逻辑拖慢主业务。关键原则是:采集尽力而为,不能因为审计日志写入失败就让API请求失败。

