module AirbrakeTools

Constants

COLORS
DEFAULT_COMPARE_DEPTH_ADDITION
DEFAULT_ENVIRONMENT
DEFAULT_HOT_PAGES
DEFAULT_LIST_PAGES
DEFAULT_NEW_PAGES
DEFAULT_SUMMARY_PAGES
HOUR
PER_PAGE
VERSION

Public Class Methods

cli(argv) click to toggle source
# File lib/airbrake_tools.rb, line 26
def cli(argv)
  options = extract_options(argv)

  # TODO get rid of argument 0
  @token = ARGV[1]
  if @token.to_s.empty?
    puts "Usage instructions: airbrake-tools --help"
    return 1
  end

  options[:project_id] = project_id(options.delete(:project_name)) if options[:project_name]

  case ARGV[2]
  when "hot"
    print_errors(hot(options))
  when "list"
    list(options)
  when "summary"
    summary(ARGV[3] || raise("Need error id"), options)
  when "new"
    print_errors(new(options))
  when "open"
    open(ARGV[3] || raise("Need error id"), ARGV[4])
  else
    raise "Unknown command #{ARGV[2].inspect} try hot/new/list/summary/open"
  end
  return 0
end
errors_with_notices(options) click to toggle source
# File lib/airbrake_tools.rb, line 68
def errors_with_notices(options)
  add_notices_to_pages(options.fetch(:project_id), errors_from_pages(options))
end
hot(options = {}) click to toggle source
# File lib/airbrake_tools.rb, line 55
def hot(options = {})
  errors = Array(options[:project_id] || projects.map(&:id)).flat_map do |project_id|
    errors_with_notices({pages: DEFAULT_HOT_PAGES, project_id: project_id}.merge(options))
  end
  errors.sort_by{|_,_,f| f }.reverse[0...PER_PAGE]
end
list(options) click to toggle source
# File lib/airbrake_tools.rb, line 72
def list(options)
  need_project_id!(options)
  list_pages = (options[:pages] ? options[:pages] : DEFAULT_LIST_PAGES)
  page = 1
  while page <= list_pages && errors = airbrake_errors(options.fetch(:project_id), page, options)
    errors.each do |error|
      puts "#{error.id} -- #{error.error_class} -- #{error.error_message} -- #{error.created_at}"
    end
    $stderr.puts "Page #{page} ----------\n"
    page += 1
  end
end
new(options = {}) click to toggle source
# File lib/airbrake_tools.rb, line 62
def new(options = {})
  need_project_id!(options)
  errors = errors_with_notices({:pages => DEFAULT_NEW_PAGES}.merge(options))
  errors.sort_by{|e,_,_| e.created_at }.reverse
end
open(error_id, notice_id=nil) click to toggle source
# File lib/airbrake_tools.rb, line 110
def open(error_id, notice_id=nil)
  require "launchy"
  error = AirbrakeAPI.error(error_id)
  raise URI::InvalidURIError if error.nil?

  url = "https://#{AirbrakeAPI.account}.airbrake.io/projects/#{error.project_id}/groups/#{error_id}"
  url += "/notices/#{notice_id}" if notice_id
  Launchy.open url
rescue URI::InvalidURIError
  puts "Error id does not map to any error on Airbrake"
end
summary(error_id, options) click to toggle source
# File lib/airbrake_tools.rb, line 85
def summary(error_id, options)
  notices = notices_from_pages(options.fetch(:project_id), error_id, options[:pages] || DEFAULT_SUMMARY_PAGES)

  puts "last retrieved notice: #{((Time.now - notices.last.created_at) / (60 * 60)).round} hours ago at #{notices.last.created_at}"
  puts "last 2 hours:  #{sparkline(notices, :slots => 60, :interval => 120)}"
  puts "last day:      #{sparkline(notices, :slots => 24, :interval => 60 * 60)}"

  grouped_backtraces(notices, options).sort_by{|_,notices| notices.size }.reverse.each_with_index do |(backtrace, notices), index|
    puts "Trace #{index + 1}: occurred #{notices.size} times e.g. #{notices[0..5].map(&:id).join(", ")}"
    puts notices.first.error_message
    puts backtrace.map{|line| present_line(line) }
    puts ""
  end

  if options[:params]
    puts "Parameters:"
    notices.each do |notice|
      # Print each set of parameters with a stable output order.
      params = notice.request.params || {}
      ordered_params = params.sort.map{|k,v| "#{k.inspect}=>#{v.inspect}"}.join(", ")
      puts "#{notice.uuid.inspect}=>{#{ordered_params}}"
    end
  end
end

Private Class Methods

