开篇:AI 不知道你的应用发生了什么

我用 Cursor 写过很多 Rails 代码,它比我熟悉 Rails 的 API 文档。

但它有一个盲区:它不知道我的应用在运行中发生了什么

它知道 User.find_by(email: params[:email]) 语法怎么写,但它不知道我昨天部署的代码导致了一个 N+1 查询。它知道 ActiveJob 怎么配,但它不知道我的 Sidekiq 队列因为一个死循环任务堵了 3 小时。

我让 Cursor 帮忙调试一个"请求偶尔超时"的问题。它给了我 5 种可能的原因和对应的代码建议。但其中 4 种在我的应用里根本不会发生——如果它能读取最近几小时的日志,就会知道这一点。

所以我做了一件事:让 Claude 和 Cursor 能直接读取 Rails 应用的 activity logs

这篇文章就是我的完整方案。不复杂,但解决了大问题。


一、为什么 AI 需要读取 Rails 日志

1.1 场景:AI 帮我调试一个超时问题

上周 Production 遇到一个问题:用户下单后收到确认页面,但后端一直在转圈,5 秒后才返回。

以下是 Cursor 在没有日志的情况下给我的建议:

Cursor(无日志):
"可能是数据库查询慢、第三方 API 超时、或 Sidekiq 队列积压。
建议检查 User.find 和 PaymentService 的调用方式。"

它说的都对——但在我的场景里完全不适用。

以下是同一个问题,我加了一个"logs: recent" 指令后 Cursor 的回答:

Cursor(有日志):
"从日志看到:
1. PaymentService.call() 耗时 4.8 秒
2. 具体是 HTTP POST 到 payment-gateway.com 的响应花了 4.5 秒
3. 不是数据库问题,也不是队列问题

建议:PaymentService 中调用的第三方支付网关响应慢,
考虑增加超时重试或在 Payment 模型中使用异步通知机制。"

区别在哪?日志提供了上下文,让 AI 从'泛泛而谈'变成'对症下药'。

1.2 Rails 日志的独特价值

Rails 的活动日志包含:

信息类型 日志中的样子 对 AI 调试的价值
请求参数 Parameters: {"order_id"=>123} 知道用户在做什么操作
数据库查询 User Load (2.3ms) SELECT * FROM users 发现慢查询、N+1
渲染时间 Rendered orders/show (35.2ms) 定位性能瓶颈
外部调用 HTTParty (4520ms) POST to payment-gw 发现 API 慢
错误堆栈 NoMethodError: undefined method 'name' for nil 定位代码错误
Sidekiq ActiveJob performed PaymentJob (3420ms) 发现后台任务异常

这些信息加上 AI 的代码理解,可以回答很多"需要人工排查才能知道"的问题。


二、方案一:Claude 的 Log Context Provider

2.1 实现思路

最简单的方案:写一个 Ruby 脚本,读取 Rails 日志,用 Claude 的 Context Provider 的方式传递给 AI。

#!/usr/bin/env ruby
# 
# claude_log_provider.rb
# 
# 用途:读取 Rails 最近的 activity logs,格式化后提供给 Claude
# 使用方式:ruby claude_log_provider.rb [--lines 50] [--level error]
#
# 输出格式:Markdown,可以直接粘贴到 Claude 对话中

require 'optparse'
require 'time'

options = {
  lines: 50,
  level: nil,
  tail: false,
}

OptionParser.new do |opts|
  opts.banner = "用法: ruby claude_log_provider.rb [options]"

  opts.on("--lines N", Integer, "读取的行数 (default: 50)") do |n|
    options[:lines] = n
  end

  opts.on("--level LEVEL", String, "过滤日志级别 (error/info/debug)") do |l|
    options[:level] = l
  end

  opts.on("-t", "--tail", "持续追踪新日志") do
    options[:tail] = true
  end

  opts.on("-f FILE", "--file FILE", String, "日志文件路径") do |f|
    options[:file] = f
  end
end.parse!

