tracksiy 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -137,6 +137,8 @@ Labels: `bug`, `feature`, `question`, `blocker`. `npx tracksiy support` prints t
137
137
  | `budgets check` | Judge every budget now -- for one release with --release. Exit code 10 if one is crossed. For a deploy to stop on. | read | `budgets_check` |
138
138
  | `performance findings` | Routes that got slower after a release and N+1 queries, which Tracksiy raised as issues on its own. | read | `performance_findings` |
139
139
  | `monitors timing <slug>` | Where an HTTP monitor's time goes (DNS, connect, server, TTFB, download, page size); how long a job runs and how close to its max runtime. | read | `monitors_timing` |
140
+ | `storage` | What each project holds on the server (events, transactions, source maps, debug files), largest first, with its retention limits. | read | `storage` |
141
+ | `mobile` | How fast the app is on phones: app start (cold/warm) against the window before, per OS and release, and per screen TTID/TTFD and slow/frozen frames, with checks for what the SDK does not report. | read | `mobile` |
140
142
  | `issues list` | Recent issues of a project, newest activity first, with the frames that matter inlined. | read | `issues_list` |
141
143
  | `issues view <id>` | One issue in enough depth to act on: latest event, stack frames, tags, recent volume. | read | `issues_view` |
142
144
  | `issues resolve <ids...>` | Mark issues resolved, optionally fixed in a release so old clients do not reopen them. Records why. | write | `issues_resolve` |
@@ -165,6 +167,7 @@ Labels: `bug`, `feature`, `question`, `blocker`. `npx tracksiy support` prints t
165
167
  | `releases list` | What did the last deploys break? Recent releases with their events, issues and the issues they introduced. | read | `releases_list` |
166
168
  | `releases health` | Is the app crashing for its users, and did the last release make it worse? Crash-free sessions and users per release. | read | `releases_health` |
167
169
  | `releases declare <version>` | Create or update a release idempotently: its commit, URL, and when it was released. Run it from the deploy. | write | `releases_declare` |
170
+ | `releases verdict <version>` | Should the rollout of this release continue? Crash-free vs threshold and the previous build, issues spreading fast, new issues, budgets, regressions. Exit code 10 on fail. | read | `releases_verdict` |
168
171
  | `sourcemaps inject <paths...>` | Stamp built JavaScript and its source maps with a debug ID, so errors map to the exact build. Run after the build, before deploying. | write | `sourcemaps_inject` |
169
172
  | `sourcemaps upload <paths...>` | Upload built JavaScript and source maps so stack traces show original files and lines. Only missing bytes are sent. | write | `sourcemaps_upload` |
170
173
  | `proguard upload <mapping>` | Upload an Android ProGuard/R8 mapping so obfuscated stack traces are readable. | write | `proguard_upload` |
package/dist/tracksiy.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { readFileSync as readFileSync2 } from "node:fs";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = true ? "0.9.0" : "0.0.0-dev";
7
+ var VERSION = true ? "0.10.0" : "0.0.0-dev";
8
8
  var USER_AGENT = `tracksiy-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
9
9
 
10
10
  // src/core/errors.ts
@@ -81,7 +81,7 @@ var seg = (value) => encodeURIComponent(String(value));
81
81
  var IDEMPOTENT = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
82
82
  var RETRY_STATUS = /* @__PURE__ */ new Set([429, 502, 503, 504]);
83
83
  var MAX_ATTEMPTS = 3;
84
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ var sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
85
85
  var Api = class {
86
86
  url;
87
87
  hasToken;
@@ -822,7 +822,7 @@ var monitorsList = {
822
822
  status: { type: "string", enum: ["all", "up", "down", "never"], description: "Filter by state. Default all." },
823
823
  type: { type: "string", enum: ["heartbeat", "get", "ping", "ssl", "ingest"], description: "Only this monitor type." }
824
824
  },
825
- output: "{summary{total, up, down, never, unalertable}, monitors:[{id, name, project, monitorType, url, upNow, lastCheck, overdue, alertable, checkInUrl, downReason, lastCheckDetail{status, expectedStatus, bytes, truncated, expectedTextFound, forbiddenTextFound, error, ms}}], notes[]}",
825
+ output: "{summary{total, up, down, never, unalertable}, monitors:[{id, name, slug, project, monitorType, url, upNow, lastCheck, overdue, alertable, checkInUrl, downReason, lastCheckDetail{status, expectedStatus, bytes, truncated, expectedTextFound, forbiddenTextFound, error, ms}}], notes[]}",
826
826
  text: (d) => `${kv(d.summary ?? {})}
