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,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