fastlane-plugin-sentry_api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ require 'fastlane/action'
2
+ require_relative '../helper/sentry_api_helper'
3
+
4
+ module Fastlane
5
+ module Actions
6
+ module SharedValues
7
+ SENTRY_TTID_DATA = :SENTRY_TTID_DATA
8
+ SENTRY_TTID_STATUS_CODE = :SENTRY_TTID_STATUS_CODE
9
+ SENTRY_TTID_JSON = :SENTRY_TTID_JSON
10
+ end
11
+
12
+ # Query Time to Initial Display (TTID) percentiles per screen from the Sentry Events/Discover API.
13
+ # Returns p50, p75, p95 metrics for each screen transaction, sorted by load count.
14
+ class SentryTtidPercentilesAction < Action
15
+ class << self
16
+ def run(params)
17
+ auth_token = params[:auth_token]
18
+ org_slug = params[:org_slug]
19
+ project_id = params[:project_id]
20
+
21
+ query_params = build_query_params(params, project_id)
22
+
23
+ UI.message("Fetching TTID percentiles from Sentry (#{query_params[:statsPeriod] || 'custom range'})...")
24
+
25
+ response = Helper::SentryApiHelper.get_events(
26
+ auth_token: auth_token,
27
+ org_slug: org_slug,
28
+ params: query_params
29
+ )
30
+
31
+ status_code = response[:status]
32
+ json = response[:json]
33
+
34
+ unless status_code.between?(200, 299)
35
+ UI.user_error!("Sentry Events API error #{status_code}: #{response[:body]}")
36
+ return nil
37
+ end
38
+
39
+ result = parse_response(json)
40
+
41
+ Actions.lane_context[SharedValues::SENTRY_TTID_STATUS_CODE] = status_code
42
+ Actions.lane_context[SharedValues::SENTRY_TTID_JSON] = json
43
+ Actions.lane_context[SharedValues::SENTRY_TTID_DATA] = result
44
+
45
+ UI.success("Fetched TTID data for #{result.length} screens")
46
+ result.first(5).each do |screen|
47
+ UI.message(" #{screen[:transaction]}: p50=#{screen[:p50]}ms p75=#{screen[:p75]}ms p95=#{screen[:p95]}ms (#{screen[:count]} loads)")
48
+ end
49
+
50
+ result
51
+ end
52
+
53
+ #####################################################
54
+ # @!group Documentation
55
+ #####################################################
56
+
57
+ def description
58
+ "Query TTID (Time to Initial Display) percentiles per screen from Sentry"
59
+ end
60
+
61
+ def details
62
+ [
63
+ "Queries the Sentry Events/Discover API for Time to Initial Display (TTID) metrics.",
64
+ "Returns p50, p75, and p95 percentiles per screen transaction, sorted by load count.",
65
+ "Useful for monitoring app launch performance and screen rendering latency.",
66
+ "",
67
+ "Supports filtering by release, environment, and transaction operation type.",
68
+ "Use `stats_period` for rolling windows or `start_date` + `end_date` for specific ranges.",
69
+ "",
70
+ "API Documentation: https://docs.sentry.io/api/discover/"
71
+ ].join("\n")
72
+ end
73
+
74
+ def available_options
75
+ [
76
+ FastlaneCore::ConfigItem.new(key: :auth_token,
77
+ env_name: "SENTRY_AUTH_TOKEN",
78
+ description: "Sentry API Bearer auth token",
79
+ optional: false,
80
+ type: String,
81
+ sensitive: true,
82
+ code_gen_sensitive: true,
83
+ verify_block: proc do |value|
84
+ UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
85
+ end),
86
+ FastlaneCore::ConfigItem.new(key: :org_slug,
87
+ env_name: "SENTRY_ORG_SLUG",
88
+ description: "Sentry organization slug",
89
+ optional: false,
90
+ type: String,
91
+ verify_block: proc do |value|
92
+ UI.user_error!("No Sentry org slug given, pass using `org_slug: 'my-org'`") if value.to_s.empty?
93
+ end),
94
+ FastlaneCore::ConfigItem.new(key: :project_id,
95
+ env_name: "SENTRY_PROJECT_ID",
96
+ description: "Sentry numeric project ID",
97
+ optional: false,
98
+ type: String,
99
+ verify_block: proc do |value|
100
+ UI.user_error!("No Sentry project ID given, pass using `project_id: '12345'`") if value.to_s.empty?
101
+ end),
102
+ FastlaneCore::ConfigItem.new(key: :environment,
103
+ env_name: "SENTRY_ENVIRONMENT",
104
+ description: "Environment filter (e.g. 'production')",
105
+ optional: true,
106
+ default_value: "production",
107
+ type: String),
108
+ FastlaneCore::ConfigItem.new(key: :stats_period,
109
+ description: "Rolling time window (e.g. '7d', '14d', '30d')",
110
+ optional: true,
111
+ default_value: "7d",
112
+ type: String),
113
+ FastlaneCore::ConfigItem.new(key: :start_date,
114
+ description: "Start date in ISO 8601 format. Use with end_date instead of stats_period",
115
+ optional: true,
116
+ type: String),
117
+ FastlaneCore::ConfigItem.new(key: :end_date,
118
+ description: "End date in ISO 8601 format. Use with start_date instead of stats_period",
119
+ optional: true,
120
+ type: String),
121
+ FastlaneCore::ConfigItem.new(key: :release,
122
+ description: "Filter by specific release version",
123
+ optional: true,
124
+ type: String),
125
+ FastlaneCore::ConfigItem.new(key: :transaction_op,
126
+ description: "Transaction operation filter (e.g. 'ui.load', 'ui.action')",
127
+ optional: true,
128
+ default_value: "ui.load",
129
+ type: String),
130
+ FastlaneCore::ConfigItem.new(key: :per_page,
131
+ description: "Number of screens to return (max 100)",
132
+ optional: true,
133
+ default_value: 20,
134
+ type: Integer),
135
+ FastlaneCore::ConfigItem.new(key: :sort,
136
+ description: "Sort order (e.g. '-count()', '-p95(measurements.time_to_initial_display)')",
137
+ optional: true,
138
+ default_value: "-count()",
139
+ type: String)
140
+ ]
141
+ end
142
+
143
+ def output
144
+ [
145
+ ['SENTRY_TTID_DATA', 'Array of hashes with :transaction, :p50, :p75, :p95, :count per screen'],
146
+ ['SENTRY_TTID_STATUS_CODE', 'HTTP status code from the Sentry API'],
147
+ ['SENTRY_TTID_JSON', 'Raw JSON response from the Sentry API']
148
+ ]
149
+ end
150
+
151
+ def return_value
152
+ "An array of hashes, each with :transaction (screen name), :p50, :p75, :p95 (in ms), and :count (number of loads)."
153
+ end
154
+
155
+ def authors
156
+ ["crazymanish"]
157
+ end
158
+
159
+ def example_code
160
+ [
161
+ '# Top 10 screens by load count, last 7 days
162
+ screens = sentry_ttid_percentiles(stats_period: "7d", per_page: 10)
163
+ screens.each do |s|
164
+ UI.message("#{s[:transaction]}: p50=#{s[:p50]}ms p95=#{s[:p95]}ms (#{s[:count]} loads)")
165
+ end',
166
+
167
+ '# Filter by release
168
+ sentry_ttid_percentiles(release: "v25.10.0", stats_period: "14d")',
169
+
170
+ '# Custom date range (for week-over-week comparison)
171
+ sentry_ttid_percentiles(start_date: "2026-02-24T00:00:00Z", end_date: "2026-03-03T00:00:00Z")'
172
+ ]
173
+ end
174
+
175
+ def category
176
+ :misc
177
+ end
178
+
179
+ def is_supported?(platform)
180
+ true
181
+ end
182
+
183
+ private
184
+
185
+ def build_query_params(params, project_id)
186
+ fields = [
187
+ 'transaction',
188
+ 'p50(measurements.time_to_initial_display)',
189
+ 'p75(measurements.time_to_initial_display)',
190
+ 'p95(measurements.time_to_initial_display)',
191
+ 'count()'
192
+ ]
193
+
194
+ # Build the query filter
195
+ query_parts = ["event.type:transaction"]
196
+ query_parts << "transaction.op:#{params[:transaction_op]}" if params[:transaction_op]
197
+ query_parts << "release:#{params[:release]}" if params[:release]
198
+
199
+ query_params = {
200
+ dataset: 'metrics',
201
+ field: fields,
202
+ project: project_id.to_s,
203
+ query: query_parts.join(' '),
204
+ sort: params[:sort] || '-count()',
205
+ per_page: (params[:per_page] || 20).to_s
206
+ }
207
+
208
+ if params[:start_date] && params[:end_date]
209
+ query_params[:start] = params[:start_date]
210
+ query_params[:end] = params[:end_date]
211
+ else
212
+ query_params[:statsPeriod] = params[:stats_period] || '7d'
213
+ end
214
+
215
+ query_params[:environment] = params[:environment] if params[:environment]
216
+
217
+ query_params
218
+ end
219
+
220
+ def parse_response(json)
221
+ data = json&.dig('data') || []
222
+
223
+ data.map do |row|
224
+ {
225
+ transaction: row['transaction'],
226
+ p50: round_ms(row['p50(measurements.time_to_initial_display)']),
227
+ p75: round_ms(row['p75(measurements.time_to_initial_display)']),
228
+ p95: round_ms(row['p95(measurements.time_to_initial_display)']),
229
+ count: row['count()']
230
+ }
231
+ end
232
+ end
233
+
234
+ def round_ms(value)
235
+ return nil if value.nil?
236
+
237
+ value.round(1)
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,113 @@
1
+ require 'fastlane_core/ui/ui'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module Fastlane
7
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
8
+
9
+ module Helper
10
+ class SentryApiHelper
11
+ BASE_URL = "https://sentry.io/api/0"
12
+
13
+ class << self
14
+ # Make a GET request to the Sentry REST API.
15
+ #
16
+ # @param auth_token [String] Sentry auth token (Bearer)
17
+ # @param path [String] API endpoint path (e.g. "/organizations/my-org/sessions/")
18
+ # @param params [Hash] Query parameters. Array values produce repeated keys (field=a&field=b).
19
+ # @param base_url [String] Sentry API base URL
20
+ # @return [Hash] { status: Integer, body: String, json: Object|nil }
21
+ def api_request(auth_token:, path:, params: {}, base_url: BASE_URL)
22
+ url = "#{base_url}#{path}"
23
+ uri = URI(url)
24
+ uri.query = build_query_string(params) unless params.nil? || params.empty?
25
+
26
+ UI.verbose("Sentry API GET: #{uri}")
27
+
28
+ req = Net::HTTP::Get.new(uri)
29
+ req['Authorization'] = "Bearer #{auth_token}"
30
+ req['Content-Type'] = 'application/json'
31
+
32
+ http = Net::HTTP.new(uri.hostname, uri.port)
33
+ if uri.scheme == 'https'
34
+ http.use_ssl = true
35
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
36
+
37
+ # Disable CRL checking to avoid "unable to get certificate CRL" errors
38
+ cert_store = OpenSSL::X509::Store.new
39
+ cert_store.set_default_paths
40
+ http.cert_store = cert_store
41
+ end
42
+ http.open_timeout = 30
43
+ http.read_timeout = 60
44
+
45
+ response = http.request(req)
46
+
47
+ status_code = response.code.to_i
48
+ body = response.body.to_s
49
+ json = parse_json(body)
50
+
51
+ { status: status_code, body: body, json: json }
52
+ end
53
+
54
+ # GET /api/0/organizations/{org}/sessions/
55
+ # Used for crash-free rates, session counts, user counts.
56
+ def get_sessions(auth_token:, org_slug:, params: {})
57
+ api_request(
58
+ auth_token: auth_token,
59
+ path: "/organizations/#{org_slug}/sessions/",
60
+ params: params
61
+ )
62
+ end
63
+
64
+ # GET /api/0/organizations/{org}/events/
65
+ # Used for Discover queries (TTID percentiles, performance metrics).
66
+ def get_events(auth_token:, org_slug:, params: {})
67
+ api_request(
68
+ auth_token: auth_token,
69
+ path: "/organizations/#{org_slug}/events/",
70
+ params: params
71
+ )
72
+ end
73
+
74
+ # GET /api/0/projects/{org}/{project}/issues/
75
+ # Used for listing project issues filtered by release, query, etc.
76
+ def get_issues(auth_token:, org_slug:, project_slug:, params: {})
77
+ api_request(
78
+ auth_token: auth_token,
79
+ path: "/projects/#{org_slug}/#{project_slug}/issues/",
80
+ params: params
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ # Build a query string that supports repeated parameter keys via Array values.
87
+ # Example: { field: ['a', 'b'], project: 1 } => "field=a&field=b&project=1"
88
+ def build_query_string(params)
89
+ return nil if params.nil? || params.empty?
90
+
91
+ pairs = []
92
+ params.each do |key, value|
93
+ next if value.nil?
94
+
95
+ if value.is_a?(Array)
96
+ value.each { |v| pairs << [key.to_s, v.to_s] }
97
+ else
98
+ pairs << [key.to_s, value.to_s]
99
+ end
100
+ end
101
+
102
+ URI.encode_www_form(pairs)
103
+ end
104
+
105
+ def parse_json(value)
106
+ JSON.parse(value)
107
+ rescue JSON::ParserError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module SentryApi
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require 'fastlane/plugin/sentry_api/version'
2
+
3
+ module Fastlane
4
+ module SentryApi
5
+ # Return all .rb files inside the "actions" and "helper" directory
6
+ def self.all_classes
7
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
8
+ end
9
+ end
10
+ end
11
+
12
+ # By default we want to import all available actions and helpers
13
+ # A plugin can contain any number of actions and plugins
14
+ Fastlane::SentryApi.all_classes.each do |current|
15
+ require current
16
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-sentry_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - crazymanish
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email: i.am.manish.rathi@gmail.com
13
+ executables: []
14
+ extensions: []
15
+ extra_rdoc_files: []
16
+ files:
17
+ - LICENSE
18
+ - README.md
19
+ - lib/fastlane/plugin/sentry_api.rb
20
+ - lib/fastlane/plugin/sentry_api/actions/sentry_api_action.rb
21
+ - lib/fastlane/plugin/sentry_api/actions/sentry_crash_free_sessions_action.rb
22
+ - lib/fastlane/plugin/sentry_api/actions/sentry_crash_free_users_action.rb
23
+ - lib/fastlane/plugin/sentry_api/actions/sentry_list_issues_action.rb
24
+ - lib/fastlane/plugin/sentry_api/actions/sentry_slo_report_action.rb
25
+ - lib/fastlane/plugin/sentry_api/actions/sentry_ttid_percentiles_action.rb
26
+ - lib/fastlane/plugin/sentry_api/helper/sentry_api_helper.rb
27
+ - lib/fastlane/plugin/sentry_api/version.rb
28
+ homepage: https://github.com/crazymanish/fastlane-plugin-sentry_api
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ rubygems_mfa_required: 'true'
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.6.7
48
+ specification_version: 4
49
+ summary: Fastlane plugin for Sentry APIs - crash-free rates, TTID percentiles, issue
50
+ tracking, and SLO reports
51
+ test_files: []