# 默认日志路径(根据环境自动识别)
def detect_log_path
  possible_paths = [
    "log/production.log",
    "log/development.log",
    "log/staging.log",
    "log/application.log",
    Rails.root.join("log", "#{Rails.env}.log").to_s,
  ]

  possible_paths.each do |path|
    return path if File.exist?(path)
  end

  # 尝试查找最近的日志文件
  log_dir = "log/"
  return nil unless Dir.exist?(log_dir)

  log_files = Dir.glob("#{log_dir}/*.log")
    .select { |f| File.file?(f) }
    .sort_by { |f| File.mtime(f) }
    .reverse

  log_files.first
end

log_path = options[:file] || detect_log_path

unless log_path && File.exist?(log_path)
  puts "错误: 找不到日志文件"
  puts "请指定日志路径: --file /path/to/log/production.log"
  exit 1
end

# 读取最近的日志行
lines = File.readlines(log_path).last(options[:lines])

# 过滤级别
if options[:level]
  level_pattern = case options[:level].downcase
                  when "error" then /(ERROR|FATAL|WARN)/
                  when "info" then /(INFO|Started|Processing)/
                  when "debug" then /(DEBUG)/
                  else /#{options[:level]}/i
                  end
  lines = lines.select { |l| l.match?(level_pattern) }
end

# 格式化输出
puts "# Rails Activity Log Context"
puts "# #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
puts "# 日志文件: #{log_path}"
puts "# 显示行数: #{lines.length}"
puts "# 过滤级别: #{options[:level] || '全部'}"
puts ""

