Skip to content

Instantly share code, notes, and snippets.

@leandro
Created January 30, 2026 17:15
Show Gist options
  • Select an option

  • Save leandro/69ac2200524062244646a7cb65cd2912 to your computer and use it in GitHub Desktop.

Select an option

Save leandro/69ac2200524062244646a7cb65cd2912 to your computer and use it in GitHub Desktop.
Retrieve ALL the Sentry error events for a specific Sentry Issue
#!/usr/bin/env ruby
# -- Usage:
#
# SENTRY_AUTH_TOKEN=api_tokken ./sentry-retrieve-all-issue-events.rb issue_id [full_data, [verbose]]
#
# -- Program arguments:
#
# @issue_id The Sentry issue id number.
#
# @full_data Whether fetching full data from issue events or not.
# Possible values: 1, t, true, or yes OR 0, f, false or no.
# Default value: true
#
# @verbose Whether printing to STDOUT each paginated request being performed or not.
# Possible values: 1, t, true, or yes OR 0, f, false or no
# Default value: false
#
# -- ENV Vars:
#
# SENTRY_AUTH_TOKEN You Sentry API token that will be used to perform the Sentry API requests.
#
# -- Output:
#
# The output to STDOUT will be a JSON response of all the Sentry issue events. Don't forget that
# when verbose=true, the JSON output will be preceded by the request status lines.
#
# -- Observations:
#
# * Once you copy the content of this file into a file on your computer, don't forget to run the
# command `chmod +x sentry-retrieve-all-issue-events.rb`, sou you can run the script as a program
# call on your terminal.
#
require 'uri'
require 'net/http'
require 'rack/utils'
require 'json'
ENTRYPOINT_BASE = 'https://sentry.io/api/0'.freeze
API_AUTH_TOKEN = ENV['SENTRY_AUTH_TOKEN'].freeze
# INI.extensions
module ObjectExtension
def instance_variable_get_or_set(variable, &value_getter)
var = variable.start_with?('@') ? variable : :"@#{variable}"
return instance_variable_get(var) if instance_variable_defined?(var)
instance_variable_set(var, value_getter&.call)
end
end
Object.include(ObjectExtension)
module HashExtension
def symbolize_keys = transform_keys { _1.to_s.to_sym }
end
Hash.include(HashExtension)
# END.extensions
Request = Data.define(:path, :payload, :verb, :response) do
def initialize(path:, payload: {}, verb: :get, response: nil)
path = "#{path}/" unless path.end_with?('/')
super
end
end
Response = Data.define(:result, :data, :request) do
def initialize(result:, data: nil, request: nil) = super
def results? = !data.nil? && !data.empty?
end
class ApiRequester
class << self
def make_request(path, payload: {}, verb: :get)
request = request_object_for(path, payload:, verb:)
verb, path, payload = request.to_h.values_at(:verb, :path, :payload)
uri = URI.parse(File.join(ENTRYPOINT_BASE, path, ''))
uri.query = URI.encode_www_form(payload) if verb == :get && payload.any?
class_name = :"#{verb[0].upcase}#{verb[1..]}"
req = Net::HTTP.const_get(class_name).new(uri)
if verb != :get && payload.any?
req.body = payload.to_json
req.content_type = 'application/json'
end
req['Authorization'] = "Bearer #{API_AUTH_TOKEN}"
result = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
response_object_for(result, request)
end
private
def request_object_for(path, payload: {}, verb: :get)
Request.new(path:, payload: payload.symbolize_keys, verb: verb.to_s.downcase.to_sym)
end
def response_object_for(result, request)
Response.new(result:, data: JSON.parse(result.body), request:)
end
end
end
class ResponseLink
DEFAULT_CURSOR = '0:0:0'.freeze
LINK_PATTERN_REGEX = %r{^<([^>]+)>; rel="([^"]+)"; results="([^"]+)"; cursor="([^"]+)"$}
URL_REGEX = %r{^#{Regexp.quote(ENTRYPOINT_BASE)}/([^?]+)(?:\?(.+))?$}
VALID_RELS = %i[current previous next].freeze
attr_reader :cursor, :url
class << self
def from_response(response)
request = response.request
payload = request.payload
rel = :current
any_results = response.results?
cursor = payload[:cursor] || DEFAULT_CURSOR
new(url: response.result.uri.to_s, rel:, any_results:, cursor:)
end
def parse(link_str)
if (match = LINK_PATTERN_REGEX.match(link_str)).nil?
raise ArgumentError, 'link_str has an invalid string format'
end
url, rel, any_results, cursor = match.captures
rel = rel.downcase.to_sym.then { VALID_RELS.include?(_1) ? _1 : nil }
any_results = any_results.downcase == 'true'
new(url:, rel:, any_results:, cursor:)
end
end
def initialize(url:, rel:, any_results:, cursor:)
@any_results = any_results
@cursor = cursor
@rel = rel
@url = url
end
def next? = rel == :next
def path = @path ||= url_parts[:path]
def payload = @payload ||= { **Rack::Utils.parse_query(querystring).symbolize_keys, cursor: }
def previous? = rel == :previous
def querystring = @querystring ||= url_parts[:querystring] || ''
def results? = !!@any_results
private
def url_parts
@url_parts ||= begin
path, querystring = URL_REGEX.match(url).captures
{ path:, querystring: }.freeze
end
end
end
class PaginatedResponse
class << self
def from_response(response, prev_page = nil)
prev_link, next_link = parse_links(response.result['link'])
new(prev_link:, next_link:, response:)
end
private
def parse_links(links_str)
prev_link, next_link = links_str.to_s.split(',').map { ResponseLink.parse(_1.strip) }
end
end
attr_reader :curr_link, :next_link, :prev_link
def initialize(prev_link:, next_link:, request: nil, response: nil, prev_page: nil)
@prev_link = prev_link
@next_link = next_link
@request = request || response&.request
@response = response
@curr_link = @response && ResponseLink.from_response(@response)
@prev_page = prev_page
end
def data = @response&.data
def next_page? = results? && next_link.results?
def next_page = instance_variable_get_or_set(:next_page) { request_next_page }
def results? = @response&.results?
def to_h = @to_h ||= { previous: @prev_link, current: @curr_link, next: @next_link }
private
def make_request(link) = ApiRequester.make_request(link.path, payload: link.payload)
def request_next_page = next_page? ? self.class.from_response(make_request(next_link), self) : nil
end
class PaginatedResponses
PAGE_SIZE = 100
attr_reader :pages, :path, :payload, :verb, :items
def initialize(path, payload: {}, verb: :get, verbose: false)
@items = []
@pages = []
@path = path
@payload = payload
@verb = verb
@verbose = verbose
end
def each_page(&block) = page_iterator.each(&block)
def load_all_items = each_page { items.concat(_1.data) }
def next_page = page_with_results(fetch_next_page).then { _1 && register_page(_1) }
private
def fetch_first_page = PaginatedResponse.from_response(first_page_response)
def first_page = instance_variable_get_or_set(:first_page) { fetch_first_page }
def first_page_loaded? = instance_variable_defined?(:@first_page)
def first_page_response = ApiRequester.make_request(path, payload:, verb:)
def page_with_results(page) = (page if page&.results?)
def register_page(page) = page.tap { @pages << _1 if _1 }
def fetch_next_page
@verbose.then do |verbose|
if verbose && (@pages.last&.next_page? || !first_page_loaded?)
i = pages.size
page = i.succ
puts "Requesting page ##{page} (#{i * PAGE_SIZE} entries requested so far)..."
end
@pages.last ? @pages.last.next_page : first_page
end
end
def page_iterator = Enumerator.new { |yielder; page| yielder << page while page = next_page }
end
issue_id = $*[0]
full = !%w[0 f false no].include?($*[1]&.downcase) ? 'true' : 'false'
verbose = %w[1 t true yes].include?($*[2]&.downcase)
prs = PaginatedResponses.new("issues/#{issue_id}/events", payload: { full: }, verbose:)
prs.load_all_items
jj prs.items
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment