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,573 @@
|
|
|
1
|
+
require 'fastlane/action'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../helper/sentry_api_helper'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
module SharedValues
|
|
9
|
+
SENTRY_SLO_REPORT = :SENTRY_SLO_REPORT
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Orchestrator action that produces a comprehensive SLO report by querying
|
|
13
|
+
# crash-free rates (availability), TTID percentiles (latency), and release issues.
|
|
14
|
+
# Supports week-over-week, release-over-release comparisons, and issues diff.
|
|
15
|
+
class SentrySloReportAction < 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
|
+
project_slug = params[:project_slug]
|
|
22
|
+
environment = params[:environment]
|
|
23
|
+
stats_period = params[:stats_period]
|
|
24
|
+
days = parse_days(stats_period)
|
|
25
|
+
|
|
26
|
+
report = {
|
|
27
|
+
generated_at: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
28
|
+
period: stats_period,
|
|
29
|
+
environment: environment,
|
|
30
|
+
availability: {},
|
|
31
|
+
latency: {},
|
|
32
|
+
issues: {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# ── AVAILABILITY (Crash-Free Sessions) ──────────────────────────
|
|
36
|
+
UI.header("Availability (Crash-Free Sessions)")
|
|
37
|
+
|
|
38
|
+
report[:availability][:current] = fetch_crash_free(
|
|
39
|
+
auth_token: auth_token, org_slug: org_slug, project_id: project_id,
|
|
40
|
+
environment: environment, stats_period: stats_period
|
|
41
|
+
)
|
|
42
|
+
log_availability("Current #{stats_period}", report[:availability][:current], params[:crash_free_target])
|
|
43
|
+
|
|
44
|
+
if params[:compare_weeks]
|
|
45
|
+
prev_dates = previous_period_dates(days)
|
|
46
|
+
report[:availability][:previous] = fetch_crash_free(
|
|
47
|
+
auth_token: auth_token, org_slug: org_slug, project_id: project_id,
|
|
48
|
+
environment: environment,
|
|
49
|
+
start_date: prev_dates[:start], end_date: prev_dates[:end]
|
|
50
|
+
)
|
|
51
|
+
log_availability("Previous #{stats_period}", report[:availability][:previous], params[:crash_free_target])
|
|
52
|
+
|
|
53
|
+
report[:availability][:delta] = compute_availability_delta(
|
|
54
|
+
report[:availability][:current], report[:availability][:previous]
|
|
55
|
+
)
|
|
56
|
+
log_delta(report[:availability][:delta])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
report[:availability][:target] = params[:crash_free_target]
|
|
60
|
+
report[:availability][:current_meets_target] = meets_target?(
|
|
61
|
+
report[:availability][:current][:crash_free_session_rate], params[:crash_free_target]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if params[:compare_releases]
|
|
65
|
+
report[:availability][:releases] = fetch_crash_free_by_release(
|
|
66
|
+
auth_token: auth_token, org_slug: org_slug, project_id: project_id,
|
|
67
|
+
environment: environment, stats_period: stats_period,
|
|
68
|
+
per_page: params[:release_count]
|
|
69
|
+
)
|
|
70
|
+
log_releases(report[:availability][:releases], params[:crash_free_target])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── LATENCY (TTID Percentiles) ──────────────────────────────────
|
|
74
|
+
UI.header("Latency (TTID Percentiles)")
|
|
75
|
+
|
|
76
|
+
report[:latency][:current] = fetch_ttid(
|
|
77
|
+
auth_token: auth_token, org_slug: org_slug, project_id: project_id,
|
|
78
|
+
environment: environment, stats_period: stats_period,
|
|
79
|
+
per_page: params[:ttid_screen_count]
|
|
80
|
+
)
|
|
81
|
+
log_ttid("Current #{stats_period}", report[:latency][:current], params[:ttid_p95_target_ms])
|
|
82
|
+
|
|
83
|
+
if params[:compare_weeks]
|
|
84
|
+
prev_dates = previous_period_dates(days)
|
|
85
|
+
report[:latency][:previous] = fetch_ttid(
|
|
86
|
+
auth_token: auth_token, org_slug: org_slug, project_id: project_id,
|
|
87
|
+
environment: environment,
|
|
88
|
+
start_date: prev_dates[:start], end_date: prev_dates[:end],
|
|
89
|
+
per_page: params[:ttid_screen_count]
|
|
90
|
+
)
|
|
91
|
+
log_ttid("Previous #{stats_period}", report[:latency][:previous], params[:ttid_p95_target_ms])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
report[:latency][:target_p95_ms] = params[:ttid_p95_target_ms]
|
|
95
|
+
|
|
96
|
+
# ── ISSUES (Release Comparison) ─────────────────────────────────
|
|
97
|
+
if params[:current_release]
|
|
98
|
+
UI.header("Issues (Release Comparison)")
|
|
99
|
+
|
|
100
|
+
report[:issues][:current_release] = fetch_issues_for_release(
|
|
101
|
+
auth_token: auth_token, org_slug: org_slug, project_slug: project_slug,
|
|
102
|
+
release: params[:current_release], per_page: params[:issue_count]
|
|
103
|
+
)
|
|
104
|
+
log_issues(params[:current_release], report[:issues][:current_release])
|
|
105
|
+
|
|
106
|
+
if params[:previous_release]
|
|
107
|
+
report[:issues][:previous_release] = fetch_issues_for_release(
|
|
108
|
+
auth_token: auth_token, org_slug: org_slug, project_slug: project_slug,
|
|
109
|
+
release: params[:previous_release], per_page: params[:issue_count]
|
|
110
|
+
)
|
|
111
|
+
log_issues(params[:previous_release], report[:issues][:previous_release])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ── OUTPUT ──────────────────────────────────────────────────────
|
|
116
|
+
if params[:output_json]
|
|
117
|
+
File.write(params[:output_json], JSON.pretty_generate(report))
|
|
118
|
+
UI.success("SLO report JSON written to #{params[:output_json]}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Actions.lane_context[SharedValues::SENTRY_SLO_REPORT] = report
|
|
122
|
+
|
|
123
|
+
print_summary(report, params)
|
|
124
|
+
|
|
125
|
+
report
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#####################################################
|
|
129
|
+
# @!group Documentation
|
|
130
|
+
#####################################################
|
|
131
|
+
|
|
132
|
+
def description
|
|
133
|
+
"Generate a comprehensive SLO report with availability, latency, and issue comparison"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def details
|
|
137
|
+
[
|
|
138
|
+
"Orchestrates multiple Sentry API calls to produce a comprehensive SLO report including:",
|
|
139
|
+
" - Crash-free session/user rates (availability) with week-over-week delta",
|
|
140
|
+
" - Release-over-release crash-free rate comparison",
|
|
141
|
+
" - TTID p50/p75/p95 per screen (latency) with week-over-week delta",
|
|
142
|
+
" - Issue counts and top issues per release (latest vs previous)",
|
|
143
|
+
"",
|
|
144
|
+
"Outputs a structured hash to lane_context and optionally writes JSON to a file."
|
|
145
|
+
].join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def available_options
|
|
149
|
+
[
|
|
150
|
+
FastlaneCore::ConfigItem.new(key: :auth_token,
|
|
151
|
+
env_name: "SENTRY_AUTH_TOKEN",
|
|
152
|
+
description: "Sentry API Bearer auth token",
|
|
153
|
+
optional: false,
|
|
154
|
+
type: String,
|
|
155
|
+
sensitive: true,
|
|
156
|
+
code_gen_sensitive: true,
|
|
157
|
+
verify_block: proc do |value|
|
|
158
|
+
UI.user_error!("No Sentry auth token given") if value.to_s.empty?
|
|
159
|
+
end),
|
|
160
|
+
FastlaneCore::ConfigItem.new(key: :org_slug,
|
|
161
|
+
env_name: "SENTRY_ORG_SLUG",
|
|
162
|
+
description: "Sentry organization slug",
|
|
163
|
+
optional: false,
|
|
164
|
+
type: String),
|
|
165
|
+
FastlaneCore::ConfigItem.new(key: :project_id,
|
|
166
|
+
env_name: "SENTRY_PROJECT_ID",
|
|
167
|
+
description: "Sentry numeric project ID (for Sessions & Events APIs)",
|
|
168
|
+
optional: false,
|
|
169
|
+
type: String),
|
|
170
|
+
FastlaneCore::ConfigItem.new(key: :project_slug,
|
|
171
|
+
env_name: "SENTRY_PROJECT_SLUG",
|
|
172
|
+
description: "Sentry project slug (for Issues API)",
|
|
173
|
+
optional: false,
|
|
174
|
+
type: String),
|
|
175
|
+
FastlaneCore::ConfigItem.new(key: :environment,
|
|
176
|
+
env_name: "SENTRY_ENVIRONMENT",
|
|
177
|
+
description: "Environment filter",
|
|
178
|
+
optional: true,
|
|
179
|
+
default_value: "production",
|
|
180
|
+
type: String),
|
|
181
|
+
FastlaneCore::ConfigItem.new(key: :stats_period,
|
|
182
|
+
description: "Rolling time window (e.g. '7d', '14d')",
|
|
183
|
+
optional: true,
|
|
184
|
+
default_value: "7d",
|
|
185
|
+
type: String),
|
|
186
|
+
# ── Targets ──
|
|
187
|
+
FastlaneCore::ConfigItem.new(key: :crash_free_target,
|
|
188
|
+
description: "Target crash-free session rate (e.g. 0.998 = 99.8%)",
|
|
189
|
+
optional: true,
|
|
190
|
+
default_value: 0.998,
|
|
191
|
+
type: Float),
|
|
192
|
+
FastlaneCore::ConfigItem.new(key: :ttid_p95_target_ms,
|
|
193
|
+
description: "Target TTID p95 in milliseconds",
|
|
194
|
+
optional: true,
|
|
195
|
+
default_value: 1000,
|
|
196
|
+
type: Integer),
|
|
197
|
+
# ── Comparison flags ──
|
|
198
|
+
FastlaneCore::ConfigItem.new(key: :compare_weeks,
|
|
199
|
+
description: "Include week-over-week comparison",
|
|
200
|
+
optional: true,
|
|
201
|
+
default_value: true,
|
|
202
|
+
type: Fastlane::Boolean),
|
|
203
|
+
FastlaneCore::ConfigItem.new(key: :compare_releases,
|
|
204
|
+
description: "Include release-over-release comparison",
|
|
205
|
+
optional: true,
|
|
206
|
+
default_value: true,
|
|
207
|
+
type: Fastlane::Boolean),
|
|
208
|
+
# ── Release versions ──
|
|
209
|
+
FastlaneCore::ConfigItem.new(key: :current_release,
|
|
210
|
+
description: "Current release version for issue comparison (e.g. 'v25.10.0')",
|
|
211
|
+
optional: true,
|
|
212
|
+
type: String),
|
|
213
|
+
FastlaneCore::ConfigItem.new(key: :previous_release,
|
|
214
|
+
description: "Previous release version for issue comparison (e.g. 'v25.9.0')",
|
|
215
|
+
optional: true,
|
|
216
|
+
type: String),
|
|
217
|
+
# ── Limits ──
|
|
218
|
+
FastlaneCore::ConfigItem.new(key: :release_count,
|
|
219
|
+
description: "Number of releases to compare",
|
|
220
|
+
optional: true,
|
|
221
|
+
default_value: 5,
|
|
222
|
+
type: Integer),
|
|
223
|
+
FastlaneCore::ConfigItem.new(key: :ttid_screen_count,
|
|
224
|
+
description: "Number of top screens to include in TTID report",
|
|
225
|
+
optional: true,
|
|
226
|
+
default_value: 10,
|
|
227
|
+
type: Integer),
|
|
228
|
+
FastlaneCore::ConfigItem.new(key: :issue_count,
|
|
229
|
+
description: "Number of top issues to include per release",
|
|
230
|
+
optional: true,
|
|
231
|
+
default_value: 10,
|
|
232
|
+
type: Integer),
|
|
233
|
+
# ── Output ──
|
|
234
|
+
FastlaneCore::ConfigItem.new(key: :output_json,
|
|
235
|
+
description: "Path to write JSON report file (optional)",
|
|
236
|
+
optional: true,
|
|
237
|
+
type: String)
|
|
238
|
+
]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def output
|
|
242
|
+
[
|
|
243
|
+
['SENTRY_SLO_REPORT', 'Complete SLO report hash with :availability, :latency, :issues sections']
|
|
244
|
+
]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def return_value
|
|
248
|
+
"A hash with :availability, :latency, and :issues sections containing all SLO data."
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def authors
|
|
252
|
+
["crazymanish"]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def example_code
|
|
256
|
+
[
|
|
257
|
+
'# Full SLO report with WoW & RoR comparison
|
|
258
|
+
sentry_slo_report(
|
|
259
|
+
crash_free_target: 0.998,
|
|
260
|
+
ttid_p95_target_ms: 1000,
|
|
261
|
+
compare_weeks: true,
|
|
262
|
+
compare_releases: true,
|
|
263
|
+
current_release: "v25.10.0",
|
|
264
|
+
previous_release: "v25.9.0",
|
|
265
|
+
output_json: "slo_report.json"
|
|
266
|
+
)',
|
|
267
|
+
|
|
268
|
+
'# Quick availability check only
|
|
269
|
+
report = sentry_slo_report(
|
|
270
|
+
compare_weeks: true,
|
|
271
|
+
compare_releases: false
|
|
272
|
+
)
|
|
273
|
+
rate = report[:availability][:current][:crash_free_session_rate]
|
|
274
|
+
UI.important("Crash-free: #{(rate * 100).round(2)}%")'
|
|
275
|
+
]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def category
|
|
279
|
+
:misc
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def is_supported?(platform)
|
|
283
|
+
true
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
# ── Data Fetchers ─────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
def fetch_crash_free(auth_token:, org_slug:, project_id:, environment:, stats_period: nil, start_date: nil, end_date: nil)
|
|
291
|
+
params = {
|
|
292
|
+
field: ['crash_free_rate(session)', 'crash_free_rate(user)', 'sum(session)', 'count_unique(user)'],
|
|
293
|
+
project: project_id.to_s,
|
|
294
|
+
includeSeries: '0'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if start_date && end_date
|
|
298
|
+
params[:start] = start_date
|
|
299
|
+
params[:end] = end_date
|
|
300
|
+
else
|
|
301
|
+
params[:statsPeriod] = stats_period
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
params[:environment] = environment if environment
|
|
305
|
+
|
|
306
|
+
response = Helper::SentryApiHelper.get_sessions(auth_token: auth_token, org_slug: org_slug, params: params)
|
|
307
|
+
|
|
308
|
+
unless response[:status].between?(200, 299)
|
|
309
|
+
UI.error("Sentry Sessions API error #{response[:status]}: #{response[:body]}")
|
|
310
|
+
return empty_crash_free
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
totals = response[:json]&.dig('groups', 0, 'totals') || {}
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
crash_free_session_rate: totals['crash_free_rate(session)'],
|
|
317
|
+
crash_free_user_rate: totals['crash_free_rate(user)'],
|
|
318
|
+
total_sessions: totals['sum(session)'],
|
|
319
|
+
total_users: totals['count_unique(user)']
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def fetch_crash_free_by_release(auth_token:, org_slug:, project_id:, environment:, stats_period:, per_page:)
|
|
324
|
+
params = {
|
|
325
|
+
field: ['crash_free_rate(session)', 'crash_free_rate(user)', 'sum(session)'],
|
|
326
|
+
groupBy: 'release',
|
|
327
|
+
project: project_id.to_s,
|
|
328
|
+
statsPeriod: stats_period,
|
|
329
|
+
per_page: per_page.to_s,
|
|
330
|
+
orderBy: '-sum(session)',
|
|
331
|
+
includeSeries: '0'
|
|
332
|
+
}
|
|
333
|
+
params[:environment] = environment if environment
|
|
334
|
+
|
|
335
|
+
response = Helper::SentryApiHelper.get_sessions(auth_token: auth_token, org_slug: org_slug, params: params)
|
|
336
|
+
|
|
337
|
+
unless response[:status].between?(200, 299)
|
|
338
|
+
UI.error("Sentry Sessions API error #{response[:status]}: #{response[:body]}")
|
|
339
|
+
return []
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
groups = response[:json]&.dig('groups') || []
|
|
343
|
+
groups.map do |group|
|
|
344
|
+
totals = group['totals'] || {}
|
|
345
|
+
{
|
|
346
|
+
release: group.dig('by', 'release'),
|
|
347
|
+
crash_free_session_rate: totals['crash_free_rate(session)'],
|
|
348
|
+
crash_free_user_rate: totals['crash_free_rate(user)'],
|
|
349
|
+
total_sessions: totals['sum(session)']
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def fetch_ttid(auth_token:, org_slug:, project_id:, environment:, stats_period: nil, start_date: nil, end_date: nil, per_page: 10)
|
|
355
|
+
fields = [
|
|
356
|
+
'transaction',
|
|
357
|
+
'p50(measurements.time_to_initial_display)',
|
|
358
|
+
'p75(measurements.time_to_initial_display)',
|
|
359
|
+
'p95(measurements.time_to_initial_display)',
|
|
360
|
+
'count()'
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
params = {
|
|
364
|
+
dataset: 'metrics',
|
|
365
|
+
field: fields,
|
|
366
|
+
project: project_id.to_s,
|
|
367
|
+
query: 'event.type:transaction transaction.op:ui.load',
|
|
368
|
+
sort: '-count()',
|
|
369
|
+
per_page: per_page.to_s
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if start_date && end_date
|
|
373
|
+
params[:start] = start_date
|
|
374
|
+
params[:end] = end_date
|
|
375
|
+
else
|
|
376
|
+
params[:statsPeriod] = stats_period
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
params[:environment] = environment if environment
|
|
380
|
+
|
|
381
|
+
response = Helper::SentryApiHelper.get_events(auth_token: auth_token, org_slug: org_slug, params: params)
|
|
382
|
+
|
|
383
|
+
unless response[:status].between?(200, 299)
|
|
384
|
+
UI.error("Sentry Events API error #{response[:status]}: #{response[:body]}")
|
|
385
|
+
return []
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
data = response[:json]&.dig('data') || []
|
|
389
|
+
data.map do |row|
|
|
390
|
+
{
|
|
391
|
+
transaction: row['transaction'],
|
|
392
|
+
p50: round_ms(row['p50(measurements.time_to_initial_display)']),
|
|
393
|
+
p75: round_ms(row['p75(measurements.time_to_initial_display)']),
|
|
394
|
+
p95: round_ms(row['p95(measurements.time_to_initial_display)']),
|
|
395
|
+
count: row['count()']
|
|
396
|
+
}
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def fetch_issues_for_release(auth_token:, org_slug:, project_slug:, release:, per_page:)
|
|
401
|
+
response = Helper::SentryApiHelper.get_issues(
|
|
402
|
+
auth_token: auth_token,
|
|
403
|
+
org_slug: org_slug,
|
|
404
|
+
project_slug: project_slug,
|
|
405
|
+
params: {
|
|
406
|
+
query: "is:unresolved release:#{release}",
|
|
407
|
+
sort: 'freq',
|
|
408
|
+
per_page: per_page.to_s
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
unless response[:status].between?(200, 299)
|
|
413
|
+
UI.error("Sentry Issues API error #{response[:status]}: #{response[:body]}")
|
|
414
|
+
return { version: release, count: 0, issues: [] }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
issues_data = response[:json] || []
|
|
418
|
+
issues = issues_data.map do |issue|
|
|
419
|
+
{
|
|
420
|
+
id: issue['id'],
|
|
421
|
+
short_id: issue['shortId'],
|
|
422
|
+
title: issue['title'],
|
|
423
|
+
event_count: (issue['count'] || '0').to_i,
|
|
424
|
+
user_count: issue['userCount'] || 0,
|
|
425
|
+
level: issue['level'],
|
|
426
|
+
first_seen: issue['firstSeen'],
|
|
427
|
+
last_seen: issue['lastSeen']
|
|
428
|
+
}
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
{ version: release, count: issues.length, issues: issues }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# ── Date Utilities ────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
def parse_days(stats_period)
|
|
437
|
+
match = stats_period.match(/^(\d+)d$/)
|
|
438
|
+
UI.user_error!("Invalid stats_period format '#{stats_period}'. Use format like '7d', '14d'.") unless match
|
|
439
|
+
|
|
440
|
+
match[1].to_i
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def previous_period_dates(days)
|
|
444
|
+
now = Time.now.utc
|
|
445
|
+
period_end = now - (days * 86_400)
|
|
446
|
+
period_start = now - (2 * days * 86_400)
|
|
447
|
+
|
|
448
|
+
{
|
|
449
|
+
start: period_start.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
450
|
+
end: period_end.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# ── Computation Helpers ───────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def compute_availability_delta(current, previous)
|
|
457
|
+
return {} unless current && previous
|
|
458
|
+
|
|
459
|
+
{
|
|
460
|
+
crash_free_session_rate: safe_delta(current[:crash_free_session_rate], previous[:crash_free_session_rate]),
|
|
461
|
+
crash_free_user_rate: safe_delta(current[:crash_free_user_rate], previous[:crash_free_user_rate])
|
|
462
|
+
}
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def safe_delta(a, b)
|
|
466
|
+
return nil if a.nil? || b.nil?
|
|
467
|
+
|
|
468
|
+
(a - b).round(6)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def meets_target?(value, target)
|
|
472
|
+
return false if value.nil? || target.nil?
|
|
473
|
+
|
|
474
|
+
value >= target
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def empty_crash_free
|
|
478
|
+
{ crash_free_session_rate: nil, crash_free_user_rate: nil, total_sessions: nil, total_users: nil }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def round_ms(value)
|
|
482
|
+
return nil if value.nil?
|
|
483
|
+
|
|
484
|
+
value.round(1)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# ── Logging Helpers ───────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
def log_availability(label, data, target)
|
|
490
|
+
rate = data[:crash_free_session_rate]
|
|
491
|
+
indicator = rate && target ? (rate >= target ? "✅" : "⚠️") : ""
|
|
492
|
+
UI.message(" #{label}: #{format_pct(rate)} #{indicator} (target: #{format_pct(target)})")
|
|
493
|
+
UI.message(" Sessions: #{data[:total_sessions]}, Users: #{data[:total_users]}")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def log_delta(delta)
|
|
497
|
+
session_delta = delta[:crash_free_session_rate]
|
|
498
|
+
sign = session_delta && session_delta >= 0 ? "+" : ""
|
|
499
|
+
UI.message(" Delta: #{sign}#{format_pct(session_delta)}")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def log_releases(releases, target)
|
|
503
|
+
UI.message(" Release-over-Release:")
|
|
504
|
+
releases.each do |r|
|
|
505
|
+
rate = r[:crash_free_session_rate]
|
|
506
|
+
indicator = rate && target ? (rate >= target ? "✅" : "⚠️") : ""
|
|
507
|
+
UI.message(" #{r[:release]}: #{format_pct(rate)} #{indicator}")
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def log_ttid(label, screens, target_p95)
|
|
512
|
+
UI.message(" #{label}: #{screens.length} screens")
|
|
513
|
+
screens.first(5).each do |s|
|
|
514
|
+
indicator = s[:p95] && target_p95 ? (s[:p95] <= target_p95 ? "✅" : "⚠️") : ""
|
|
515
|
+
UI.message(" #{s[:transaction]}: p50=#{s[:p50]}ms p75=#{s[:p75]}ms p95=#{s[:p95]}ms (#{s[:count]} loads) #{indicator}")
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def log_issues(release, data)
|
|
520
|
+
UI.message(" #{release}: #{data[:count]} unresolved issues")
|
|
521
|
+
data[:issues].first(3).each_with_index do |issue, idx|
|
|
522
|
+
UI.message(" #{idx + 1}. #{issue[:short_id]}: #{issue[:title]} (#{issue[:event_count]} events, #{issue[:user_count]} users)")
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def print_summary(report, params)
|
|
527
|
+
UI.message("")
|
|
528
|
+
UI.header("SLO Report Summary")
|
|
529
|
+
|
|
530
|
+
# Availability
|
|
531
|
+
current_rate = report.dig(:availability, :current, :crash_free_session_rate)
|
|
532
|
+
target = params[:crash_free_target]
|
|
533
|
+
indicator = current_rate && target ? (current_rate >= target ? "✅" : "⚠️") : ""
|
|
534
|
+
UI.message("Crash-free sessions: #{format_pct(current_rate)} #{indicator} (target: #{format_pct(target)})")
|
|
535
|
+
|
|
536
|
+
if report.dig(:availability, :delta)
|
|
537
|
+
delta = report[:availability][:delta][:crash_free_session_rate]
|
|
538
|
+
sign = delta && delta >= 0 ? "+" : ""
|
|
539
|
+
UI.message("Week-over-week delta: #{sign}#{format_pct(delta)}")
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Latency
|
|
543
|
+
screens = report.dig(:latency, :current) || []
|
|
544
|
+
if screens.any?
|
|
545
|
+
avg_p95 = screens.map { |s| s[:p95] }.compact
|
|
546
|
+
avg_p95_val = avg_p95.empty? ? nil : (avg_p95.sum / avg_p95.length).round(1)
|
|
547
|
+
target_p95 = params[:ttid_p95_target_ms]
|
|
548
|
+
indicator = avg_p95_val && target_p95 ? (avg_p95_val <= target_p95 ? "✅" : "⚠️") : ""
|
|
549
|
+
UI.message("TTID avg p95: #{avg_p95_val}ms #{indicator} (target: #{target_p95}ms)")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Issues
|
|
553
|
+
if report.dig(:issues, :current_release)
|
|
554
|
+
current_issues = report[:issues][:current_release]
|
|
555
|
+
UI.message("Issues in #{current_issues[:version]}: #{current_issues[:count]}")
|
|
556
|
+
if report.dig(:issues, :previous_release)
|
|
557
|
+
prev_issues = report[:issues][:previous_release]
|
|
558
|
+
UI.message("Issues in #{prev_issues[:version]}: #{prev_issues[:count]}")
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
UI.success("SLO report generated at #{report[:generated_at]}")
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def format_pct(value)
|
|
566
|
+
return "N/A" if value.nil?
|
|
567
|
+
|
|
568
|
+
"#{(value * 100).round(4)}%"
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|