fastlane-plugin-sentry_api 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb37a45115a453684dd66170705b0742827ed070a18f78bb11180d8d796b74a0
4
- data.tar.gz: b6775dc33d9ff13ca2152efc140fa74aa304e8e31251b9fc10bd3af08f5de4dd
3
+ metadata.gz: cee8dc5203226fee972dc466c125f43e872f655f4776dc703feb349a638a8ea1
4
+ data.tar.gz: 8d4f199b0714184b9475043557c0150da47fa83881b09de8e1ebe4b4866187e8
5
5
  SHA512:
6
- metadata.gz: ce63e72ef9919324c6107a90402407172ecaf58410e8b80b593fec312e6fb844901f8bd5c173574bb6e3a454a3fcdd09b09acfe37fe600c53f839c1af21859d2
7
- data.tar.gz: 2cf45d8c19fb2bc24f7ceea371d64ba2b490cb2ffe7cf8e1d2277b006661a9e6a6ef530008d64218f53fdb48b6ba4880c7762f6972ce73a766e7c47e63bb052a
6
+ metadata.gz: dbbb4b57bf4a3fa1530489172d9c40024e04f2b2c54ceb59ae2c79bc1f63488749528d708c7e31353212fbabf5e16c5a6ed26b893c5a2a96b9c7affed0d920e1
7
+ data.tar.gz: 1ef24556a924fd1a372e56e8693d8eac3c25295d4a30608cd3c72a5f064c6cc34d0ff8b96739da6cc88495858560cdc570d5fe97148ca9eb8982dde9178f2c62
data/README.md CHANGED
@@ -21,7 +21,8 @@ A Fastlane plugin providing reusable actions for querying [Sentry](https://sentr
21
21
  | [`sentry_api`](#sentry_api) | Generic GET request to any Sentry API endpoint |
22
22
  | [`sentry_crash_free_sessions`](#sentry_crash_free_sessions) | Crash-free session & user rates from the Sessions API |
23
23
  | [`sentry_crash_free_users`](#sentry_crash_free_users) | User-focused crash-free rate (convenience wrapper) |
24
- | [`sentry_ttid_percentiles`](#sentry_ttid_percentiles) | TTID p50/p75/p95 per screen from the Discover API |
24
+ | [`sentry_ttid_percentiles`](#sentry_ttid_percentiles) | TTID p50/p75/p95 per screen (+ overall aggregate) from the Discover API |
25
+ | [`sentry_app_launch`](#sentry_app_launch) | App launch latency (cold start & warm start) percentiles |
25
26
  | [`sentry_list_issues`](#sentry_list_issues) | Fetch project issues with filtering & sorting |
26
27
  | [`sentry_slo_report`](#sentry_slo_report) | Comprehensive SLO report orchestrating all the above |
27
28
 
@@ -143,7 +144,7 @@ UI.message("Crash-free users: #{(rate * 100).round(2)}%")
143
144
 
144
145
  ### `sentry_ttid_percentiles`
145
146
 
146
- Query TTID (Time to Initial Display) percentiles per screen from the Sentry Events/Discover API. Returns p50, p75, p95 per screen transaction, sorted by load count.
147
+ Query TTID (Time to Initial Display) percentiles per screen from the Sentry Events/Discover API. Returns p50, p75, p95 per screen transaction, sorted by load count. Optionally fetches overall/aggregate TTID percentiles across all screens.
147
148
 
148
149
  **Parameters:**
149
150
 
@@ -160,8 +161,9 @@ Query TTID (Time to Initial Display) percentiles per screen from the Sentry Even
160
161
  | `transaction_op` | `String` | No | `ui.load` | Transaction operation filter |
161
162
  | `per_page` | `Integer` | No | `20` | Number of screens to return (max 100) |
162
163
  | `sort` | `String` | No | `-count()` | Sort order |
164
+ | `include_overall` | `Boolean` | No | `false` | Also fetch overall/aggregate TTID percentiles across all screens |
163
165
 
164
- **Output (SharedValues):** `SENTRY_TTID_DATA` (array of `{ transaction:, p50:, p75:, p95:, count: }`)
166
+ **Output (SharedValues):** `SENTRY_TTID_DATA` (array of `{ transaction:, p50:, p75:, p95:, count: }`), `SENTRY_TTID_OVERALL` (hash with `{ p50:, p75:, p95:, count: }` when `include_overall` is true)
165
167
 
166
168
  **Examples:**
167
169
 
@@ -172,6 +174,11 @@ screens.each do |s|
172
174
  UI.message("#{s[:transaction]}: p50=#{s[:p50]}ms p95=#{s[:p95]}ms (#{s[:count]} loads)")
173
175
  end
174
176
 
177
+ # With overall aggregate TTID
178
+ screens = sentry_ttid_percentiles(stats_period: "7d", per_page: 10, include_overall: true)
179
+ overall = lane_context[SharedValues::SENTRY_TTID_OVERALL]
180
+ UI.message("Overall TTID: p50=#{overall[:p50]}ms p95=#{overall[:p95]}ms") if overall
181
+
175
182
  # Filter by release
176
183
  sentry_ttid_percentiles(release: "v25.10.0", stats_period: "14d")
177
184
 
@@ -184,6 +191,47 @@ sentry_ttid_percentiles(
184
191
 
185
192
  ---
186
193
 
194
+ ### `sentry_app_launch`
195
+
196
+ Query app launch latency (cold start & warm start) percentiles from the Sentry Events/Discover API. Uses Sentry's `measurements.app_start_cold` and `measurements.app_start_warm` fields.
197
+
198
+ **Parameters:**
199
+
200
+ | Key | Type | Required | Default | Description |
201
+ |-----|------|----------|---------|-------------|
202
+ | `auth_token` | `String` | Yes | `SENTRY_AUTH_TOKEN` | API Bearer auth token |
203
+ | `org_slug` | `String` | Yes | `SENTRY_ORG_SLUG` | Organization slug |
204
+ | `project_id` | `String` | Yes | `SENTRY_PROJECT_ID` | Numeric project ID |
205
+ | `environment` | `String` | No | `production` | Environment filter |
206
+ | `stats_period` | `String` | No | `7d` | Rolling window |
207
+ | `start_date` | `String` | No | — | ISO 8601 start date |
208
+ | `end_date` | `String` | No | — | ISO 8601 end date |
209
+ | `release` | `String` | No | — | Filter by release version |
210
+
211
+ **Output (SharedValues):** `SENTRY_APP_LAUNCH_DATA` (hash with `:cold_start` and `:warm_start`, each containing `{ p50:, p75:, p95:, count: }`)
212
+
213
+ **Examples:**
214
+
215
+ ```ruby
216
+ # Fetch app launch metrics for the last 7 days
217
+ result = sentry_app_launch(stats_period: "7d")
218
+ cold = result[:cold_start]
219
+ warm = result[:warm_start]
220
+ UI.message("Cold start: p50=#{cold[:p50]}ms p95=#{cold[:p95]}ms (#{cold[:count]} launches)")
221
+ UI.message("Warm start: p50=#{warm[:p50]}ms p95=#{warm[:p95]}ms (#{warm[:count]} launches)")
222
+
223
+ # Filter by release
224
+ sentry_app_launch(release: "v25.10.0", stats_period: "14d")
225
+
226
+ # Custom date range
227
+ sentry_app_launch(
228
+ start_date: "2026-02-24T00:00:00Z",
229
+ end_date: "2026-03-03T00:00:00Z"
230
+ )
231
+ ```
232
+
233
+ ---
234
+
187
235
  ### `sentry_list_issues`
188
236
 
189
237
  Fetch issues from a Sentry project. Supports filtering by release version, query string, sort order, and pagination. Useful for comparing issues across releases.
@@ -226,8 +274,10 @@ sentry_list_issues(query: "is:unresolved first-release:v25.10.0", sort: "date")
226
274
  Generate a comprehensive SLO report by orchestrating multiple Sentry API calls. Produces a structured report with:
227
275
 
228
276
  - **Availability** — Crash-free session/user rates with week-over-week delta
229
- - **Latency** — TTID p50/p75/p95 per screen with week-over-week delta
277
+ - **Latency (TTID)** — Overall/aggregate TTID percentiles + per-screen p50/p75/p95 with week-over-week delta
278
+ - **Latency (App Launch)** — Cold start & warm start percentiles with week-over-week delta
230
279
  - **Release comparison** — Release-over-release crash-free rates
280
+ - **Top Crash Issues** — Top unhandled error issues by frequency (`is:unresolved issue.category:error error.unhandled:true`)
231
281
  - **Issues** — Issue counts and top issues per release (latest vs previous)
232
282
 
233
283
  Includes target checking with ✅/⚠️ indicators and optional JSON file output.
@@ -244,6 +294,7 @@ Includes target checking with ✅/⚠️ indicators and optional JSON file outpu
244
294
  | `stats_period` | `String` | No | `7d` | Rolling window |
245
295
  | `crash_free_target` | `Float` | No | `0.998` | Target crash-free session rate (e.g. 0.998 = 99.8%) |
246
296
  | `ttid_p95_target_ms` | `Integer` | No | `1000` | Target TTID p95 in milliseconds |
297
+ | `app_launch_p95_target_ms` | `Integer` | No | `2000` | Target app launch (cold start) p95 in milliseconds |
247
298
  | `compare_weeks` | `Boolean` | No | `true` | Include week-over-week comparison |
248
299
  | `compare_releases` | `Boolean` | No | `true` | Include release-over-release comparison |
249
300
  | `current_release` | `String` | No | — | Current release version for issue comparison |
@@ -251,10 +302,22 @@ Includes target checking with ✅/⚠️ indicators and optional JSON file outpu
251
302
  | `release_count` | `Integer` | No | `5` | Number of releases to compare |
252
303
  | `ttid_screen_count` | `Integer` | No | `10` | Number of top screens in TTID report |
253
304
  | `issue_count` | `Integer` | No | `10` | Number of top issues per release |
305
+ | `crash_issue_count` | `Integer` | No | `5` | Number of top crash (unhandled error) issues to include |
254
306
  | `output_json` | `String` | No | — | Path to write JSON report file |
255
307
 
256
308
  **Output (SharedValues):** `SENTRY_SLO_REPORT` (complete hash with `:availability`, `:latency`, `:issues`)
257
309
 
310
+ The `:latency` section includes:
311
+ - `:current` — array of per-screen TTID data
312
+ - `:overall` — aggregate TTID `{ p50:, p75:, p95:, count: }` across all screens
313
+ - `:app_launch` — `{ cold: { p50:, p75:, p95:, count: }, warm: { ... } }`
314
+ - `:previous`, `:overall_previous`, `:app_launch_previous` — week-over-week counterparts (when `compare_weeks` is true)
315
+
316
+ The `:issues` section includes:
317
+ - `:top_crashes` — array of top unhandled error issues (always fetched, not release-scoped)
318
+ - `:current_release` — `{ version:, count:, issues: [] }` (when `current_release` is provided)
319
+ - `:previous_release` — same structure (when `previous_release` is provided)
320
+
258
321
  **Examples:**
259
322
 
260
323
  ```ruby
@@ -262,6 +325,7 @@ Includes target checking with ✅/⚠️ indicators and optional JSON file outpu
262
325
  sentry_slo_report(
263
326
  crash_free_target: 0.998,
264
327
  ttid_p95_target_ms: 1000,
328
+ app_launch_p95_target_ms: 2000,
265
329
  compare_weeks: true,
266
330
  compare_releases: true,
267
331
  current_release: "v25.10.0",
@@ -269,6 +333,22 @@ sentry_slo_report(
269
333
  output_json: "slo_report.json"
270
334
  )
271
335
 
336
+ # Access report data
337
+ report = lane_context[SharedValues::SENTRY_SLO_REPORT]
338
+
339
+ # Overall TTID
340
+ overall = report[:latency][:overall]
341
+ UI.message("Overall TTID p95: #{overall[:p95]}ms")
342
+
343
+ # App launch
344
+ cold = report[:latency][:app_launch][:cold]
345
+ UI.message("Cold start p95: #{cold[:p95]}ms")
346
+
347
+ # Top crash issues
348
+ report[:issues][:top_crashes].each do |issue|
349
+ UI.message("#{issue[:short_id]}: #{issue[:title]} (#{issue[:event_count]} events)")
350
+ end
351
+
272
352
  # Quick availability check only
273
353
  report = sentry_slo_report(
274
354
  compare_weeks: true,
@@ -292,10 +372,29 @@ lane :availability_check do
292
372
  UI.important("Crash-free session rate: #{(rate * 100).round(2)}%")
293
373
  end
294
374
 
375
+ lane :ttid_check do
376
+ screens = sentry_ttid_percentiles(stats_period: "7d", per_page: 10, include_overall: true)
377
+ screens.each do |s|
378
+ UI.message("#{s[:transaction]}: p50=#{s[:p50]}ms p95=#{s[:p95]}ms (#{s[:count]} loads)")
379
+ end
380
+
381
+ overall = lane_context[SharedValues::SENTRY_TTID_OVERALL]
382
+ UI.important("Overall TTID: p50=#{overall[:p50]}ms p95=#{overall[:p95]}ms") if overall
383
+ end
384
+
385
+ lane :app_launch_check do
386
+ result = sentry_app_launch(stats_period: "7d")
387
+ cold = result[:cold_start]
388
+ warm = result[:warm_start]
389
+ UI.message("Cold start: p50=#{cold[:p50]}ms p95=#{cold[:p95]}ms")
390
+ UI.message("Warm start: p50=#{warm[:p50]}ms p95=#{warm[:p95]}ms")
391
+ end
392
+
295
393
  lane :slo_report do
296
394
  sentry_slo_report(
297
395
  crash_free_target: 0.998,
298
396
  ttid_p95_target_ms: 1000,
397
+ app_launch_p95_target_ms: 2000,
299
398
  current_release: "v25.10.0",
300
399
  previous_release: "v25.9.0",
301
400
  output_json: "slo_report.json"
@@ -59,32 +59,32 @@ module Fastlane
59
59
  def available_options
60
60
  [
61
61
  FastlaneCore::ConfigItem.new(key: :auth_token,
62
- env_name: "SENTRY_AUTH_TOKEN",
63
- description: "Sentry API Bearer auth token",
64
- optional: false,
62
+ env_name: "SENTRY_AUTH_TOKEN",
63
+ description: "Sentry API Bearer auth token",
64
+ optional: false,
65
65
  type: String,
66
- sensitive: true,
67
- code_gen_sensitive: true,
68
- verify_block: proc do |value|
69
- UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
70
- end),
66
+ sensitive: true,
67
+ code_gen_sensitive: true,
68
+ verify_block: proc do |value|
69
+ UI.user_error!("No Sentry auth token given, pass using `auth_token: 'token'`") if value.to_s.empty?
70
+ end),
71
71
  FastlaneCore::ConfigItem.new(key: :server_url,
72
- env_name: "SENTRY_API_SERVER_URL",
73
- description: "Sentry API base URL",
74
- optional: true,
75
- default_value: "https://sentry.io/api/0",
72
+ env_name: "SENTRY_API_SERVER_URL",
73
+ description: "Sentry API base URL",
74
+ optional: true,
75
+ default_value: "https://sentry.io/api/0",
76
76
  type: String),
77
77
  FastlaneCore::ConfigItem.new(key: :path,
78
- description: "API endpoint path (e.g. '/organizations/my-org/sessions/')",
79
- optional: false,
78
+ description: "API endpoint path (e.g. '/organizations/my-org/sessions/')",
79
+ optional: false,
80
80
  type: String,
81
- verify_block: proc do |value|
82
- UI.user_error!("API path cannot be empty") if value.to_s.empty?
83
- end),
81
+ verify_block: proc do |value|
82
+ UI.user_error!("API path cannot be empty") if value.to_s.empty?
83
+ end),
84
84
  FastlaneCore::ConfigItem.new(key: :params,
85
- description: "Query parameters hash. Array values produce repeated keys.",
86
- optional: true,
87
- default_value: {},
85
+ description: "Query parameters hash. Array values produce repeated keys.",
86
+ optional: true,
87
+ default_value: {},
88
88
  type: Hash)
89
89
  ]
90
90
  end
@@ -0,0 +1,260 @@
1
+ require 'fastlane/action'
2
+ require_relative '../helper/sentry_api_helper'
3
+
4
+ module Fastlane
5
+ module Actions
6
+ module SharedValues
7
+ SENTRY_APP_LAUNCH_DATA = :SENTRY_APP_LAUNCH_DATA
8
+ SENTRY_APP_LAUNCH_STATUS_CODE = :SENTRY_APP_LAUNCH_STATUS_CODE
9
+ SENTRY_APP_LAUNCH_JSON = :SENTRY_APP_LAUNCH_JSON
10
+ end
11
+
12
+ # Query app launch (cold start & warm start) percentiles from the Sentry Events/Discover API.
13
+ # Returns p50, p75, p95 metrics for cold start and warm start durations.
14
+ class SentryAppLaunchAction < 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
+ result = {}
22
+
23
+ # Fetch cold start metrics
24
+ cold_params = build_query_params(params, project_id, :cold)
25
+ UI.message("Fetching cold start metrics from Sentry (#{cold_params[:statsPeriod] || 'custom range'})...")
26
+
27
+ cold_response = Helper::SentryApiHelper.get_events(
28
+ auth_token: auth_token,
29
+ org_slug: org_slug,
30
+ params: cold_params
31
+ )
32
+
33
+ unless cold_response[:status].between?(200, 299)
34
+ UI.user_error!("Sentry Events API error #{cold_response[:status]}: #{cold_response[:body]}")
35
+ return nil
36
+ end
37
+
38
+ result[:cold_start] = parse_response(cold_response[:json], :cold)
39
+
40
+ # Fetch warm start metrics
41
+ warm_params = build_query_params(params, project_id, :warm)
42
+ UI.message("Fetching warm start metrics from Sentry...")
43
+
44
+ warm_response = Helper::SentryApiHelper.get_events(
45
+ auth_token: auth_token,
46
+ org_slug: org_slug,
47
+ params: warm_params
48
+ )
49
+
50
+ unless warm_response[:status].between?(200, 299)
51
+ UI.user_error!("Sentry Events API error #{warm_response[:status]}: #{warm_response[:body]}")
52
+ return nil
53
+ end
54
+
55
+ result[:warm_start] = parse_response(warm_response[:json], :warm)
56
+
57
+ Actions.lane_context[SharedValues::SENTRY_APP_LAUNCH_STATUS_CODE] = cold_response[:status]
58
+ Actions.lane_context[SharedValues::SENTRY_APP_LAUNCH_JSON] = {
59
+ cold: cold_response[:json],
60
+ warm: warm_response[:json]
61
+ }
62
+ Actions.lane_context[SharedValues::SENTRY_APP_LAUNCH_DATA] = result
63
+
64
+ log_result(result)
65
+
66
+ result
67
+ end
68
+
69
+ #####################################################
70
+ # @!group Documentation
71
+ #####################################################
72
+
73
+ def description
74
+ "Query app launch (cold start & warm start) latency percentiles from Sentry"
75
+ end
76
+
77
+ def details
78
+ [
79
+ "Queries the Sentry Events/Discover API for app launch latency metrics.",
80
+ "Returns p50, p75, and p95 percentiles for both cold start and warm start durations.",
81
+ "",
82
+ "Cold start: Full app initialization from a terminated state.",
83
+ "Warm start: App resume from a backgrounded/cached state.",
84
+ "",
85
+ "Uses Sentry's `measurements.app_start_cold` and `measurements.app_start_warm` fields.",
86
+ "Supports filtering by release, environment, and time range.",
87
+ "",
88
+ "API Documentation: https://docs.sentry.io/api/discover/"
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')",
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. 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. Use with start_date instead of stats_period",
137
+ optional: true,
138
+ type: String),
139
+ FastlaneCore::ConfigItem.new(key: :release,
140
+ description: "Filter by specific release version",
141
+ optional: true,
142
+ type: String)
143
+ ]
144
+ end
145
+
146
+ def output
147
+ [
148
+ ['SENTRY_APP_LAUNCH_DATA', 'Hash with :cold_start and :warm_start, each containing :p50, :p75, :p95, :count'],
149
+ ['SENTRY_APP_LAUNCH_STATUS_CODE', 'HTTP status code from the Sentry API'],
150
+ ['SENTRY_APP_LAUNCH_JSON', 'Raw JSON responses from the Sentry API (cold and warm)']
151
+ ]
152
+ end
153
+
154
+ def return_value
155
+ "A hash with :cold_start and :warm_start keys, each containing :p50, :p75, :p95 (in ms), and :count."
156
+ end
157
+
158
+ def authors
159
+ ["crazymanish"]
160
+ end
161
+
162
+ def example_code
163
+ [
164
+ '# Fetch app launch metrics for the last 7 days
165
+ result = sentry_app_launch(stats_period: "7d")
166
+ cold = result[:cold_start]
167
+ warm = result[:warm_start]
168
+ UI.message("Cold start: p50=#{cold[:p50]}ms p95=#{cold[:p95]}ms")
169
+ UI.message("Warm start: p50=#{warm[:p50]}ms p95=#{warm[:p95]}ms")',
170
+
171
+ '# Filter by release
172
+ sentry_app_launch(release: "v25.10.0", stats_period: "14d")',
173
+
174
+ '# Custom date range
175
+ sentry_app_launch(start_date: "2026-02-24T00:00:00Z", end_date: "2026-03-03T00:00:00Z")'
176
+ ]
177
+ end
178
+
179
+ def category
180
+ :misc
181
+ end
182
+
183
+ def is_supported?(platform)
184
+ true
185
+ end
186
+
187
+ private
188
+
189
+ def build_query_params(params, project_id, start_type)
190
+ measurement = start_type == :cold ? 'app_start_cold' : 'app_start_warm'
191
+
192
+ fields = [
193
+ "p50(measurements.#{measurement})",
194
+ "p75(measurements.#{measurement})",
195
+ "p95(measurements.#{measurement})",
196
+ 'count()'
197
+ ]
198
+
199
+ query_parts = ['event.type:transaction']
200
+ query_parts << "has:measurements.#{measurement}"
201
+ query_parts << "release:#{params[:release]}" if params[:release]
202
+
203
+ query_params = {
204
+ dataset: 'metrics',
205
+ field: fields,
206
+ project: project_id.to_s,
207
+ query: query_parts.join(' '),
208
+ per_page: '1'
209
+ }
210
+
211
+ if params[:start_date] && params[:end_date]
212
+ query_params[:start] = params[:start_date]
213
+ query_params[:end] = params[:end_date]
214
+ else
215
+ query_params[:statsPeriod] = params[:stats_period] || '7d'
216
+ end
217
+
218
+ query_params[:environment] = params[:environment] if params[:environment]
219
+
220
+ query_params
221
+ end
222
+
223
+ def parse_response(json, start_type)
224
+ measurement = start_type == :cold ? 'app_start_cold' : 'app_start_warm'
225
+ row = json&.dig('data', 0) || {}
226
+
227
+ {
228
+ p50: round_ms(row["p50(measurements.#{measurement})"]),
229
+ p75: round_ms(row["p75(measurements.#{measurement})"]),
230
+ p95: round_ms(row["p95(measurements.#{measurement})"]),
231
+ count: row['count()']
232
+ }
233
+ end
234
+
235
+ def round_ms(value)
236
+ return nil if value.nil?
237
+
238
+ value.round(1)
239
+ end
240
+
241
+ def log_result(result)
242
+ cold = result[:cold_start]
243
+ warm = result[:warm_start]
244
+
245
+ if cold[:p50]
246
+ UI.success("Cold start: p50=#{cold[:p50]}ms p75=#{cold[:p75]}ms p95=#{cold[:p95]}ms (#{cold[:count]} launches)")
247
+ else
248
+ UI.message("Cold start: no data available")
249
+ end
250
+
251
+ if warm[:p50]
252
+ UI.success("Warm start: p50=#{warm[:p50]}ms p75=#{warm[:p75]}ms p95=#{warm[:p95]}ms (#{warm[:count]} launches)")
253
+ else
254
+ UI.message("Warm start: no data available")
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -92,63 +92,63 @@ module Fastlane
92
92
  def available_options
93
93
  [
94
94
  FastlaneCore::ConfigItem.new(key: :auth_token,
95
- env_name: "SENTRY_AUTH_TOKEN",
96
- description: "Sentry API Bearer auth token",
97
- optional: false,
95
+ env_name: "SENTRY_AUTH_TOKEN",
96
+ description: "Sentry API Bearer auth token",
97
+ optional: false,
98
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),
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
104
  FastlaneCore::ConfigItem.new(key: :org_slug,
105
- env_name: "SENTRY_ORG_SLUG",
106
- description: "Sentry organization slug",
107
- optional: false,
105
+ env_name: "SENTRY_ORG_SLUG",
106
+ description: "Sentry organization slug",
107
+ optional: false,
108
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),
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
112
  FastlaneCore::ConfigItem.new(key: :project_id,
113
- env_name: "SENTRY_PROJECT_ID",
114
- description: "Sentry numeric project ID",
115
- optional: false,
113
+ env_name: "SENTRY_PROJECT_ID",
114
+ description: "Sentry numeric project ID",
115
+ optional: false,
116
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),
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
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",
121
+ env_name: "SENTRY_ENVIRONMENT",
122
+ description: "Environment filter (e.g. 'production')",
123
+ optional: true,
124
+ default_value: "production",
125
125
  type: String),
126
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",
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
130
  type: String),
131
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,
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
134
  type: String),
135
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,
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
138
  type: String),
139
139
  FastlaneCore::ConfigItem.new(key: :group_by,
140
- description: "Group results by dimension: 'release', 'environment', or nil for aggregate",
141
- optional: true,
140
+ description: "Group results by dimension: 'release', 'environment', or nil for aggregate",
141
+ optional: true,
142
142
  type: String),
143
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,
144
+ description: "Number of groups to return when group_by is set (max 100)",
145
+ optional: true,
146
+ default_value: 10,
147
147
  type: Integer),
148
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)",
149
+ description: "Sort order for grouped results (e.g. '-sum(session)', '-crash_free_rate(session)')",
150
+ optional: true,
151
+ default_value: "-sum(session)",
152
152
  type: String)
153
153
  ]
154
154
  end