827
827
 
828
828
  ${table(d.monitors ?? [], [
@@ -1050,7 +1050,7 @@ var settingsGet = {
1050
1050
  context: "project",
1051
1051
  effect: "read",
1052
1052
  endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/settings" },
1053
- output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues}",
1053
+ output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues, storage{maxEventsPerIssue, sourcemapKeepReleases, sourcemapKeepUsedDays}, releaseHealth{crashFreeSessionsMin, minSessions, maxDropPct, velocityPct, velocityMinEvents}}",
1054
1054
  async run(ctx) {
1055
1055
  return ctx.api.get(await projectPath(ctx, "settings"));
1056
1056
  }
@@ -1082,15 +1082,24 @@ var settingsSet = {
1082
1082
  "fingerprint-rule": { type: "list", split: false, value: "type=<glob>,message=<glob>,fingerprint=<name>", description: "Group matching events into one issue. Repeatable; replaces the list." },
1083
1083
  "privacy-rule": { type: "list", split: false, value: "dropHeader=<name> | mask=<email|iban|card_number|tckn|jwt>,field=<path|*>", description: "Scrub before storage. Repeatable; replaces the list." },
1084
1084
  "performance-issues": { type: "boolean", description: "Raise issues on its own for routes slower after a release and N+1 queries (default on). --no-performance-issues turns it off." },
1085
+ "max-events-per-issue": { type: "integer", value: "<n>", description: "Stored events kept per issue: the newest n-1 plus the first (default 1000; 0 keeps all, otherwise at least 10). The issue's count is unaffected. Trimmed hourly." },
1086
+ "sourcemap-keep-releases": { type: "integer", value: "<n>", description: "Source maps kept per release line (<name>@...): the newest n releases (default 10; 0 keeps every release). Pruned nightly." },
1087
+ "sourcemap-keep-used-days": { type: "integer", value: "<days>", description: "Also keep bundles a stack trace used in this many days (default 30; mobile builds also while their release has events or sessions)." },
1088
+ "crash-free-min": { type: "number", value: "<pct>", description: "Release verdict / alert: crash-free sessions under this percentage fail a release (default 99)." },
1089
+ "min-sessions": { type: "integer", value: "<n>", description: "...judged from this many sessions on; below it the verdict is unknown (default 100)." },
1090
+ "max-drop-pct": { type: "number", value: "<points>", description: "...or this many points worse than the previous release of the line (default 0.5)." },
1091
+ "velocity-pct": { type: "number", value: "<pct>", description: "Velocity alert: one issue's events in 30 minutes reaching this % of the release's sessions in the last hour (default 1)." },
1092
+ "velocity-min-events": { type: "integer", value: "<n>", description: "...with at least this many events (default 10)." },
1085
1093
  body: { type: "json", value: "<json|@file|->", description: "The whole declaration; overrides the flags." }
1086
1094
  },
1087
1095
  examples: [
1088
1096
  { command: "tracksiy settings set --environments production,staging --environment-policy drop", description: "local and local1 stop reaching Tracksiy." },
1089
1097
  { command: "tracksiy settings set --fingerprint-rule 'type=ViewException,message=syntax error*,fingerprint=blade-syntax'", description: "Every half-written template is one issue." },
1090
1098
  { command: "tracksiy settings set --source-root /home/admin/web/shop/", description: "Frames carry repoPath." },
1091
- { command: "tracksiy settings set --source-root 'strip=/var/www/shop/,prepend=backend/,surface=backend'", description: "The server checkout is only the repo's backend/ directory." }
1099
+ { command: "tracksiy settings set --source-root 'strip=/var/www/shop/,prepend=backend/,surface=backend'", description: "The server checkout is only the repo's backend/ directory." },
1100
+ { command: "tracksiy settings set --max-events-per-issue 500 --sourcemap-keep-releases 5", description: "A noisy project keeps less on disk." }
1092
1101
  ],
1093
- output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues}",
1102
+ output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues, storage{maxEventsPerIssue, sourcemapKeepReleases, sourcemapKeepUsedDays}, releaseHealth{crashFreeSessionsMin, minSessions, maxDropPct, velocityPct, velocityMinEvents}}",
1094
1103
  async run(ctx) {
1095
1104
  const path = await projectPath(ctx, "settings");
1096
1105
  const i = ctx.input;
@@ -1102,7 +1111,21 @@ var settingsSet = {
1102
1111
  environmentPolicy: i.environmentPolicy ?? current.environmentPolicy ?? "flag",
1103
1112
  fingerprintRules: pairs(i.fingerprintRule, "fingerprint-rule") ?? current.fingerprintRules ?? [],
1104
1113
  privacyRules: pairs(i.privacyRule, "privacy-rule") ?? current.privacyRules ?? [],
1105
- performanceIssues: i.performanceIssues ?? current.performanceIssues ?? true
1114
+ performanceIssues: i.performanceIssues ?? current.performanceIssues ?? true,
1115
+ storage: {
1116
+ ...current.storage ?? {},
1117
+ ...i.maxEventsPerIssue !== void 0 ? { maxEventsPerIssue: i.maxEventsPerIssue } : {},
1118
+ ...i.sourcemapKeepReleases !== void 0 ? { sourcemapKeepReleases: i.sourcemapKeepReleases } : {},
1119
+ ...i.sourcemapKeepUsedDays !== void 0 ? { sourcemapKeepUsedDays: i.sourcemapKeepUsedDays } : {}
1120
+ },
1121
+ releaseHealth: {
1122
+ ...current.releaseHealth ?? {},
1123
+ ...i.crashFreeMin !== void 0 ? { crashFreeSessionsMin: i.crashFreeMin } : {},
1124
+ ...i.minSessions !== void 0 ? { minSessions: i.minSessions } : {},
1125
+ ...i.maxDropPct !== void 0 ? { maxDropPct: i.maxDropPct } : {},
1126
+ ...i.velocityPct !== void 0 ? { velocityPct: i.velocityPct } : {},
1127
+ ...i.velocityMinEvents !== void 0 ? { velocityMinEvents: i.velocityMinEvents } : {}
1128
+ }
1106
1129
  };
1107
1130
  return ctx.api.send("PUT", path, next);
1108
1131
  }