add_blame(backtrace_line) click to toggle source
# File lib/airbrake_tools.rb, line 136
def add_blame(backtrace_line)
  file, line = backtrace_line.split(":", 2)
  line = line.to_i
  if not file.start_with?("/") and line > 0 and File.exist?(".git") and File.exist?(file)
    result = `git blame #{file} -L #{line},#{line} --show-email -w 2>&1`
    if $?.success?
      result.sub!(/ #{line}\) .*/, " )") # cut of source code
      backtrace_line += " -- #{result.strip}"
    end
  end
  backtrace_line
end
add_notices_to_pages(project_id, errors) click to toggle source
# File lib/airbrake_tools.rb, line 176
def add_notices_to_pages(project_id, errors)
  Parallel.map(errors, :in_threads => 10) do |error|
    begin
      pages = 1
      notices = notices_from_pages(project_id, error.id, pages).compact
      print "."
      [error, notices, frequency(notices, pages * PER_PAGE)]
    rescue Exception => e
      puts "Ignoring exception <<#{e}>>, most likely bad data from airbrake"
    end
  end.compact
end
airbrake_errors(project_id, page, options) click to toggle source
# File lib/airbrake_tools.rb, line 301
def airbrake_errors(project_id, page, options)
  response = make_request("https://airbrake.io/api/v3/projects/#{project_id}/groups?key=#{@token}&page=#{page}&environment=#{options[:env] || DEFAULT_ENVIRONMENT}&resolved=false")
  case response.code.to_i
  when 200..299
    JSON.parse(response.body)["groups"].compact.map do |raw|
      OpenStruct.new(
        :id            => raw["id"].to_s,
        :project_id    => raw["projectId"].to_s,
        :env           => raw["environment"],
        :count         => raw["noticeCount"],
        :created_at    => Time.parse(raw["createdAt"]),
        :most_recent   => Time.parse(raw["lastNoticeAt"]),
        :error_message => raw["errors"][0]["message"].to_s,
        :error_class   => raw["errors"][0]["type"].to_s
      )
    end
  else
    puts "ERROR - Bad response for http://airbrake.io/api/v3/projects/#{project_id}/groups - #{response.code} - #{response.message}"
  end
end
airbrake_notices(project_id, error_id, page=1) click to toggle source
# File lib/airbrake_tools.rb, line 322
def airbrake_notices(project_id, error_id, page=1)
  response = make_request("https://airbrake.io/api/v3/projects/#{project_id}/groups/#{error_id}/notices?key=#{@token}&page=#{page}")
  case response.code.to_i
  when 200..299
    JSON.parse(response.body)["notices"].compact.map do |raw|
      OpenStruct.new(
        :id            => raw["id"].to_s,
        :created_at    => Time.parse(raw["createdAt"]),
        :message       => raw["errors"][0]["message"].to_s,
        :backtrace     => (raw["errors"].first['backtrace'] || []).map { |l| "#{l["file"]}:#{l["line"]}" }
      )
    end
  else
    raise "ERROR - Bad response for http://airbrake.io/api/v3/projects/#{project_id}/groups/#{error_id}/notices - #{response.code} - #{response.message}"
  end
end
average_first_project_line(backtraces) click to toggle source
# File lib/airbrake_tools.rb, line 164
def average_first_project_line(backtraces)
  depths = backtraces.map do |backtrace|
    backtrace.index { |line| custom_file?(line) }
  end.compact
  return 0 if depths.size == 0
  depths.inject(:+) / depths.size
end
color_text(text, color) click to toggle source
# File lib/airbrake_tools.rb, line 229
def color_text(text, color)
  "#{COLORS[color]}#{text}#{COLORS[:clear]}"
end
custom_file?(line) click to toggle source
# File lib/airbrake_tools.rb, line 172
def custom_file?(line)
  line.start_with?("[PROJECT_ROOT]") && !line.start_with?("[PROJECT_ROOT]/vendor/")
end
errors_from_pages(options) click to toggle source
# File lib/airbrake_tools.rb, line 189
def errors_from_pages(options)
  errors = []
  options[:pages].times do |i|
    errors.concat(airbrake_errors(options[:project_id], i+1, options))
  end
  errors
end
extract_options(argv) click to toggle source
# File lib/airbrake_tools.rb, line 233
    def extract_options(argv)
      options = {}
      OptionParser.new do |opts|
        opts.banner = <<-BANNER.gsub(" "*12, "")
            Power tools for airbrake.

            hot: list hottest errors
            list: list errors 1-by-1 so you can e.g. grep -> search
            summary: analyze occurrences and backtrace origins
            open: opens specified error in your default browser

            Usage:
                airbrake-tools subdomain auth-token command [options]
                  auth-token: go to airbrake -> settings, copy your auth-token

            Options:
        BANNER
        opts.on("-c NUM", "--compare-depth NUM", Integer, "How deep to compare backtraces in summary (default: first line in project + #{DEFAULT_COMPARE_DEPTH_ADDITION})") {|s| options[:compare_depth] = s }
        opts.on("-p NUM", "--pages NUM", Integer, "How maybe pages to iterate over (default: hot:#{DEFAULT_HOT_PAGES} new:#{DEFAULT_NEW_PAGES} summary:#{DEFAULT_SUMMARY_PAGES})") {|s| options[:pages] = s }
        opts.on("-e ENV", "--environment ENV", String, "Only show errors from this environment (default: #{DEFAULT_ENVIRONMENT})") {|s| options[:env] = s }
        opts.on("--project NAME_OR_ID", String, "Name of project to fetch errors for") {|p| options[:project_name] = p }
        opts.on("-h", "--help", "Show this.") { puts opts; exit }
        opts.on("-v", "--version", "Show Version"){ puts "airbrake-tools #{VERSION}"; exit }
        opts.on("--params", "Show params for summary.") { options[:params] = true }
      end.parse!(argv)
      options
    end
frequency(notices, expected_notices) click to toggle source

we only have a limited sample size, so we do not know how many errors occurred in total

# File lib/airbrake_tools.rb, line 214
def frequency(notices, expected_notices)
  return 0 if notices.empty?
  range = if notices.size < expected_notices && notices.last.created_at > (Time.now - HOUR)
    HOUR # we got less notices then we wanted -> very few errors -> low frequency
  else
    Time.now - notices.map{ |n| n.created_at }.min
  end
  errors_per_second = notices.size / range.to_f
  (errors_per_second * HOUR).round(2) # errors_per_hour
end
grouped_backtraces(notices, options) click to toggle source
# File lib/airbrake_tools.rb, line 149
def grouped_backtraces(notices, options)
  notices = notices.compact.select { |n| n.backtrace.any? }

  compare_depth = if options[:compare_depth]
    options[:compare_depth]
  else
    average_first_project_line(notices.map { |n| n.backtrace }) +
      DEFAULT_COMPARE_DEPTH_ADDITION
  end

  notices.group_by do |notice|
    notice.backtrace[0..compare_depth]
  end
end
hot_summary(error) click to toggle source
# File lib/airbrake_tools.rb, line 225
def hot_summary(error)
  "id: #{color_text(error.id, :bold)} -- first: #{color_text(error.created_at, :bold)} -- #{error.error_message}"
end
make_request(url) click to toggle source
# File lib/airbrake_tools.rb, line 339
def make_request(url)
  # stolen from https://github.com/bf4/airbrake_client/blob/master/airbrake_client.rb
  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  if http.use_ssl = (uri.scheme == 'https')
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  end
  request = Net::HTTP::Get.new(uri.request_uri)
  http.request(request)
end
need_project_id!(options) click to toggle source
# File lib/airbrake_tools.rb, line 124
def need_project_id!(options)
  raise "Need a project_id" unless options[:project_id]
end
notices_from_pages(project_id, error_id, pages) click to toggle source
# File lib/airbrake_tools.rb, line 197
def notices_from_pages(project_id, error_id, pages)
  notices = []
  pages.times do |i|
    notices.concat(airbrake_notices(project_id, error_id, i+1))
  end
  notices
end
present_line(line) click to toggle source
# File lib/airbrake_tools.rb, line 128
def present_line(line)
  color = :gray if $stdout.tty? && !custom_file?(line)
  line = line.sub("[PROJECT_ROOT]/", "")
  line = add_blame(line)

  color ? color_text(line, color) : line
end
print_errors(hot) click to toggle source
project_id(project_name) click to toggle source
# File lib/airbrake_tools.rb, line 277
def project_id(project_name)
  return project_name.to_i if project_name =~ /^\d+$/
  project = projects.detect { |p| p.name == project_name }
  raise "project with name #{project_name} not found try #{projects.map(&:name).join(", ")}" unless project
  project.id
end
projects() click to toggle source
# File lib/airbrake_tools.rb, line 284
def projects
  @projects ||= begin
    response = make_request("https://airbrake.io/api/v3/projects?key=#{@token}")
    case response.code.to_i
    when 200..299
      JSON.parse(response.body)["projects"].compact.map do |raw|
        OpenStruct.new(
          :id   => raw["id"].to_s,
          :name => raw["name"]
        )
      end.sort_by{|p| p[:name].to_s.downcase }
    else
      raise "ERROR - Bad response for http://airbrake.io/api/v3/projects - #{response.code} - #{response.message}"
    end
  end
end
sparkline(notices, options) click to toggle source
# File lib/airbrake_tools.rb, line 273
def sparkline(notices, options)
  `#{File.expand_path('../../spark.sh',__FILE__)} #{sparkline_data(notices, options).join(" ")}`.strip
end
sparkline_data(notices, options) click to toggle source
# File lib/airbrake_tools.rb, line 261
def sparkline_data(notices, options)
  last = notices.last.created_at
  now = notices.map(&:created_at).push(Time.now).max # adjust now if airbrakes clock is going too fast

  Array.new(options[:slots]).each_with_index.map do |_, i|
    slot_end = now - (i * options[:interval])
    slot_start = slot_end - 1 * options[:interval]
    next if last > slot_end # do not show empty lines when we actually have no data
    notices.select { |n| n.created_at.between?(slot_start, slot_end) }.size
  end
end