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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_api_action.rb +129 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_crash_free_sessions_action.rb +283 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_crash_free_users_action.rb +198 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_list_issues_action.rb +207 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_slo_report_action.rb +573 -0
- data/lib/fastlane/plugin/sentry_api/actions/sentry_ttid_percentiles_action.rb +242 -0
- data/lib/fastlane/plugin/sentry_api/helper/sentry_api_helper.rb +113 -0
- data/lib/fastlane/plugin/sentry_api/version.rb +5 -0
- data/lib/fastlane/plugin/sentry_api.rb +16 -0
- metadata +51 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
require 'fastlane/action'
|
|
2
|
+
require_relative '../helper/sentry_api_helper'
|
|
3
|
+
|
|
4
|
+
module Fastlane
|
|
5
|
+
module Actions
|
|
6
|
+
module SharedValues
|
|
7
|
+
SENTRY_CRASH_FREE_SESSION_RATE = :SENTRY_CRASH_FREE_SESSION_RATE
|
|
8
|
+
SENTRY_CRASH_FREE_USER_RATE = :SENTRY_CRASH_FREE_USER_RATE
|
|
9
|
+
SENTRY_TOTAL_SESSIONS = :SENTRY_TOTAL_SESSIONS
|
|
10
|
+
SENTRY_TOTAL_USERS = :SENTRY_TOTAL_USERS
|
|
11
|
+
SENTRY_SESSION_GROUPS = :SENTRY_SESSION_GROUPS
|
|
12
|
+
SENTRY_CRASH_FREE_SESSIONS_STATUS_CODE = :SENTRY_CRASH_FREE_SESSIONS_STATUS_CODE
|
|
13
|
+
SENTRY_CRASH_FREE_SESSIONS_JSON = :SENTRY_CRASH_FREE_SESSIONS_JSON
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Query crash-free session and user rates from the Sentry Sessions API.
|
|
17
|
+
# Supports aggregate metrics (for week-over-week) and grouped-by-release (for release-over-release).
|
|
18
|
+
class SentryCrashFreeSessionsAction < Action
|
|
19
|
+
class << self
|
|
20
|
+
def run(params)
|
|
21
|
+
auth_token = params[:auth_token]
|
|
22
|
+
org_slug = params[:org_slug]
|
|
23
|
+
project_id = params[:project_id]
|
|
24
|
+
|
|
25
|
+
query_params = build_query_params(params, project_id)
|
|
26
|
+
|
|
27
|
+
UI.message("Fetching crash-free session metrics from Sentry (#{query_params[:statsPeriod] || 'custom range'})...")
|
|
28
|
+
|
|
29
|
+
response = Helper::SentryApiHelper.get_sessions(
|
|
30
|
+
auth_token: auth_token,
|
|
31
|
+
org_slug: org_slug,
|
|
32
|
+
params: query_params
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
status_code = response[:status]
|
|
36
|
+
json = response[:json]
|
|
37
|
+
|
|
38
|
+
unless status_code.between?(200, 299)
|
|
39
|
+
UI.user_error!("Sentry Sessions API error #{status_code}: #{response[:body]}")
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result = parse_response(json, params[:group_by])
|
|
44
|
+
|
|
45
|
+
# Store in lane context
|
|
46
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_SESSIONS_STATUS_CODE] = status_code
|
|
47
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_SESSIONS_JSON] = json
|
|
48
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_SESSION_RATE] = result[:crash_free_session_rate]
|
|
49
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_USER_RATE] = result[:crash_free_user_rate]
|
|
50
|
+
Actions.lane_context[SharedValues::SENTRY_TOTAL_SESSIONS] = result[:total_sessions]
|
|
51
|
+
Actions.lane_context[SharedValues::SENTRY_TOTAL_USERS] = result[:total_users]
|
|
52
|
+
Actions.lane_context[SharedValues::SENTRY_SESSION_GROUPS] = result[:groups]
|
|
53
|
+
|
|
54
|
+
# Log results
|
|
55
|
+
if result[:groups] && !result[:groups].empty?
|
|
56
|
+
UI.success("Fetched #{result[:groups].length} session groups")
|
|
57
|
+
result[:groups].each do |group|
|
|
58
|
+
label = group[:by].values.first || "aggregate"
|
|
59
|
+
UI.message(" #{label}: sessions=#{format_pct(group[:crash_free_session_rate])} users=#{format_pct(group[:crash_free_user_rate])}")
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
UI.success("Crash-free sessions: #{format_pct(result[:crash_free_session_rate])}")
|
|
63
|
+
UI.success("Crash-free users: #{format_pct(result[:crash_free_user_rate])}")
|
|
64
|
+
UI.success("Total sessions: #{result[:total_sessions]}, Total users: #{result[:total_users]}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#####################################################
|
|
71
|
+
# @!group Documentation
|
|
72
|
+
#####################################################
|
|
73
|
+
|
|
74
|
+
def description
|
|
75
|
+
"Query crash-free session and user rates from the Sentry Sessions API"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def details
|
|
79
|
+
[
|
|
80
|
+
"Queries the Sentry Sessions API for crash-free session rates, crash-free user rates,",
|
|
81
|
+
"total sessions, and total users. Supports aggregate metrics for a time period",
|
|
82
|
+
"(useful for week-over-week comparison) and grouped-by-release metrics",
|
|
83
|
+
"(useful for release-over-release comparison).",
|
|
84
|
+
"",
|
|
85
|
+
"Use `stats_period` for rolling windows (e.g. '7d', '14d', '30d'),",
|
|
86
|
+
"or `start_date` + `end_date` for a specific date range.",
|
|
87
|
+
"",
|
|
88
|
+
"API Documentation: https://docs.sentry.io/api/releases/retrieve-release-health-session-statistics/"
|
|
89
|
+
].join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def available_options
|
|
93
|
+
[
|
|
94
|
+
FastlaneCore::ConfigItem.new(key: :auth_token,
|
|
95
|
+
env_name: "SENTRY_AUTH_TOKEN",
|
|
96
|
+
description: "Sentry API Bearer auth token",
|
|
97
|
+
optional: false,
|
|
98
|
+
type: String,
|
|
99
|
+
sensitive: true,
|
|
100
|
+
code_gen_sensitive: true,
|
|
101
|
+
verify_block: proc do |value|
|
|
102
|
+
UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
|
|
103
|
+
end),
|
|
104
|
+
FastlaneCore::ConfigItem.new(key: :org_slug,
|
|
105
|
+
env_name: "SENTRY_ORG_SLUG",
|
|
106
|
+
description: "Sentry organization slug",
|
|
107
|
+
optional: false,
|
|
108
|
+
type: String,
|
|
109
|
+
verify_block: proc do |value|
|
|
110
|
+
UI.user_error!("No Sentry org slug given, pass using `org_slug: 'my-org'`") if value.to_s.empty?
|
|
111
|
+
end),
|
|
112
|
+
FastlaneCore::ConfigItem.new(key: :project_id,
|
|
113
|
+
env_name: "SENTRY_PROJECT_ID",
|
|
114
|
+
description: "Sentry numeric project ID",
|
|
115
|
+
optional: false,
|
|
116
|
+
type: String,
|
|
117
|
+
verify_block: proc do |value|
|
|
118
|
+
UI.user_error!("No Sentry project ID given, pass using `project_id: '12345'`") if value.to_s.empty?
|
|
119
|
+
end),
|
|
120
|
+
FastlaneCore::ConfigItem.new(key: :environment,
|
|
121
|
+
env_name: "SENTRY_ENVIRONMENT",
|
|
122
|
+
description: "Environment filter (e.g. 'production')",
|
|
123
|
+
optional: true,
|
|
124
|
+
default_value: "production",
|
|
125
|
+
type: String),
|
|
126
|
+
FastlaneCore::ConfigItem.new(key: :stats_period,
|
|
127
|
+
description: "Rolling time window (e.g. '7d', '14d', '30d'). Mutually exclusive with start_date/end_date",
|
|
128
|
+
optional: true,
|
|
129
|
+
default_value: "7d",
|
|
130
|
+
type: String),
|
|
131
|
+
FastlaneCore::ConfigItem.new(key: :start_date,
|
|
132
|
+
description: "Start date in ISO 8601 format (e.g. '2026-03-01T00:00:00Z'). Use with end_date instead of stats_period",
|
|
133
|
+
optional: true,
|
|
134
|
+
type: String),
|
|
135
|
+
FastlaneCore::ConfigItem.new(key: :end_date,
|
|
136
|
+
description: "End date in ISO 8601 format (e.g. '2026-03-08T00:00:00Z'). Use with start_date instead of stats_period",
|
|
137
|
+
optional: true,
|
|
138
|
+
type: String),
|
|
139
|
+
FastlaneCore::ConfigItem.new(key: :group_by,
|
|
140
|
+
description: "Group results by dimension: 'release', 'environment', or nil for aggregate",
|
|
141
|
+
optional: true,
|
|
142
|
+
type: String),
|
|
143
|
+
FastlaneCore::ConfigItem.new(key: :per_page,
|
|
144
|
+
description: "Number of groups to return when group_by is set (max 100)",
|
|
145
|
+
optional: true,
|
|
146
|
+
default_value: 10,
|
|
147
|
+
type: Integer),
|
|
148
|
+
FastlaneCore::ConfigItem.new(key: :order_by,
|
|
149
|
+
description: "Sort order for grouped results (e.g. '-sum(session)', '-crash_free_rate(session)')",
|
|
150
|
+
optional: true,
|
|
151
|
+
default_value: "-sum(session)",
|
|
152
|
+
type: String)
|
|
153
|
+
]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def output
|
|
157
|
+
[
|
|
158
|
+
['SENTRY_CRASH_FREE_SESSION_RATE', 'Crash-free session rate as a float (e.g. 0.9974)'],
|
|
159
|
+
['SENTRY_CRASH_FREE_USER_RATE', 'Crash-free user rate as a float (e.g. 0.9981)'],
|
|
160
|
+
['SENTRY_TOTAL_SESSIONS', 'Total number of sessions in the period'],
|
|
161
|
+
['SENTRY_TOTAL_USERS', 'Total number of unique users in the period'],
|
|
162
|
+
['SENTRY_SESSION_GROUPS', 'Array of group hashes when group_by is set'],
|
|
163
|
+
['SENTRY_CRASH_FREE_SESSIONS_STATUS_CODE', 'HTTP status code from the Sentry API'],
|
|
164
|
+
['SENTRY_CRASH_FREE_SESSIONS_JSON', 'Raw JSON response from the Sentry API']
|
|
165
|
+
]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def return_value
|
|
169
|
+
"A hash with :crash_free_session_rate, :crash_free_user_rate, :total_sessions, :total_users, and :groups (when group_by is set)."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def authors
|
|
173
|
+
["crazymanish"]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def example_code
|
|
177
|
+
[
|
|
178
|
+
'# Aggregate crash-free rate for last 7 days
|
|
179
|
+
sentry_crash_free_sessions(stats_period: "7d")
|
|
180
|
+
rate = lane_context[SharedValues::SENTRY_CRASH_FREE_SESSION_RATE]
|
|
181
|
+
UI.message("Crash-free sessions: #{(rate * 100).round(2)}%")',
|
|
182
|
+
|
|
183
|
+
'# Grouped by release (release-over-release comparison)
|
|
184
|
+
result = sentry_crash_free_sessions(stats_period: "30d", group_by: "release", per_page: 5)
|
|
185
|
+
result[:groups].each do |g|
|
|
186
|
+
puts "#{g[:by]["release"]}: #{(g[:crash_free_session_rate] * 100).round(2)}%"
|
|
187
|
+
end',
|
|
188
|
+
|
|
189
|
+
'# Custom date range (previous week for week-over-week)
|
|
190
|
+
sentry_crash_free_sessions(start_date: "2026-02-24T00:00:00Z", end_date: "2026-03-03T00:00:00Z")'
|
|
191
|
+
]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def category
|
|
195
|
+
:misc
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def is_supported?(platform)
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def build_query_params(params, project_id)
|
|
205
|
+
fields = [
|
|
206
|
+
'crash_free_rate(session)',
|
|
207
|
+
'crash_free_rate(user)',
|
|
208
|
+
'sum(session)',
|
|
209
|
+
'count_unique(user)'
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
query_params = {
|
|
213
|
+
field: fields,
|
|
214
|
+
project: project_id.to_s,
|
|
215
|
+
includeSeries: '0'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Time range: stats_period OR start/end
|
|
219
|
+
if params[:start_date] && params[:end_date]
|
|
220
|
+
query_params[:start] = params[:start_date]
|
|
221
|
+
query_params[:end] = params[:end_date]
|
|
222
|
+
else
|
|
223
|
+
query_params[:statsPeriod] = params[:stats_period] || '7d'
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
query_params[:environment] = params[:environment] if params[:environment]
|
|
227
|
+
|
|
228
|
+
# Grouping
|
|
229
|
+
if params[:group_by]
|
|
230
|
+
query_params[:groupBy] = params[:group_by]
|
|
231
|
+
query_params[:per_page] = params[:per_page].to_s if params[:per_page]
|
|
232
|
+
query_params[:orderBy] = params[:order_by] if params[:order_by]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
query_params
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def parse_response(json, group_by)
|
|
239
|
+
groups_data = json&.dig('groups') || []
|
|
240
|
+
|
|
241
|
+
if group_by && !group_by.empty?
|
|
242
|
+
# Grouped response: return array of group results
|
|
243
|
+
groups = groups_data.map do |group|
|
|
244
|
+
totals = group['totals'] || {}
|
|
245
|
+
{
|
|
246
|
+
by: group['by'] || {},
|
|
247
|
+
crash_free_session_rate: totals['crash_free_rate(session)'],
|
|
248
|
+
crash_free_user_rate: totals['crash_free_rate(user)'],
|
|
249
|
+
total_sessions: totals['sum(session)'],
|
|
250
|
+
total_users: totals['count_unique(user)']
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
crash_free_session_rate: nil,
|
|
256
|
+
crash_free_user_rate: nil,
|
|
257
|
+
total_sessions: nil,
|
|
258
|
+
total_users: nil,
|
|
259
|
+
groups: groups
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
# Aggregate response: single group
|
|
263
|
+
totals = groups_data.dig(0, 'totals') || {}
|
|
264
|
+
|
|
265
|
+
{
|
|
266
|
+
crash_free_session_rate: totals['crash_free_rate(session)'],
|
|
267
|
+
crash_free_user_rate: totals['crash_free_rate(user)'],
|
|
268
|
+
total_sessions: totals['sum(session)'],
|
|
269
|
+
total_users: totals['count_unique(user)'],
|
|
270
|
+
groups: nil
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def format_pct(value)
|
|
276
|
+
return "N/A" if value.nil?
|
|
277
|
+
|
|
278
|
+
"#{(value * 100).round(4)}%"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require 'fastlane/action'
|
|
2
|
+
require_relative '../helper/sentry_api_helper'
|
|
3
|
+
|
|
4
|
+
module Fastlane
|
|
5
|
+
module Actions
|
|
6
|
+
module SharedValues
|
|
7
|
+
SENTRY_CRASH_FREE_USER_RATE_ONLY = :SENTRY_CRASH_FREE_USER_RATE_ONLY
|
|
8
|
+
SENTRY_TOTAL_USERS_ONLY = :SENTRY_TOTAL_USERS_ONLY
|
|
9
|
+
SENTRY_CRASH_FREE_USERS_STATUS_CODE = :SENTRY_CRASH_FREE_USERS_STATUS_CODE
|
|
10
|
+
SENTRY_CRASH_FREE_USERS_JSON = :SENTRY_CRASH_FREE_USERS_JSON
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Convenience action focused on user-centric crash-free metrics.
|
|
14
|
+
# Queries the same Sentry Sessions API but returns user-focused results.
|
|
15
|
+
class SentryCrashFreeUsersAction < Action
|
|
16
|
+
class << self
|
|
17
|
+
def run(params)
|
|
18
|
+
auth_token = params[:auth_token]
|
|
19
|
+
org_slug = params[:org_slug]
|
|
20
|
+
project_id = params[:project_id]
|
|
21
|
+
|
|
22
|
+
query_params = build_query_params(params, project_id)
|
|
23
|
+
|
|
24
|
+
UI.message("Fetching crash-free user metrics from Sentry (#{query_params[:statsPeriod] || 'custom range'})...")
|
|
25
|
+
|
|
26
|
+
response = Helper::SentryApiHelper.get_sessions(
|
|
27
|
+
auth_token: auth_token,
|
|
28
|
+
org_slug: org_slug,
|
|
29
|
+
params: query_params
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
status_code = response[:status]
|
|
33
|
+
json = response[:json]
|
|
34
|
+
|
|
35
|
+
unless status_code.between?(200, 299)
|
|
36
|
+
UI.user_error!("Sentry Sessions API error #{status_code}: #{response[:body]}")
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result = parse_response(json)
|
|
41
|
+
|
|
42
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_USERS_STATUS_CODE] = status_code
|
|
43
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_USERS_JSON] = json
|
|
44
|
+
Actions.lane_context[SharedValues::SENTRY_CRASH_FREE_USER_RATE_ONLY] = result[:crash_free_user_rate]
|
|
45
|
+
Actions.lane_context[SharedValues::SENTRY_TOTAL_USERS_ONLY] = result[:total_users]
|
|
46
|
+
|
|
47
|
+
UI.success("Crash-free users: #{format_pct(result[:crash_free_user_rate])}")
|
|
48
|
+
UI.success("Total unique users: #{result[:total_users]}")
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#####################################################
|
|
54
|
+
# @!group Documentation
|
|
55
|
+
#####################################################
|
|
56
|
+
|
|
57
|
+
def description
|
|
58
|
+
"Query crash-free user rate from the Sentry Sessions API"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def details
|
|
62
|
+
[
|
|
63
|
+
"Convenience action for fetching user-centric crash-free metrics from Sentry.",
|
|
64
|
+
"Queries the Sessions API for crash_free_rate(user) and count_unique(user).",
|
|
65
|
+
"For full session + user metrics, use sentry_crash_free_sessions instead.",
|
|
66
|
+
"",
|
|
67
|
+
"API Documentation: https://docs.sentry.io/api/releases/retrieve-release-health-session-statistics/"
|
|
68
|
+
].join("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def available_options
|
|
72
|
+
[
|
|
73
|
+
FastlaneCore::ConfigItem.new(key: :auth_token,
|
|
74
|
+
env_name: "SENTRY_AUTH_TOKEN",
|
|
75
|
+
description: "Sentry API Bearer auth token",
|
|
76
|
+
optional: false,
|
|
77
|
+
type: String,
|
|
78
|
+
sensitive: true,
|
|
79
|
+
code_gen_sensitive: true,
|
|
80
|
+
verify_block: proc do |value|
|
|
81
|
+
UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
|
|
82
|
+
end),
|
|
83
|
+
FastlaneCore::ConfigItem.new(key: :org_slug,
|
|
84
|
+
env_name: "SENTRY_ORG_SLUG",
|
|
85
|
+
description: "Sentry organization slug",
|
|
86
|
+
optional: false,
|
|
87
|
+
type: String,
|
|
88
|
+
verify_block: proc do |value|
|
|
89
|
+
UI.user_error!("No Sentry org slug given, pass using `org_slug: 'my-org'`") if value.to_s.empty?
|
|
90
|
+
end),
|
|
91
|
+
FastlaneCore::ConfigItem.new(key: :project_id,
|
|
92
|
+
env_name: "SENTRY_PROJECT_ID",
|
|
93
|
+
description: "Sentry numeric project ID",
|
|
94
|
+
optional: false,
|
|
95
|
+
type: String,
|
|
96
|
+
verify_block: proc do |value|
|
|
97
|
+
UI.user_error!("No Sentry project ID given, pass using `project_id: '12345'`") if value.to_s.empty?
|
|
98
|
+
end),
|
|
99
|
+
FastlaneCore::ConfigItem.new(key: :environment,
|
|
100
|
+
env_name: "SENTRY_ENVIRONMENT",
|
|
101
|
+
description: "Environment filter (e.g. 'production')",
|
|
102
|
+
optional: true,
|
|
103
|
+
default_value: "production",
|
|
104
|
+
type: String),
|
|
105
|
+
FastlaneCore::ConfigItem.new(key: :stats_period,
|
|
106
|
+
description: "Rolling time window (e.g. '7d', '14d', '30d')",
|
|
107
|
+
optional: true,
|
|
108
|
+
default_value: "7d",
|
|
109
|
+
type: String),
|
|
110
|
+
FastlaneCore::ConfigItem.new(key: :start_date,
|
|
111
|
+
description: "Start date in ISO 8601 format. Use with end_date instead of stats_period",
|
|
112
|
+
optional: true,
|
|
113
|
+
type: String),
|
|
114
|
+
FastlaneCore::ConfigItem.new(key: :end_date,
|
|
115
|
+
description: "End date in ISO 8601 format. Use with start_date instead of stats_period",
|
|
116
|
+
optional: true,
|
|
117
|
+
type: String)
|
|
118
|
+
]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def output
|
|
122
|
+
[
|
|
123
|
+
['SENTRY_CRASH_FREE_USER_RATE_ONLY', 'Crash-free user rate as a float (e.g. 0.9991)'],
|
|
124
|
+
['SENTRY_TOTAL_USERS_ONLY', 'Total unique users in the period'],
|
|
125
|
+
['SENTRY_CRASH_FREE_USERS_STATUS_CODE', 'HTTP status code from the Sentry API'],
|
|
126
|
+
['SENTRY_CRASH_FREE_USERS_JSON', 'Raw JSON response from the Sentry API']
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def return_value
|
|
131
|
+
"A hash with :crash_free_user_rate and :total_users."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def authors
|
|
135
|
+
["crazymanish"]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def example_code
|
|
139
|
+
[
|
|
140
|
+
'sentry_crash_free_users(stats_period: "7d")
|
|
141
|
+
rate = lane_context[SharedValues::SENTRY_CRASH_FREE_USER_RATE_ONLY]
|
|
142
|
+
UI.message("Crash-free users: #{(rate * 100).round(2)}%")'
|
|
143
|
+
]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def category
|
|
147
|
+
:misc
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def is_supported?(platform)
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def build_query_params(params, project_id)
|
|
157
|
+
fields = [
|
|
158
|
+
'crash_free_rate(user)',
|
|
159
|
+
'count_unique(user)'
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
query_params = {
|
|
163
|
+
field: fields,
|
|
164
|
+
project: project_id.to_s,
|
|
165
|
+
includeSeries: '0'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if params[:start_date] && params[:end_date]
|
|
169
|
+
query_params[:start] = params[:start_date]
|
|
170
|
+
query_params[:end] = params[:end_date]
|
|
171
|
+
else
|
|
172
|
+
query_params[:statsPeriod] = params[:stats_period] || '7d'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
query_params[:environment] = params[:environment] if params[:environment]
|
|
176
|
+
|
|
177
|
+
query_params
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def parse_response(json)
|
|
181
|
+
groups_data = json&.dig('groups') || []
|
|
182
|
+
totals = groups_data.dig(0, 'totals') || {}
|
|
183
|
+
|
|
184
|
+
{
|
|
185
|
+
crash_free_user_rate: totals['crash_free_rate(user)'],
|
|
186
|
+
total_users: totals['count_unique(user)']
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_pct(value)
|
|
191
|
+
return "N/A" if value.nil?
|
|
192
|
+
|
|
193
|
+
"#{(value * 100).round(4)}%"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
require 'fastlane/action'
|
|
2
|
+
require_relative '../helper/sentry_api_helper'
|
|
3
|
+
|
|
4
|
+
module Fastlane
|
|
5
|
+
module Actions
|
|
6
|
+
module SharedValues
|
|
7
|
+
SENTRY_ISSUES = :SENTRY_ISSUES
|
|
8
|
+
SENTRY_ISSUE_COUNT = :SENTRY_ISSUE_COUNT
|
|
9
|
+
SENTRY_ISSUES_STATUS_CODE = :SENTRY_ISSUES_STATUS_CODE
|
|
10
|
+
SENTRY_ISSUES_JSON = :SENTRY_ISSUES_JSON
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Fetch issues from a Sentry project. Supports filtering by release, query, sort order, etc.
|
|
14
|
+
# Uses the Projects Issues API: GET /api/0/projects/{org}/{project}/issues/
|
|
15
|
+
class SentryListIssuesAction < Action
|
|
16
|
+
class << self
|
|
17
|
+
def run(params)
|
|
18
|
+
auth_token = params[:auth_token]
|
|
19
|
+
org_slug = params[:org_slug]
|
|
20
|
+
project_slug = params[:project_slug]
|
|
21
|
+
|
|
22
|
+
query_params = build_query_params(params)
|
|
23
|
+
|
|
24
|
+
UI.message("Fetching issues from Sentry project '#{project_slug}'...")
|
|
25
|
+
|
|
26
|
+
response = Helper::SentryApiHelper.get_issues(
|
|
27
|
+
auth_token: auth_token,
|
|
28
|
+
org_slug: org_slug,
|
|
29
|
+
project_slug: project_slug,
|
|
30
|
+
params: query_params
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
status_code = response[:status]
|
|
34
|
+
json = response[:json]
|
|
35
|
+
|
|
36
|
+
unless status_code.between?(200, 299)
|
|
37
|
+
UI.user_error!("Sentry Issues API error #{status_code}: #{response[:body]}")
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
issues = parse_response(json)
|
|
42
|
+
issue_count = issues.length
|
|
43
|
+
|
|
44
|
+
Actions.lane_context[SharedValues::SENTRY_ISSUES_STATUS_CODE] = status_code
|
|
45
|
+
Actions.lane_context[SharedValues::SENTRY_ISSUES_JSON] = json
|
|
46
|
+
Actions.lane_context[SharedValues::SENTRY_ISSUES] = issues
|
|
47
|
+
Actions.lane_context[SharedValues::SENTRY_ISSUE_COUNT] = issue_count
|
|
48
|
+
|
|
49
|
+
UI.success("Fetched #{issue_count} issues from '#{project_slug}'")
|
|
50
|
+
issues.first(5).each do |issue|
|
|
51
|
+
UI.message(" #{issue[:short_id]}: #{issue[:title]} (#{issue[:event_count]} events, #{issue[:user_count]} users)")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
{ issues: issues, count: issue_count }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#####################################################
|
|
58
|
+
# @!group Documentation
|
|
59
|
+
#####################################################
|
|
60
|
+
|
|
61
|
+
def description
|
|
62
|
+
"Fetch issues from a Sentry project"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def details
|
|
66
|
+
[
|
|
67
|
+
"Fetches issues from a Sentry project using the Projects Issues API.",
|
|
68
|
+
"Supports filtering by release version, query string, sort order, and pagination.",
|
|
69
|
+
"Useful for comparing issues across releases (e.g. latest vs previous).",
|
|
70
|
+
"",
|
|
71
|
+
"API Documentation: https://docs.sentry.io/api/events/list-a-projects-issues/"
|
|
72
|
+
].join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def available_options
|
|
76
|
+
[
|
|
77
|
+
FastlaneCore::ConfigItem.new(key: :auth_token,
|
|
78
|
+
env_name: "SENTRY_AUTH_TOKEN",
|
|
79
|
+
description: "Sentry API Bearer auth token",
|
|
80
|
+
optional: false,
|
|
81
|
+
type: String,
|
|
82
|
+
sensitive: true,
|
|
83
|
+
code_gen_sensitive: true,
|
|
84
|
+
verify_block: proc do |value|
|
|
85
|
+
UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
|
|
86
|
+
end),
|
|
87
|
+
FastlaneCore::ConfigItem.new(key: :org_slug,
|
|
88
|
+
env_name: "SENTRY_ORG_SLUG",
|
|
89
|
+
description: "Sentry organization slug",
|
|
90
|
+
optional: false,
|
|
91
|
+
type: String,
|
|
92
|
+
verify_block: proc do |value|
|
|
93
|
+
UI.user_error!("No Sentry org slug given, pass using `org_slug: 'my-org'`") if value.to_s.empty?
|
|
94
|
+
end),
|
|
95
|
+
FastlaneCore::ConfigItem.new(key: :project_slug,
|
|
96
|
+
env_name: "SENTRY_PROJECT_SLUG",
|
|
97
|
+
description: "Sentry project slug (e.g. 'ios', 'android')",
|
|
98
|
+
optional: false,
|
|
99
|
+
type: String,
|
|
100
|
+
verify_block: proc do |value|
|
|
101
|
+
UI.user_error!("No Sentry project slug given, pass using `project_slug: 'ios'`") if value.to_s.empty?
|
|
102
|
+
end),
|
|
103
|
+
FastlaneCore::ConfigItem.new(key: :query,
|
|
104
|
+
description: "Sentry search query (e.g. 'is:unresolved', 'is:unresolved release:v1.0')",
|
|
105
|
+
optional: true,
|
|
106
|
+
default_value: "is:unresolved",
|
|
107
|
+
type: String),
|
|
108
|
+
FastlaneCore::ConfigItem.new(key: :sort,
|
|
109
|
+
description: "Sort order: 'date', 'new', 'freq', 'priority', 'user', 'trend'",
|
|
110
|
+
optional: true,
|
|
111
|
+
default_value: "freq",
|
|
112
|
+
type: String),
|
|
113
|
+
FastlaneCore::ConfigItem.new(key: :stats_period,
|
|
114
|
+
description: "Time window for issue stats (e.g. '7d', '14d', '30d')",
|
|
115
|
+
optional: true,
|
|
116
|
+
type: String),
|
|
117
|
+
FastlaneCore::ConfigItem.new(key: :per_page,
|
|
118
|
+
description: "Number of issues to return (max 100)",
|
|
119
|
+
optional: true,
|
|
120
|
+
default_value: 25,
|
|
121
|
+
type: Integer),
|
|
122
|
+
FastlaneCore::ConfigItem.new(key: :cursor,
|
|
123
|
+
description: "Pagination cursor for fetching next page of results",
|
|
124
|
+
optional: true,
|
|
125
|
+
type: String)
|
|
126
|
+
]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def output
|
|
130
|
+
[
|
|
131
|
+
['SENTRY_ISSUES', 'Array of issue hashes with :id, :short_id, :title, :event_count, :user_count, :first_seen, :last_seen, :level, :status'],
|
|
132
|
+
['SENTRY_ISSUE_COUNT', 'Number of issues returned'],
|
|
133
|
+
['SENTRY_ISSUES_STATUS_CODE', 'HTTP status code from the Sentry API'],
|
|
134
|
+
['SENTRY_ISSUES_JSON', 'Raw JSON response from the Sentry API']
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def return_value
|
|
139
|
+
"A hash with :issues (array of issue hashes) and :count (number of issues)."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def authors
|
|
143
|
+
["crazymanish"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def example_code
|
|
147
|
+
[
|
|
148
|
+
'# Fetch unresolved issues sorted by frequency
|
|
149
|
+
result = sentry_list_issues(query: "is:unresolved", sort: "freq", per_page: 10)
|
|
150
|
+
result[:issues].each do |issue|
|
|
151
|
+
UI.message("##{issue[:short_id]}: #{issue[:title]} (#{issue[:event_count]} events)")
|
|
152
|
+
end',
|
|
153
|
+
|
|
154
|
+
'# Fetch issues for a specific release
|
|
155
|
+
sentry_list_issues(query: "is:unresolved release:v25.10.0", sort: "freq")',
|
|
156
|
+
|
|
157
|
+
'# Fetch new issues in the latest release
|
|
158
|
+
sentry_list_issues(query: "is:unresolved first-release:v25.10.0", sort: "date")'
|
|
159
|
+
]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def category
|
|
163
|
+
:misc
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def is_supported?(platform)
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def build_query_params(params)
|
|
173
|
+
query_params = {}
|
|
174
|
+
|
|
175
|
+
query_params[:query] = params[:query] if params[:query]
|
|
176
|
+
query_params[:sort] = params[:sort] if params[:sort]
|
|
177
|
+
query_params[:statsPeriod] = params[:stats_period] if params[:stats_period]
|
|
178
|
+
query_params[:per_page] = params[:per_page].to_s if params[:per_page]
|
|
179
|
+
query_params[:cursor] = params[:cursor] if params[:cursor]
|
|
180
|
+
|
|
181
|
+
query_params
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_response(json)
|
|
185
|
+
return [] unless json.is_a?(Array)
|
|
186
|
+
|
|
187
|
+
json.map do |issue|
|
|
188
|
+
{
|
|
189
|
+
id: issue['id'],
|
|
190
|
+
short_id: issue['shortId'],
|
|
191
|
+
title: issue['title'],
|
|
192
|
+
culprit: issue['culprit'],
|
|
193
|
+
level: issue['level'],
|
|
194
|
+
status: issue['status'],
|
|
195
|
+
event_count: (issue['count'] || '0').to_i,
|
|
196
|
+
user_count: issue['userCount'] || 0,
|
|
197
|
+
first_seen: issue['firstSeen'],
|
|
198
|
+
last_seen: issue['lastSeen'],
|
|
199
|
+
permalink: issue['permalink'],
|
|
200
|
+
metadata: issue['metadata']
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|