@@ -1126,7 +1149,7 @@ var privacyPreview = {
1126
1149
  var fleet_default = [orgs, projects, overview, changes, health, monitorsList, monitorsDeclare, monitorsDelete, monitorsDeleted, performance, queries, notFound, settingsGet, settingsSet, privacyPreview];
1127
1150
 
1128
1151
  // src/commands/perf.ts
1129
- var METRICS = "p50|p75|p95|p99|failure_rate|lcp|fcp|inp|cls|ttfb (transactions); response_p95|ttfb_p95 (HTTP monitors); duration_p95 (heartbeats)";
1152
+ var METRICS = "p50|p75|p95|p99|failure_rate|lcp|fcp|inp|cls|ttfb|app_start_cold|app_start_warm|ttid|ttfd|slow_frame_rate|frozen_frame_rate (transactions); response_p95|ttfb_p95 (HTTP monitors); duration_p95 (heartbeats)";
1130
1153
  var BUDGET_KEYS = {
1131
1154
  name: "name",
1132
1155
  transaction: "transaction",
@@ -1141,6 +1164,22 @@ var BUDGET_KEYS = {
1141
1164
  minSamples: "minSamples"
1142
1165
  };
1143
1166
  var NUMERIC = /* @__PURE__ */ new Set(["max", "windowMinutes", "minSamples"]);
1167
+ BUDGET_KEYS["window_minutes"] = "windowMinutes";
1168
+ BUDGET_KEYS["min_samples"] = "minSamples";
1169
+ function normalizeBudget(raw) {
1170
+ const out = {};
1171
+ for (const [key, value] of Object.entries(raw ?? {})) {
1172
+ out[BUDGET_KEYS[key] ?? key] = value;
1173
+ }
1174
+ return out;
1175
+ }
1176
+ function budgetsBody(body) {
1177
+ const wrapped = Array.isArray(body) ? { budgets: body } : body;
1178
+ if (!wrapped || !Array.isArray(wrapped.budgets)) {
1179
+ throw usageError("bad_value", '--body must be {"budgets": [...]} or a list of budgets.', `e.g. --body '{"budgets": [{"name": "init", "transaction": "GET /api/v1/init", "metric": "p95", "max": 800, "windowMinutes": 60}]}'`);
1180
+ }
1181
+ return { ...wrapped, budgets: wrapped.budgets.map(normalizeBudget) };
1182
+ }
1144
1183
  function parseBudget(raw) {
1145
1184
  const out = {};
1146
1185
  for (const part of String(raw).split(/,(?=\s*[\w-]+=)/)) {
@@ -1192,7 +1231,7 @@ var budgetsGet = {
1192
1231
  var budgetsSet = {
1193
1232
  path: ["budgets", "set"],
1194
1233
  summary: "Declare performance budgets: told once when one is crossed and once when it is met again.",
1195
- description: `The list replaces the stored one, by name (--merge keeps the others). Metrics: ${METRICS}. max is an upper bound: ms, a 0..1 ratio for failure_rate, unitless for cls. Below min-samples the verdict is unknown, never pass. Alerts go through the project's rules with performance on (the default).`,
1234
+ description: `The list replaces the stored one, by name (--merge keeps the others). Metrics: ${METRICS}. max is an upper bound: ms, a 0..1 ratio for failure_rate and the frame rates, unitless for cls. Below min-samples the verdict is unknown, never pass. Alerts go through the project's rules with performance on (the default). --body takes the API's shape, {"budgets": [{"name", "metric", "max", "transaction"?, "op"?, "monitor"? (slug or id), "windowMinutes"?, "minSamples"?}]}, or a bare list; the flag spellings window and min-samples are accepted there too, and --dry-run and --merge apply to it.`,
1196
1235
  context: "project",
1197
1236
  effect: "write",
1198
1237
  idempotent: true,
@@ -1211,23 +1250,27 @@ var budgetsSet = {
1211
1250
  examples: [
1212
1251
  { command: "tracksiy budgets set --budget 'name=init,transaction=GET /api/v1/init,metric=p95,max=800'", description: "Told when the init call's p95 passes 800 ms over an hour." },
1213
1252
  { command: "tracksiy budgets set --merge --budget 'name=inp,op=ui.interaction,metric=inp,max=200,window=1440'", description: "INP p75 over a day, across every route; keeps the other budgets." },
1214
- { command: "tracksiy budgets set --merge --budget 'name=backup,monitor=db-backup-s3,metric=duration_p95,max=1200000,window=10080,min-samples=3'", description: "Told before the nightly backup gets near its max runtime." }
1253
+ { command: "tracksiy budgets set --merge --budget 'name=backup,monitor=db-backup-s3,metric=duration_p95,max=1200000,window=10080,min-samples=3'", description: "Told before the nightly backup gets near its max runtime." },
1254
+ { command: "tracksiy budgets set --body @tools/tracksiy/budgets.json --dry-run", description: "The whole declaration from a file; shows the changes, writes nothing." },
1255
+ { command: "tracksiy budgets set --merge --budget 'name=site-ttfb,monitor=41,metric=ttfb_p95,max=800'", description: "A monitor by its id (from `monitors list`), for one whose name has spaces." }
1215
1256
  ],
1216
1257
  output: "The stored budgets and `changes` [{name, action}], same shape as `budgets get`.",
1217
1258
  text: budgetsText,
1218
1259
  async run(ctx) {
1219
1260
  const path = await projectPath(ctx, "budgets");
1220
1261
  const i = ctx.input;
1221
- if (i.body) return ctx.api.send("PUT", path, i.body);
1222
- if (!i.budget?.length && !i.merge) throw usageError("missing_flag", "No --budget given.", `Pass --budget ... (repeatable), or --body. To delete every budget: --body '{"budgets": []}'.`);
1223
- let budgets = (i.budget ?? []).map(parseBudget);
1262
+ if (!i.body && !i.budget?.length && !i.merge) throw usageError("missing_flag", "No --budget given.", `Pass --budget ... (repeatable), or --body. To delete every budget: --body '{"budgets": []}'.`);
1263
+ const fromBody = i.body ? budgetsBody(i.body) : void 0;
1264
+ let budgets = fromBody ? fromBody.budgets : (i.budget ?? []).map(parseBudget);
1265
+ if (fromBody && i.budget?.length) budgets = [...budgets, ...i.budget.map(parseBudget)];
1224
1266
  if (i.merge) {
1225
1267
  const current = await ctx.api.get(path);
1226
1268
  const named = new Set(budgets.map((b) => b.name));
1227
1269
  const kept = (current.budgets ?? []).filter((b) => !named.has(b.name)).map((b) => compact({ name: b.name, transaction: b.transaction ?? void 0, op: b.op ?? void 0, monitor: b.monitor ?? void 0, metric: b.metric, max: b.max, windowMinutes: b.windowMinutes, minSamples: b.minSamples }));
1228
1270
  budgets = [...kept, ...budgets];
1229
1271
  }
1230
- return ctx.api.send("PUT", path, compact({ budgets, dryRun: i.dryRun }));
1272
+ const dryRun = i.dryRun || fromBody?.dryRun || void 0;
1273
+ return ctx.api.send("PUT", path, compact({ budgets, dryRun }));
1231
1274
  }
1232
1275
  };
1233
1276
  var budgetsCheck = {
@@ -1294,7 +1337,7 @@ var monitorsTiming = {
1294
1337
  context: "project",
1295
1338
  effect: "read",
1296
1339
  endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/monitors/{slug}/timing" },
1297
- args: [{ name: "slug", required: true, description: "The monitor's slug (its name)." }],
1340
+ args: [{ name: "slug", required: true, description: "The monitor's slug (its name; quote it when it has spaces), or its id from `monitors list`." }],
1298
1341
  flags: { since: { type: "string", value: TIME, description: "Default 24h for HTTP, 7d for jobs; compared with the window before." } },
1299
1342
  examples: [
1300
1343
  { command: "tracksiy monitors timing home", description: "Is the home page slow at the server, or on the wire?" },
@@ -1317,6 +1360,107 @@ ${table(
1317
1360
  };
1318
1361
  var perf_default = [budgetsGet, budgetsSet, budgetsCheck, findings, monitorsTiming];
1319
1362
 
1363
+ // src/commands/storage.ts
1364
+ function mb(bytes) {
1365
+ const n = Number(bytes ?? 0);
1366
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
1367
+ return `${(n / 1e6).toFixed(1)} MB`;
1368
+ }
1369
+ var storage = {
1370
+ path: ["storage"],
1371
+ summary: "What each project holds on the server (events, transactions, source maps, debug files), largest first, with its retention limits.",
1372
+ description: "Limits are per project: `tracksiy settings set --max-events-per-issue/--sourcemap-keep-releases/--sourcemap-keep-used-days`. Staff tokens also see the server's free disk and database size. Cached ten minutes; --fresh recounts.",
1373
+ context: "org",
1374
+ effect: "read",
1375
+ endpoint: { method: "GET", path: "/api/agent/v1/organizations/{org}/storage" },
1376
+ flags: { fresh: { type: "boolean", description: "Recount instead of the ten-minute cache (a few seconds)." } },
1377
+ examples: [
1378
+ { command: "tracksiy storage", description: "Which project is filling the disk?" },
1379
+ { command: "tracksiy storage --select instance,notes", description: "Free disk and warnings only (staff)." }
1380
+ ],
1381
+ output: "{generatedAt, cached, projects[{project, events{stored, approxBytes, issuesOverCap, largestIssueEvents}, transactions{stored, approxBytes}, sourceMaps{bundles, releases, releaseLines, bytes}, debugFiles{files, bytes}, approxTotalBytes, limits{...}}], instance{diskTotalBytes, diskFreeBytes, diskUsedPct, databaseBytes, uploadsBytes, eventRetentionDays, transactionRetentionDays}|null, notes[]}",
1382
+ text: (d) => `${d.instance ? kv({ disk: `${d.instance.diskUsedPct}% used, ${mb(d.instance.diskFreeBytes)} free`, database: mb(d.instance.databaseBytes), uploads: mb(d.instance.uploadsBytes) }) + "\n\n" : ""}${table(d.projects ?? [], [
1383
+ "project",
1384
+ ["total", (r) => mb(r.approxTotalBytes)],
1385
+ ["events", (r) => `${r.events.stored} (${mb(r.events.approxBytes)})`],
1386
+ ["over cap", (r) => r.events.issuesOverCap],
1387
+ ["transactions", (r) => `${r.transactions.stored} (${mb(r.transactions.approxBytes)})`],
1388
+ ["source maps", (r) => `${r.sourceMaps.bundles} in ${r.sourceMaps.releases} rel (${mb(r.sourceMaps.bytes)})`],
1389
+ ["debug files", (r) => `${r.debugFiles.files} (${mb(r.debugFiles.bytes)})`],
1390
+ ["limits", (r) => `${r.limits.maxEventsPerIssue}/issue, ${r.limits.sourcemapKeepReleases} rel, ${r.limits.sourcemapKeepUsedDays}d`]
1391
+ ])}${(d.notes ?? []).length ? `
1392
+
1393
+ ${d.notes.join("\n")}` : ""}`,
1394
+ async run(ctx) {
1395
+ return ctx.api.get(await orgPath(ctx, "storage"), { fresh: ctx.input.fresh || void 0 });
1396
+ }
1397
+ };
1398
+ var storage_default = [storage];
1399
+
1400
+ // src/commands/mobile.ts
1401
+ var pct = (v) => v === null || v === void 0 ? "-" : `${(Number(v) * 100).toFixed(1)}%`;
1402
+ var ms = (v) => v === null || v === void 0 ? "-" : `${v} ms`;
1403
+ var mobile = {
1404
+ path: ["mobile"],
1405
+ aliases: [["mobile", "vitals"]],
1406
+ summary: "How fast the app is on phones: app start (cold/warm) against the window before, per OS and release, and per screen TTID/TTFD and slow/frozen frames, with checks for what the SDK does not report.",
1407
+ description: "Read from the measurements the mobile SDKs already send with transactions (sentry-android, sentry-cocoa, sentry_flutter incl. Flutter 10 span streaming, @sentry/react-native); nothing to configure but tracesSampleRate > 0. Budgets take the same metrics: app_start_cold, app_start_warm, ttid, ttfd (p75 ms) and slow_frame_rate, frozen_frame_rate (0..1).",
1408
+ context: "project",
1409
+ effect: "read",
1410
+ endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/mobile" },
1411
+ flags: {
1412
+ since: { type: "string", value: TIME, description: "Window start (default 7d), compared with the window before." },
1413
+ release: { type: "string", value: "<version>", description: "Only this release." },
1414
+ os: { type: "string", value: "<name>", description: "Only this OS as the SDK names it: Android, iOS." },
1415
+ sort: { type: "string", enum: ["count", "ttid", "ttfd", "slowFrames", "frozenFrames"], description: "Screen order (default count)." },
1416
+ limit: { type: "integer", value: "<n>", description: "Screens (default 30, at most 100)." }
1417
+ },
1418
+ examples: [
1419
+ { command: "tracksiy mobile --os Android --sort ttid", description: "The slowest Android screens to show." },
1420
+ { command: "tracksiy budgets set --merge --budget 'name=cold start,metric=app_start_cold,max=2000'", description: "Alert when cold start p75 passes 2 s." }
1421
+ ],
1422
+ output: "{transactions, appStart{cold{count,p50,p75,p95}, warm{...}, previousColdP75, previousWarmP75}, byOs[], byRelease[], screens[{name, op, count, ttidP50, ttidP75, ttidP95, ttfdP75, previousTtidP75, slowFrameRate, frozenFrameRate, frames, frameDelayP75, stallP75}], checks[]}",
1423
+ text: (d) => {
1424
+ const s = d.appStart ?? {};
1425
+ const head = kv({
1426
+ transactions: d.transactions,
1427
+ "cold start p75": `${ms(s.cold?.p75)} (before ${ms(s.previousColdP75)}, n=${s.cold?.count ?? 0})`,
1428
+ "warm start p75": `${ms(s.warm?.p75)} (before ${ms(s.previousWarmP75)}, n=${s.warm?.count ?? 0})`
1429
+ });
1430
+ const screens = table(d.screens ?? [], [
1431
+ "name",
1432
+ "count",
1433
+ ["ttid p75", (r) => ms(r.ttidP75)],
1434
+ ["before", (r) => ms(r.previousTtidP75)],
1435
+ ["ttfd p75", (r) => ms(r.ttfdP75)],
1436
+ ["slow", (r) => pct(r.slowFrameRate)],
1437
+ ["frozen", (r) => pct(r.frozenFrameRate)]
1438
+ ]);
1439
+ const releases = table(d.byRelease ?? [], ["release", "transactions", ["cold p75", (r) => ms(r.coldP75)], ["ttid p75", (r) => ms(r.ttidP75)], ["slow", (r) => pct(r.slowFrameRate)]]);
1440
+ const checks = (d.checks ?? []).map((c) => `${c.status.padEnd(7)} ${c.name}: ${c.detail}`).join("\n");
1441
+ return `${head}
1442
+
1443
+ Screens:
1444
+ ${screens}
1445
+
1446
+ Releases:
1447
+ ${releases}
1448
+
1449
+ ${checks}`;
1450
+ },
1451
+ async run(ctx) {
1452
+ const i = ctx.input;
1453
+ return ctx.api.get(await projectPath(ctx, "mobile"), {
1454
+ since: since(i.since),
1455
+ release: i.release,
1456
+ os: i.os,
1457
+ sort: i.sort,
1458
+ limit: i.limit
1459
+ });
1460
+ }
1461
+ };
1462
+ var mobile_default = [mobile];
1463
+
1320
1464
  // src/commands/issues.ts
1321
1465
  import { writeFileSync } from "node:fs";
1322
1466
  var FILTERS = {
@@ -1711,7 +1855,7 @@ ${(d.nextSteps ?? []).map((s, n) => `${n + 1}. ${s.action} -- ${s.why}`).join("\
1711
1855
  );
1712
1856
  }
1713
1857
  };
1714
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1858
+ var sleep2 = (ms2) => new Promise((r) => setTimeout(r, ms2));
1715
1859
  var verify = {
1716
1860
  path: ["projects", "verify"],
1717
1861
  aliases: [["verify"]],
@@ -1785,6 +1929,7 @@ var DECLARE = {
1785
1929
  threshold: { type: "string", value: "<events>/<minutes>", description: "Also alert when an issue gets this many events in this many minutes, e.g. 50/10." },
1786
1930
  uptime: { type: "boolean", description: "Alert when this project's monitors go down (default true)." },
1787
1931
  performance: { type: "boolean", description: "Alert when a performance budget is crossed or met again (default true; see `budgets set`). Rides on the new-issue or uptime rule." },
1932
+ "release-health": { type: "boolean", description: "Alert when a release's crash-free rate drops under the threshold (and recovers), or one issue spreads fast through a release (default true; see `releases verdict`). Rides on the new-issue or uptime rule." },
1788
1933
  "replace-recipients": { type: "boolean", description: "Make the recipient list exactly this one (default: add to it)." },
1789
1934
  body: { type: "json", value: "<json|@file|->", description: "Raw request body; overrides the other flags." }
1790
1935
  };
@@ -1821,6 +1966,7 @@ function declareBody(i) {
1821
1966
  threshold: threshold(i.threshold),
1822
1967
  uptime: i.uptime,
1823
1968
  performance: i.performance,
1969
+ releaseHealth: i.releaseHealth,
1824
1970
  replaceRecipients: i.replaceRecipients
1825
1971
  });
1826
1972
  }
@@ -1834,6 +1980,7 @@ ${table(d.rules ?? [], [
1834
1980
  ["threshold", (r) => r.threshold ? `${r.threshold.events}/${r.threshold.minutes}m` : "-"],
1835
1981
  ["uptime", (r) => r.uptime],
1836
1982
  ["perf", (r) => r.performance],
1983
+ ["release", (r) => r.releaseHealth],
1837
1984
  ["recipients", (r) => (r.recipients ?? []).map((x) => `${x.type}${x.delivery === "digest" ? "(digest)" : ""}`).join(", ")]
1838
1985
  ])}${(d.notes ?? []).length ? `
1839
1986
 
@@ -2133,7 +2280,49 @@ function commitList(values, git = runGit) {
2133
2280
  function runGit(args) {
2134
2281
  return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2135
2282
  }
2136
- var releases_default = [list3, health2, declare];
2283
+ var verdict = {
2284
+ path: ["releases", "verdict"],
2285
+ summary: "Should the rollout of this release continue? Crash-free vs threshold and the previous build, issues spreading fast, new issues, budgets, regressions. Exit code 10 on fail.",
2286
+ description: "For a staged rollout step: `--wait 30m` re-asks every minute while the verdict is unknown (too few sessions yet). Unknown passes unless --strict. Thresholds are project settings (`settings set --crash-free-min/--min-sessions/--max-drop-pct/--velocity-pct/--velocity-min-events`). An upload token (project:releases) may run it.",
2287
+ context: "project",
2288
+ effect: "read",
2289
+ endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/releases/{version}/verdict" },
2290
+ scopes: ["project:releases", "event:read", "project:read", "project:admin", "org:read"],
2291
+ args: [{ name: "version", required: true, description: "The release, e.g. shop-android@1.4.2+57." }],
2292
+ flags: {
2293
+ wait: { type: "string", value: "<duration>", description: "Keep asking (every 60 s) while the verdict is unknown, up to this long: 30m, 2h." },
2294
+ strict: { type: "boolean", description: "An unknown verdict fails too (exit 10)." }
2295
+ },
2296
+ examples: [
2297
+ { command: "tracksiy releases verdict shop-android@1.4.2+57 --wait 30m && fastlane supply --rollout 0.5", description: "Widen the rollout only if the 10% stage held." }
2298
+ ],
2299
+ output: "{release, previousRelease, verdict, health{sessions, crashed, crashFreeSessionsPct, ...}, previousHealth, newIssues[], velocity[], budgets[], regressions[], checks[{name, status, detail}], rules}",
2300
+ text: (d) => `${kv({ release: d.release, previous: d.previousRelease ?? "-", verdict: d.verdict, sessions: d.health?.sessions, "crash-free %": d.health?.crashFreeSessionsPct ?? "-", "previous crash-free %": d.previousHealth?.crashFreeSessionsPct ?? "-" })}
2301
+
2302
+ ${table(d.checks ?? [], ["name", "status", "detail"])}${(d.newIssues ?? []).length ? `
2303
+
2304
+ New issues:
2305
+ ${table(d.newIssues, ["short_id", "count", "title"])}` : ""}`,
2306
+ async run(ctx) {
2307
+ const path = await projectPath(ctx, `releases/${seg(ctx.input.version)}/verdict`);
2308
+ const waitSeconds = duration(ctx.input.wait, "wait") ?? 0;
2309
+ const deadline = Date.now() + waitSeconds * 1e3;
2310
+ let data = await ctx.api.get(path);
2311
+ while (data.verdict === "unknown" && Date.now() + 6e4 <= deadline) {
2312
+ await new Promise((r) => setTimeout(r, 6e4));
2313
+ data = await ctx.api.get(path);
2314
+ }
2315
+ if (data.verdict === "fail") {
2316
+ const failed = (data.checks ?? []).filter((c) => c.status === "fail").map((c) => `${c.name}: ${c.detail}`).join(" | ");
2317
+ throw new CheckFailed(`Release ${data.release} failed: ${failed}`, data, "Halt the rollout; `tracksiy releases list` and `tracksiy issues list --release` show what broke.");
2318
+ }
2319
+ if (ctx.input.strict && data.verdict === "unknown") {
2320
+ throw new CheckFailed(`Release ${data.release} could not be judged yet (${data.health?.sessions ?? 0} sessions).`, data, "Wait longer (--wait), or lower releaseHealth.minSessions.");
2321
+ }
2322
+ return data;
2323
+ }
2324
+ };
2325
+ var releases_default = [list3, health2, declare, verdict];
2137
2326
 
2138
2327
  // src/commands/artifacts.ts
2139
2328
  import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
@@ -2255,7 +2444,7 @@ function manifestFor(files, chunkSize) {
2255
2444
  });
2256
2445
  return { manifest, chunks };
2257
2446
  }
2258
- var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
2447
+ var sleep3 = (ms2) => new Promise((r) => setTimeout(r, ms2));
2259
2448
  function packManifest(manifest, maxFiles, maxBytes) {
2260
2449
  const units = [];
2261
2450
  for (const entry of manifest) {
@@ -3591,6 +3780,8 @@ var COMMANDS = [
3591
3780
  ...auth_default,
3592
3781
  ...fleet_default,
3593
3782
  ...perf_default,
3783
+ ...storage_default,
3784
+ ...mobile_default,
3594
3785
  ...issues_default,
3595
3786
  ...projects_default,
3596
3787
  ...alerts_default,
@@ -3604,7 +3795,7 @@ var COMMANDS = [
3604
3795
  ];
3605
3796
  var GROUPS = [
3606
3797
  ["Start", "init auth onboarding describe"],
3607
- ["Look", "overview changes health orgs projects issues monitors performance queries not-found releases alerts keys artifacts settings privacy-preview"],
3798
+ ["Look", "overview changes health storage orgs projects issues monitors performance mobile queries not-found releases alerts keys artifacts settings privacy-preview"],
3608
3799
  ["Ship", "sourcemaps proguard"],
3609
3800
  ["Manage", "tokens"],
3610
3801
  ["Talk to us", "support"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracksiy",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent-first CLI and MCP server for Tracksiy: errors, uptime, alerts, releases and source maps, from one token.",
5
5
  "type": "module",
6
6
  "bin": {