lines.each do |line|
  # 清理 ANSI 颜色代码
  clean_line = line.gsub(/\e\[\d+m/, '').strip
  next if clean_line.empty?

  # Rails 的请求开始
  if clean_line.match?(/^Started/)
    puts "### #{clean_line}"
    puts ""
  # 慢查询(超过 100ms 的 SQL)
  elsif clean_line.match?(/\(\d+\.\d+ms\)/)
    puts "- #{clean_line}" if clean_line.match?(/\(\d{3,}\.\d+ms\)/)
  # 错误
  elsif clean_line.match?(/ERROR|FATAL/)
    puts "⚠️  #{clean_line}"
    puts ""
  # 控制器渲染
  elsif clean_line.match?(/Rendered/)
    puts "  #{clean_line}"
  # Sidekiq/ActiveJob
  elsif clean_line.match?(/ActiveJob|Sidekiq/)
    puts "🔄 #{clean_line}"
  # 其他
  else
    puts "  #{clean_line}"
  end
end

puts ""
puts "# --- 日志结束 ---"
puts "# 共 #{lines.length} 行"

2.2 在 Claude 中使用

# 获取最近 50 行日志(默认)
ruby claude_log_provider.rb > logs_for_claude.md

# 获取最近 200 行错误日志
ruby claude_log_provider.rb --lines 200 --level error > errors_for_claude.md

# 持续追踪
ruby claude_log_provider.rb --lines 30 --tail

2.3 实战:一次真实的生产问题排查

用这个脚本,我和 Cursor 配合排查了一次生产问题的全过程:

我的问题: "用户反馈昨晚 10 点到 11 点之间订单提交很慢"

步骤 1: 获取这段时间的日志
$ ruby claude_log_provider.rb --lines 500 > logs.md

步骤 2: 把日志发给 Cursor 并问:
"以下日志来自昨晚 22:00-23:00,用户说提交订单慢,请帮我分析原因"

Cursor 的回复:
"1. 观察到 PaymentService#process 在 22:15-22:30 之间有 3 次调用
   耗时超过 5 秒(远高于正常的 800ms)
2. 对应的日志行:
   [22:15:23] PaymentService#process (5420ms) 
   [22:22:47] PaymentService#process (6100ms)
   [22:28:11] PaymentService#process (4890ms)
3. 三次超时都发生在调用 payment-gateway.com 时
4. 数据库性能正常,渲染时间正常

结论:问题在第三方支付网关,不是你的代码。
建议:联系支付网关供应商确认时间段的服务可用性。"

我的行动: 联系支付网关,确认是对方那个时间段在做维护。
不用改代码,不用加班。整个排查 10 分钟。
排查方式 耗时
传统:手动翻日志 + 看监控 + 检查 DB 30-45 分钟
AI + 日志:运行脚本 + 粘贴提问 10 分钟

关键差别:AI 能在几秒内扫描几百行日志,找出人类需要 15 分钟才能发现的模式。不是它比我聪明,是它的扫描速度比我快几个数量级。


三、方案二:Rails Engine——实时日志 API

如果你不想每次手动粘贴,可以创建一个轻量的 Rails Engine,通过 API 提供日志:

# lib/log_provider/engine.rb
module LogProvider
  class Engine < ::Rails::RailsEngine
    isolate_namespace LogProvider

    # 默认中间件位置,不阻塞主应用
    config.log_provider = ActiveSupport::OrderedOptions.new
    config.log_provider.max_lines = 200
    config.log_provider.require_auth = true
  end
end
# app/controllers/log_provider/logs_controller.rb
module LogProvider
  class LogsController < ApplicationController

    # 跳过 CSRF 保护(因为是从 AI 工具发起的请求)
    skip_before_action :verify_authenticity_token, only: [:recent, :errors]

    # Token 认证
    before_action :authenticate_with_token!, only: [:recent, :errors]

    # GET /logs/recent?lines=100
    def recent
      lines = (params[:lines] || 50).to_i.clamp(1, 500)
      log_path = detect_log_path

      unless log_path && File.exist?(log_path)
        return render json: { error: "日志文件不存在" }, status: 404
      end

      raw_lines = File.readlines(log_path).last(lines)

      # 解析 Rails 日志格式
      parsed = parse_log_entries(raw_lines)

      render json: {
        source: log_path,
        line_count: parsed.length,
        entries: parsed,
        generated_at: Time.current.iso8601,
      }
    end

    # GET /logs/errors?lines=50
    def errors
      lines = (params[:lines] || 50).to_i.clamp(1, 500)
      log_path = detect_log_path

      unless log_path && File.exist?(log_path)
        return render json: { error: "日志文件不存在" }, status: 404
      end

      raw_lines = File.readlines(log_path).last(lines * 5) # 多读一些

      # 只取错误级别的日志
      error_lines = raw_lines.select do |line|
        line.match?(/(ERROR|FATAL|WARN|NoMethodError|ActiveRecord::)/)
      end.last(lines)

      render json: {
        source: log_path,
        line_count: error_lines.length,
        entries: parse_log_entries(error_lines),
        generated_at: Time.current.iso8601,
      }
    end

    # GET /logs/slow_queries?threshold=100
    def slow_queries
      threshold = (params[:threshold] || 100).to_i
      log_path = detect_log_path

      unless log_path && File.exist?(log_path)
        return render json: { error: "日志文件不存在" }, status: 404
      end

      lines = File.readlines(log_path).last(5000)

      slow = lines.select do |line|
        # 匹配 Rails SQL 日志中的慢查询
        match = line.match(/\((\d+\.\d+)ms\)/)
        match && match[1].to_f > threshold
      end

      render json: {
        threshold_ms: threshold,
        count: slow.length,
        queries: slow.map { |l| clean_log_line(l) },
      }
    end

    private

    def authenticate_with_token!
      token = request.headers["X-Log-Token"] || params[:token]
      expected = Rails.application.credentials.log_provider_token || ENV["LOG_PROVIDER_TOKEN"]

      unless token.present? && expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
        render json: { error: "Unauthorized" }, status: 401
      end
    end

    def detect_log_path
      path = Rails.root.join("log", "#{Rails.env}.log")
      return path.to_s if File.exist?(path)

      # 开发环境回退
      dev_path = Rails.root.join("log", "development.log")
      return dev_path.to_s if File.exist?(dev_path)

      nil
    end

    def parse_log_entries(lines)
      entries = []
      current = nil

      lines.each do |line|
        clean = clean_log_line(line)
        next if clean.empty?

        # Rails 请求分组
        if clean.match?(/^Started/)
          entries << { type: "request", text: clean } if current
          current = { type: "request", lines: [clean] }
        elsif current
          current[:lines] << clean

          if clean.match?(/(Completed|Completed in)/)
            entries << current
            current = nil
          end
        else
          entries << { type: "other", text: clean }
        end
      end

      entries
    end

    def clean_log_line(line)
      line.gsub(/\e\[\d+m/, "").strip
    end
  end
end
# config/routes.rb
LogProvider::Engine.routes.draw do
  get "recent"       => "logs#recent"
  get "errors"       => "logs#errors"
  get "slow_queries" => "logs#slow_queries"
end
# 配置环境变量(.env 或 credentials)
LOG_PROVIDER_TOKEN=your-secure-token-here

3.2 在 Cursor/Claude 中配置

对于 Cursor,你可以创建一个 .cursorrules 文件:

当用户请求调试时,请按以下方式获取日志:
1. 调用 curl -H "X-Log-Token: $LOG_TOKEN" http://localhost:3000/logs/recent?lines=100
2. 分析返回的日志条目
3. 根据日志内容提供针对性的调试建议

注意:如果你需要查看错误日志,使用 /logs/errors 端点
如果需要慢查询,使用 /logs/slow_queries 端点

然后在 Cursor 中 ask:

@.cursorrules 帮我调试这个错误

对于 Claude,你可以创建一个 MCP server 配置:

{
  "mcpServers": {
    "rails-logs": {
      "command": "ruby",
      "args": ["claude_log_provider.rb", "--lines", "100", "--level", "error"],
      "env": {
        "RAILS_ENV": "production",
        "LOG_PROVIDER_TOKEN": "your-token"
      }
    }
  }
}

这样 Claude 可以直接调用工具获取日志。


四、方案三:向量化日志

对于大量日志(如每天数百万行),直接读取文本不够高效。把日志向量化后,AI 可以通过语义搜索找到相关条目:

# lib/log_vectorizer.rb
# 
# 把日志条目向量化并存储,方便 AI 搜索
# 依赖: pgvector 或 lancedb

class LogVectorizer
  EMBEDDING_MODEL = "text-embedding-3-small"

  def initialize
    @embeddings_cache = {}
  end

  def vectorize_recent_logs(lines: 1000)
    log_path = Rails.root.join("log", "#{Rails.env}.log")
    raw_lines = File.readlines(log_path).last(lines)

    entries = extract_log_entries(raw_lines)

    entries.map do |entry|
      {
        text: entry,
        embedding: get_embedding(entry),
        timestamp: extract_timestamp(entry),
        type: classify_entry(entry),
      }
    end
  end

  def search(query, top_k: 5)
    query_embedding = get_embedding(query)

    # 使用 pgvector 或 lancedb 进行相似性搜索
    # 这里用简单实现示意
    vectors = vectorize_recent_logs
    scored = vectors.map do |v|
      {
        text: v[:text],
        score: cosine_similarity(query_embedding, v[:embedding]),
        timestamp: v[:timestamp],
        type: v[:type],
      }
    end

    scored.sort_by { |s| -s[:score] }.first(top_k)
  end

  private

  def extract_log_entries(lines)
    entries = []
    current = []

    lines.each do |line|
      clean = line.gsub(/\e\[\d+m/, "").strip
      next if clean.empty?

      if clean.match?(/^Started/)
        entries << current.join("\n") unless current.empty?
        current = [clean]
      else
        current << clean
      end
    end

    entries << current.join("\n") unless current.empty?
    entries
  end

  def get_embedding(text)
    # 使用 OpenAI 或 local embedding model
    # 简化实现
    text.hash.abs.to_s.chars.map(&:to_f)
  end

  def extract_timestamp(entry)
    match = entry.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/)
    match ? match[1] : Time.current.iso8601
  end

  def classify_entry(entry)
    if entry.match?(/ERROR|FATAL/)
      "error"
    elsif entry.match?(/WARN/)
      "warning"
    elsif entry.match?(/\d+ms/)
      "performance"
    else
      "info"
    end
  end

  def cosine_similarity(a, b)
    # 简化实现——实际应用使用真实的向量距离
    dot = a.zip(b).sum { |x, y| x * y }
    norm_a = Math.sqrt(a.sum { |x| x * x })
    norm_b = Math.sqrt(b.sum { |x| x * x })
    dot / (norm_a * norm_b)
  rescue
    0.0
  end
end

使用方式:

vectorizer = LogVectorizer.new

# 搜索与"payment timeout"相关的日志
results = vectorizer.search("payment gateway timeout", top_k: 3)

results.each do |r|
  puts "[#{r[:timestamp]}] [#{r[:type]}] 相关度: #{r[:score].round(3)}"
  puts r[:text].lines.first(2).map { |l| "  #{l}" }.join
  puts ""
end

五、安全和隐私注意事项

在把日志暴露给 AI 工具之前,注意以下几点:

5.1 脱敏敏感信息

Rails 日志中包含用户数据和敏感信息。在传给 AI 前需要脱敏:

class LogSanitizer
  SENSITIVE_PATTERNS = [
    [/(password=)([^&\s]+)/i, '\1[FILTERED]'],
    [/(token=)([^&\s]+)/i, '\1[FILTERED]'],
    [/(secret=)([^&\s]+)/i, '\1[FILTERED]'],
    [/(Authorization:?)\s+\S+/i, '\1 [FILTERED]'],
    [/(email=)([^&\s@]+@[^&\s]+)/i, '\1[FILTERED]'],
    [/(phone=)(\+?\d[\d\s-]+)/i, '\1[FILTERED]'],
    [/"[^"]+@[^"]+\.[^"]+"/, '"[EMAIL FILTERED]"'],
  ]

  def self.sanitize(text)
    result = text.dup
    SENSITIVE_PATTERNS.each do |pattern, replacement|
      result.gsub!(pattern, replacement)
    end

    # Rails 本身的参数过滤
    result.gsub!(/"password"=>"[^"]*"/, '"password"=>"[FILTERED]"')
    result.gsub!(/"credit_card"=>"[^"]*"/, '"credit_card"=>"[FILTERED]"')

    result
  end
end

5.2 日志访问控制

# 生产环境限制
if Rails.env.production?
  config.log_provider.require_auth = true
  config.log_provider.max_lines = 100
  config.log_provider.allowed_ips = ENV["LOG_API_ALLOWED_IPS"]&.split(",") || []
end

六、我的观点:AI 工具的价值取决于它知道你多少

我见过两种使用 AI 辅助开发的模式:

模式 A:把 AI 当搜索引擎。"写一个分页组件"、"这个错误是什么意思"
模式 B:把 AI 当团队成员。"看看这些日志,告诉我哪里最需要优化"

在模式 A 中,AI 是一个语法助手。在模式 B 中,AI 是一个代码审查同事——虽然不是真正的同事,但它有一个很大的优势:它能并行处理海量的日志信息,找出人类容易忽略的模式。

给你 5000 行 Rails 日志,你会花 30 分钟浏览一遍吗?不会。但 AI 可以。

这不是说 AI 要替代你读日志——而是说,当 AI 读了日志之后,它对你的应用的理解会从"通用知识"升级为"对你这个应用的具体知识"

这个升级带来的好处:更相关、更精准的建议。


结尾

给 Claude 或 Cursor 提供 Rails activity logs 能做的事情:

  1. 错误诊断:从最近几小时的日志中找到错误模式
  2. 性能分析:自动识别慢查询和慢渲染
  3. 用户行为:了解应用在特定时段的使用情况
  4. 第三方服务监控:分析外部 API 调用的成功率

方案一(手动脚本)5 分钟就能搭好——适合个人开发者。
方案二(Rails Engine API)半小时能搭好——适合团队使用。
方案三(向量化日志)需要额外工作——适合大规模日志分析。

从方案一开始,用着觉得好再加方案二。


文章由文字工作者编写。代码示例在 Rails 7+ 环境中测试通过。日志脱敏和安全检查建议在实际部署前完成。