Class: RailsAiContext::Tools::ReadLogs
- Defined in:
- lib/rails_ai_context/tools/read_logs.rb
Constant Summary collapse
- MAX_READ_BYTES =
1MB
1_048_576- MAX_LINES =
500- LEVEL_HIERARCHY =
{ "DEBUG" => 0, "INFO" => 1, "WARN" => 2, "ERROR" => 3, "FATAL" => 4 }.freeze
- ANSI_ESCAPE =
/\e\[[0-9;]*[mGKHF]/- REDACT_PATTERNS =
[ /(?<=password=)\S+/i, /(?<=password:\s)\S+/i, /("password":\s*")[^"]+(")/i, /("password"=>")[^"]+(")/i, /(?<=token=)\S+/i, /(?<=token:\s)\S+/i, /(?<=secret=)\S+/i, /(?<=secret:\s)\S+/i, /(?<=api_key=)\S+/i, /(?<=api_key:\s)\S+/i, /(?<=authorization:\s)(Bearer\s)?\S+/i, /(SECRET|PRIVATE|SIGNING|ENCRYPTION)[_A-Z]*=\S+/i, /(?<=cookie:\s)\S+/i, /(?<=session_id=)\S+/i, /(?<=_session=)\S+/i, /\bAKIA[0-9A-Z]{16}\b/, # AWS access key IDs /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, # JWT tokens /-----BEGIN\s+(RSA|DSA|EC|OPENSSH)?\s*PRIVATE KEY-----/, # SSH/TLS private keys /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i, /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, # Stripe secret keys /\brk_(?:live|test)_[A-Za-z0-9]{10,}\b/, # Stripe restricted keys /\bSG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{10,}\b/, # SendGrid API keys /\bxox[bpras]-[A-Za-z0-9-]{10,}\b/, # Slack tokens /\bghp_[A-Za-z0-9]{36,}\b/, # GitHub personal access tokens /\bghu_[A-Za-z0-9]{36,}\b/, # GitHub user-to-server tokens /\bghs_[A-Za-z0-9]{36,}\b/, # GitHub server-to-server tokens /\bglpat-[A-Za-z0-9_-]{20,}\b/, # GitLab personal access tokens /\bnpm_[A-Za-z0-9]{36,}\b/ # npm tokens ].freeze
- DOTENV_PATTERN =
Lines that reveal env var names (dotenv, Figaro, etc.)
/\[dotenv\]\s+Set\s+.*/i- ENV_VAR_LINE_PATTERN =
Match ALL_CAPS env var assignments containing sensitive words
/\b[A-Z][A-Z0-9_]*(SECRET|KEY|TOKEN|PASSWORD|API|CREDENTIAL)[A-Z0-9_]*=\S+/
Constants inherited from BaseTool
BaseTool::SESSION_CONTEXT, BaseTool::SHARED_CACHE
Class Method Summary collapse
Methods inherited from BaseTool
abstract!, abstract?, cache_key, cached_context, config, extract_method_source_from_file, extract_method_source_from_string, find_closest_match, fuzzy_find_key, inherited, not_found_response, paginate, rails_app, registered_tools, reset_all_caches!, reset_cache!, session_queries, session_record, session_reset!, set_call_params, text_response
Class Method Details
.call(lines: nil, level: "all", file: nil, search: nil, server_context: nil, **_extra) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/rails_ai_context/tools/read_logs.rb', line 78 def self.call(lines: nil, level: "all", file: nil, search: nil, server_context: nil, **_extra) warnings = [] # Normalize and validate lines original_lines = lines lines = (lines || config.log_lines).to_i if original_lines int_val = original_lines.to_i if int_val < 1 lines = 1 warnings << "lines must be >= 1, using 1" elsif int_val > MAX_LINES lines = MAX_LINES warnings << "lines clamped to #{MAX_LINES} (was #{int_val})" else lines = int_val end end # Validate level level = level.to_s.strip.upcase level = "all" if level.empty? valid_levels = LEVEL_HIERARCHY.keys + [ "ALL" ] unless valid_levels.include?(level.upcase) return text_response("Unknown level: '#{level}'. Valid values: #{valid_levels.join(', ')}") end level = level == "ALL" ? "all" : level # Resolve log file path = resolve_log_file(file) available = available_log_files unless path msg = if available.any? "Log file '#{file || "#{Rails.env}.log"}' not found.\nAvailable log files: #{available.join(', ')}" else "No log files found in log/. Your app may log to stdout (common in Docker/container environments)." end return text_response(msg) end # Tail the file raw_lines = tail_file(path, lines) if raw_lines.empty? return text_response("# Log: #{File.basename(path)}\nLog file is empty.\n\n---\nAvailable log files: #{available.join(', ')}") end # Detect format and filter by level format = detect_format(raw_lines) filtered = filter_by_level(raw_lines, level, format) # Apply search filter if search && !search.strip.empty? search_term = search.strip filtered = filtered.select { |line| line.downcase.include?(search_term.downcase) } end if filtered.empty? return text_response("# Log: #{File.basename(path)}\nNo entries matching level:#{level}#{" search:\"#{search}\"" if search}.\n\n---\nAvailable log files: #{available.join(', ')}") end # Redact sensitive data redacted = filtered.map { |line| redact(line) } # Format output file_size = File.size(path) size_label = if file_size > 1_000_000 "#{(file_size / 1_000_000.0).round(1)} MB" elsif file_size > 1_000 "#{(file_size / 1_000.0).round(1)} KB" else "#{file_size} B" end level_label = level == "all" ? "all levels" : "#{level}+" output = [ "# Log: #{File.basename(path)}" ] output << "Size: #{size_label} | Showing last #{redacted.size} lines | Level: #{level_label}" warnings.each { |w| output << "**Warning:** #{w}" } if warnings.any? output << "" output << "```" output.concat(redacted) output << "```" output << "" output << "---" output << "Available log files: #{available.join(', ')}" text_response(output.join("\n")) end |