Created
January 30, 2026 17:15
-
-
Save leandro/69ac2200524062244646a7cb65cd2912 to your computer and use it in GitHub Desktop.
Retrieve ALL the Sentry error events for a specific Sentry Issue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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