tracksiy 0.7.0 → 0.9.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 +5 -0
- package/dist/tracksiy.mjs +259 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,11 @@ Labels: `bug`, `feature`, `question`, `blocker`. `npx tracksiy support` prints t
|
|
|
132
132
|
| `settings` | The project's reading rules: environments, grouping rules, privacy rules, source roots. | read | `settings` |
|
|
133
133
|
| `settings set` | Change the project's reading rules. Only the flags given change; the rest are kept. | write | `settings_set` |
|
|
134
134
|
| `privacy-preview` | Try privacy rules on the project's last 50 stored events: what each would change and what PII shapes would remain. Writes nothing. | read | `privacy_preview` |
|
|
135
|
+
| `budgets get` | The project's performance budgets and each one's last verdict (judged every 5 minutes). | read | `budgets_get` |
|
|
136
|
+
| `budgets set` | Declare performance budgets: told once when one is crossed and once when it is met again. | write | `budgets_set` |
|
|
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
|
+
| `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
|
+
| `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` |
|
|
135
140
|
| `issues list` | Recent issues of a project, newest activity first, with the frames that matter inlined. | read | `issues_list` |
|
|
136
141
|
| `issues view <id>` | One issue in enough depth to act on: latest event, stack frames, tags, recent volume. | read | `issues_view` |
|
|
137
142
|
| `issues resolve <ids...>` | Mark issues resolved, optionally fixed in a release so old clients do not reopen them. Records why. | write | `issues_resolve` |
|
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.
|
|
7
|
+
var VERSION = true ? "0.9.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
|
|
@@ -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}], notes[]}",
|
|
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[]}",
|
|
826
826
|
text: (d) => `${kv(d.summary ?? {})}
|
|
827
827
|
|
|
828
828
|
${table(d.monitors ?? [], [
|
|
@@ -830,6 +830,7 @@ ${table(d.monitors ?? [], [
|
|
|
830
830
|
"project",
|
|
831
831
|
"monitorType",
|
|
832
832
|
["up", (r) => r.upNow == null ? "never" : r.upNow],
|
|
833
|
+
["why down", (r) => r.downReason ?? ""],
|
|
833
834
|
["last check", (r) => ago(r.lastCheck)],
|
|
834
835
|
["alertable", (r) => r.alertable]
|
|
835
836
|
])}`,
|
|
@@ -945,7 +946,7 @@ var performance = {
|
|
|
945
946
|
flags: {
|
|
946
947
|
since: { type: "string", value: TIME, description: "Window start (default: server's, 24h; 7d with --release)." },
|
|
947
948
|
op: { type: "string", value: "<op>", description: "Only this operation, e.g. http.server or pageload." },
|
|
948
|
-
sort: { type: "string", enum: ["slower", "duration", "p95", "failures"], description: "Order (default: slower first)." },
|
|
949
|
+
sort: { type: "string", enum: ["slower", "duration", "p95", "failures", "impact"], description: "Order (default: slower first). impact: total time taken (count x mean), the route that costs the most overall." },
|
|
949
950
|
limit: { type: "integer", value: "<n>", description: "How many transactions." },
|
|
950
951
|
release: { type: "string", value: "<version>", description: "Compare this release's transactions with --against instead of two time windows." },
|
|
951
952
|
against: { type: "string", value: "<version>", description: "The release to compare with (default: the one before --release)." }
|
|
@@ -953,9 +954,11 @@ var performance = {
|
|
|
953
954
|
examples: [
|
|
954
955
|
{ command: "tracksiy performance --sort p95", description: "The slowest routes by p95." },
|
|
955
956
|
{ command: "tracksiy performance --release web@$(git rev-parse HEAD)", description: "Did this deploy make anything slower or failing?" },
|
|
956
|
-
{ command: "tracksiy performance --op pageload --select transactions.transaction,transactions.current.webVitals", description: "Core Web Vitals (p75) per page." }
|
|
957
|
+
{ command: "tracksiy performance --op pageload --select transactions.transaction,transactions.current.webVitals", description: "Core Web Vitals (p75) per page." },
|
|
958
|
+
{ command: "tracksiy performance --sort impact", description: "Where the server's time goes: count x mean, with each route's share." },
|
|
959
|
+
{ command: "tracksiy performance --op ui.interaction --sort p95", description: "The slowest interactions (INP) by route." }
|
|
957
960
|
],
|
|
958
|
-
output: "{since, until, release?, againstRelease?, transactions:[{transaction, op, current{count, avgDurationMs, p50Ms, p95Ms, p99Ms, failureRate, failed, status, webVitals}, previous{...}, changeRatio, slower}], uptime[...], blindSpots[...]}",
|
|
961
|
+
output: "{since, until, release?, againstRelease?, transactions:[{transaction, op, current{count, avgDurationMs, p50Ms, p95Ms, p99Ms, failureRate, failed, status, webVitals}, previous{...}, changeRatio, slower, totalTimeMs, timeShare}], uptime[...], blindSpots[...]}",
|
|
959
962
|
text: (d) => `${table(d.transactions ?? [], [
|
|
960
963
|
"transaction",
|
|
961
964
|
"op",
|
|
@@ -965,7 +968,8 @@ var performance = {
|
|
|
965
968
|
["fail", (r) => r.current?.failureRate == null ? "-" : `${(r.current.failureRate * 100).toFixed(1)}%`],
|
|
966
969
|
["count", (r) => r.current?.count ?? "-"],
|
|
967
970
|
["change", (r) => r.changeRatio == null ? "-" : `${r.changeRatio > 0 ? "+" : ""}${Math.round(r.changeRatio * 100)}%`],
|
|
968
|
-
["slower", (r) => r.slower]
|
|
971
|
+
["slower", (r) => r.slower],
|
|
972
|
+
["share", (r) => r.timeShare == null ? "-" : `${(r.timeShare * 100).toFixed(1)}%`]
|
|
969
973
|
])}${(d.uptime ?? []).length ? `
|
|
970
974
|
|
|
971
975
|
uptime:
|
|
@@ -1000,7 +1004,7 @@ var queries = {
|
|
|
1000
1004
|
{ command: "tracksiy queries --since 24h", description: "The queries that cost the most time today." },
|
|
1001
1005
|
{ command: "tracksiy queries --sort perTransaction --select queries.query,queries.maxPerTransaction,queries.routes", description: "N+1 candidates." }
|
|
1002
1006
|
],
|
|
1003
|
-
output: "{since, until, queries:[{fingerprint, op, query, count, transactions, perTransaction, maxPerTransaction, totalMs, avgMs, maxMs, previous{count, avgMs}, changeRatio, nPlusOne, routes[{transaction, count, avgMs}]}], note}",
|
|
1007
|
+
output: "{since, until, queries:[{fingerprint, op, query, count, transactions, perTransaction, maxPerTransaction, totalMs, avgMs, maxMs, previous{count, avgMs}, changeRatio, nPlusOne, routes[{transaction, count, avgMs}], origin{file, line, function, repoPath}}], note}",
|
|
1004
1008
|
text: (d) => table(d.queries ?? [], [
|
|
1005
1009
|
["total ms", (r) => r.totalMs],
|
|
1006
1010
|
["avg", (r) => r.avgMs ?? "-"],
|
|
@@ -1008,7 +1012,8 @@ var queries = {
|
|
|
1008
1012
|
["count", (r) => r.count],
|
|
1009
1013
|
["per req", (r) => r.perTransaction],
|
|
1010
1014
|
["n+1", (r) => r.nPlusOne ? "yes" : ""],
|
|
1011
|
-
["query", (r) => String(r.query).slice(0, 90)]
|
|
1015
|
+
["query", (r) => String(r.query).slice(0, 90)],
|
|
1016
|
+
["where", (r) => r.origin ? `${r.origin.repoPath ?? r.origin.file}${r.origin.line ? `:${r.origin.line}` : ""}` : ""]
|
|
1012
1017
|
]),
|
|
1013
1018
|
async run(ctx) {
|
|
1014
1019
|
return ctx.api.get(await projectPath(ctx, "queries"), {
|
|
@@ -1045,7 +1050,7 @@ var settingsGet = {
|
|
|
1045
1050
|
context: "project",
|
|
1046
1051
|
effect: "read",
|
|
1047
1052
|
endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/settings" },
|
|
1048
|
-
output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[]}",
|
|
1053
|
+
output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues}",
|
|
1049
1054
|
async run(ctx) {
|
|
1050
1055
|
return ctx.api.get(await projectPath(ctx, "settings"));
|
|
1051
1056
|
}
|
|
@@ -1073,18 +1078,19 @@ var settingsSet = {
|
|
|
1073
1078
|
flags: {
|
|
1074
1079
|
environments: { type: "list", value: "<env,...>", description: "The environments this project reports from (empty string: any)." },
|
|
1075
1080
|
"environment-policy": { type: "string", enum: ["flag", "drop"], description: "What happens to an event from another environment." },
|
|
1076
|
-
"source-root": { type: "list", split: false, value: "<path> | strip=<path>,prepend=<dir/>[,surface=<surface>]", description: "How frame paths become repo paths: a prefix to strip, or strip + prepend
|
|
1081
|
+
"source-root": { type: "list", split: false, value: "<path> | strip=<path>,prepend=<dir/>[,surface=<surface>]", description: "How frame paths become repo paths: a prefix to strip, or strip + prepend, optionally for one surface. Each rule is tried on the frame's abs_path first (sentry-laravel: the full server path, /home/admin/web/shop/backend/app/...), then on filename (/app/...). Repeatable; replaces the list." },
|
|
1077
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." },
|
|
1078
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
|
+
"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." },
|
|
1079
1085
|
body: { type: "json", value: "<json|@file|->", description: "The whole declaration; overrides the flags." }
|
|
1080
1086
|
},
|
|
1081
1087
|
examples: [
|
|
1082
1088
|
{ command: "tracksiy settings set --environments production,staging --environment-policy drop", description: "local and local1 stop reaching Tracksiy." },
|
|
1083
1089
|
{ command: "tracksiy settings set --fingerprint-rule 'type=ViewException,message=syntax error*,fingerprint=blade-syntax'", description: "Every half-written template is one issue." },
|
|
1084
1090
|
{ command: "tracksiy settings set --source-root /home/admin/web/shop/", description: "Frames carry repoPath." },
|
|
1085
|
-
{ command: "tracksiy settings set --source-root 'strip
|
|
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." }
|
|
1086
1092
|
],
|
|
1087
|
-
output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[]}",
|
|
1093
|
+
output: "{sourceRoots[], environments[], environmentPolicy, fingerprintRules[], privacyRules[], performanceIssues}",
|
|
1088
1094
|
async run(ctx) {
|
|
1089
1095
|
const path = await projectPath(ctx, "settings");
|
|
1090
1096
|
const i = ctx.input;
|
|
@@ -1095,7 +1101,8 @@ var settingsSet = {
|
|
|
1095
1101
|
environments: i.environments ? i.environments.filter((e) => e) : current.environments ?? [],
|
|
1096
1102
|
environmentPolicy: i.environmentPolicy ?? current.environmentPolicy ?? "flag",
|
|
1097
1103
|
fingerprintRules: pairs(i.fingerprintRule, "fingerprint-rule") ?? current.fingerprintRules ?? [],
|
|
1098
|
-
privacyRules: pairs(i.privacyRule, "privacy-rule") ?? current.privacyRules ?? []
|
|
1104
|
+
privacyRules: pairs(i.privacyRule, "privacy-rule") ?? current.privacyRules ?? [],
|
|
1105
|
+
performanceIssues: i.performanceIssues ?? current.performanceIssues ?? true
|
|
1099
1106
|
};
|
|
1100
1107
|
return ctx.api.send("PUT", path, next);
|
|
1101
1108
|
}
|
|
@@ -1118,6 +1125,198 @@ var privacyPreview = {
|
|
|
1118
1125
|
};
|
|
1119
1126
|
var fleet_default = [orgs, projects, overview, changes, health, monitorsList, monitorsDeclare, monitorsDelete, monitorsDeleted, performance, queries, notFound, settingsGet, settingsSet, privacyPreview];
|
|
1120
1127
|
|
|
1128
|
+
// 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)";
|
|
1130
|
+
var BUDGET_KEYS = {
|
|
1131
|
+
name: "name",
|
|
1132
|
+
transaction: "transaction",
|
|
1133
|
+
op: "op",
|
|
1134
|
+
monitor: "monitor",
|
|
1135
|
+
metric: "metric",
|
|
1136
|
+
max: "max",
|
|
1137
|
+
window: "windowMinutes",
|
|
1138
|
+
"window-minutes": "windowMinutes",
|
|
1139
|
+
windowMinutes: "windowMinutes",
|
|
1140
|
+
"min-samples": "minSamples",
|
|
1141
|
+
minSamples: "minSamples"
|
|
1142
|
+
};
|
|
1143
|
+
var NUMERIC = /* @__PURE__ */ new Set(["max", "windowMinutes", "minSamples"]);
|
|
1144
|
+
function parseBudget(raw) {
|
|
1145
|
+
const out = {};
|
|
1146
|
+
for (const part of String(raw).split(/,(?=\s*[\w-]+=)/)) {
|
|
1147
|
+
const eq = part.indexOf("=");
|
|
1148
|
+
if (eq < 1) throw usageError("bad_value", `--budget expects key=value,..., got ${JSON.stringify(raw)}`, "e.g. --budget 'name=init,transaction=GET /api/v1/init,metric=p95,max=800'");
|
|
1149
|
+
const key = part.slice(0, eq).trim();
|
|
1150
|
+
const field = BUDGET_KEYS[key];
|
|
1151
|
+
if (!field) throw usageError("bad_value", `--budget: unknown key ${JSON.stringify(key)}`, `Keys: name, transaction, op, monitor, metric, max, window, min-samples.`);
|
|
1152
|
+
const value = part.slice(eq + 1).trim();
|
|
1153
|
+
if (NUMERIC.has(field)) {
|
|
1154
|
+
const n = Number(value);
|
|
1155
|
+
if (!Number.isFinite(n)) throw usageError("bad_value", `--budget ${key}=${value} is not a number`);
|
|
1156
|
+
out[field] = n;
|
|
1157
|
+
} else {
|
|
1158
|
+
out[field] = value;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
for (const required of ["name", "metric", "max"]) {
|
|
1162
|
+
if (out[required] === void 0) throw usageError("bad_value", `--budget ${JSON.stringify(raw)} has no ${required}=`);
|
|
1163
|
+
}
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
var budgetsText = (d) => `${table(d.budgets ?? [], [
|
|
1167
|
+
"name",
|
|
1168
|
+
["target", (r) => r.monitor ? `monitor ${r.monitor}` : [r.op, r.transaction].filter(Boolean).join(" ") || "every transaction"],
|
|
1169
|
+
"metric",
|
|
1170
|
+
["max", (r) => `${r.max}${r.unit === "ms" ? " ms" : ""}`],
|
|
1171
|
+
["value", (r) => r.value == null ? "-" : `${Math.round(r.value * 1e3) / 1e3}`],
|
|
1172
|
+
["status", (r) => r.status ?? "not yet"],
|
|
1173
|
+
["samples", (r) => r.samples],
|
|
1174
|
+
["since", (r) => r.breachedSince ? ago(r.breachedSince) : ""]
|
|
1175
|
+
])}${(d.changes ?? []).length ? `
|
|
1176
|
+
|
|
1177
|
+
changes:
|
|
1178
|
+
${table(d.changes, ["name", "action"])}` : ""}`;
|
|
1179
|
+
var budgetsGet = {
|
|
1180
|
+
path: ["budgets", "get"],
|
|
1181
|
+
aliases: [["budgets"]],
|
|
1182
|
+
summary: "The project's performance budgets and each one's last verdict (judged every 5 minutes).",
|
|
1183
|
+
context: "project",
|
|
1184
|
+
effect: "read",
|
|
1185
|
+
endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/budgets" },
|
|
1186
|
+
output: "{budgets:[{name, transaction, op, monitor, metric, max, unit, windowMinutes, minSamples, status, value, samples, evaluatedAt, breachedSince}], notes[]}",
|
|
1187
|
+
text: budgetsText,
|
|
1188
|
+
async run(ctx) {
|
|
1189
|
+
return ctx.api.get(await projectPath(ctx, "budgets"));
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
var budgetsSet = {
|
|
1193
|
+
path: ["budgets", "set"],
|
|
1194
|
+
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).`,
|
|
1196
|
+
context: "project",
|
|
1197
|
+
effect: "write",
|
|
1198
|
+
idempotent: true,
|
|
1199
|
+
endpoint: { method: "PUT", path: "/api/agent/v1/projects/{org}/{project}/budgets" },
|
|
1200
|
+
flags: {
|
|
1201
|
+
budget: {
|
|
1202
|
+
type: "list",
|
|
1203
|
+
split: false,
|
|
1204
|
+
value: "name=<n>,metric=<m>,max=<x>[,transaction=<route|glob*>][,op=<op>][,monitor=<slug>][,window=<min>][,min-samples=<n>]",
|
|
1205
|
+
description: "One budget. Repeatable."
|
|
1206
|
+
},
|
|
1207
|
+
merge: { type: "boolean", description: "Keep the stored budgets not named here (default: the list is the whole declaration)." },
|
|
1208
|
+
"dry-run": { type: "boolean", description: "Report the changes without writing." },
|
|
1209
|
+
body: { type: "json", value: "<json|@file|->", description: "Raw body {budgets:[...]}; overrides the flags." }
|
|
1210
|
+
},
|
|
1211
|
+
examples: [
|
|
1212
|
+
{ 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
|
+
{ 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." }
|
|
1215
|
+
],
|
|
1216
|
+
output: "The stored budgets and `changes` [{name, action}], same shape as `budgets get`.",
|
|
1217
|
+
text: budgetsText,
|
|
1218
|
+
async run(ctx) {
|
|
1219
|
+
const path = await projectPath(ctx, "budgets");
|
|
1220
|
+
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);
|
|
1224
|
+
if (i.merge) {
|
|
1225
|
+
const current = await ctx.api.get(path);
|
|
1226
|
+
const named = new Set(budgets.map((b) => b.name));
|
|
1227
|
+
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
|
+
budgets = [...kept, ...budgets];
|
|
1229
|
+
}
|
|
1230
|
+
return ctx.api.send("PUT", path, compact({ budgets, dryRun: i.dryRun }));
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
var budgetsCheck = {
|
|
1234
|
+
path: ["budgets", "check"],
|
|
1235
|
+
summary: "Judge every budget now -- for one release with --release. Exit code 10 if one is crossed. For a deploy to stop on.",
|
|
1236
|
+
description: "With --release, transaction budgets look only at that release's transactions since it was first known (run it after the canary or the first minutes of traffic). Monitor budgets use their window. Writes nothing and alerts nobody. An unknown verdict (too few samples) passes unless --strict. An upload token (project:releases) may run it.",
|
|
1237
|
+
context: "project",
|
|
1238
|
+
effect: "read",
|
|
1239
|
+
endpoint: { method: "POST", path: "/api/agent/v1/projects/{org}/{project}/budgets/check" },
|
|
1240
|
+
scopes: ["project:releases", "event:read", "project:read", "project:write", "project:admin"],
|
|
1241
|
+
flags: {
|
|
1242
|
+
release: { type: "string", value: "<version>", description: "Judge transaction budgets on this release only." },
|
|
1243
|
+
since: { type: "string", value: TIME, description: "Instead of each budget's window: from this time." },
|
|
1244
|
+
strict: { type: "boolean", description: "An unknown verdict (too few samples) fails too." }
|
|
1245
|
+
},
|
|
1246
|
+
examples: [
|
|
1247
|
+
{ command: "tracksiy budgets check --release web@$(git rev-parse HEAD)", description: "In the deploy, after a few minutes of traffic: stop if the release broke a budget." }
|
|
1248
|
+
],
|
|
1249
|
+
output: "{ok, failed, unknown, release, results:[{name, metric, max, status, value, samples, minSamples, scope}]}",
|
|
1250
|
+
text: (d) => `${kv({ ok: d.ok, failed: d.failed, unknown: d.unknown, release: d.release ?? "-" })}
|
|
1251
|
+
|
|
1252
|
+
${table(d.results ?? [], ["name", "metric", "max", ["value", (r) => r.value ?? "-"], "status", "samples", "scope"])}`,
|
|
1253
|
+
async run(ctx) {
|
|
1254
|
+
const data = await ctx.api.send("POST", await projectPath(ctx, "budgets/check"), compact({ release: ctx.input.release, since: since(ctx.input.since) }));
|
|
1255
|
+
const unknown = (data.results ?? []).filter((r) => r.status === "unknown").length;
|
|
1256
|
+
if (data.failed) {
|
|
1257
|
+
const names = data.results.filter((r) => r.status === "fail").map((r) => r.name).join(", ");
|
|
1258
|
+
throw new CheckFailed(`${data.failed} budget(s) crossed: ${names}.`, data, "See `tracksiy performance --release <r>` and `tracksiy queries` for what got slow.");
|
|
1259
|
+
}
|
|
1260
|
+
if (ctx.input.strict && unknown) {
|
|
1261
|
+
throw new CheckFailed(`${unknown} budget(s) could not be judged (too few samples).`, data, "Wait for more traffic, or lower minSamples.");
|
|
1262
|
+
}
|
|
1263
|
+
return data;
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
var findings = {
|
|
1267
|
+
path: ["performance", "findings"],
|
|
1268
|
+
summary: "Routes that got slower after a release and N+1 queries, which Tracksiy raised as issues on its own.",
|
|
1269
|
+
context: "project",
|
|
1270
|
+
effect: "read",
|
|
1271
|
+
endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/performance/findings" },
|
|
1272
|
+
flags: {
|
|
1273
|
+
since: { type: "string", value: TIME, description: "Default 30 days." },
|
|
1274
|
+
kind: { type: "string", enum: ["regression", "n_plus_one"], description: "Only one kind." },
|
|
1275
|
+
limit: { type: "integer", value: "<n>", description: "How many (max 200)." }
|
|
1276
|
+
},
|
|
1277
|
+
examples: [{ command: "tracksiy performance findings --kind regression --since 7d", description: "What the last deploys made slower." }],
|
|
1278
|
+
output: "{enabled, findings:[{kind, release, created, issue{id, title, status}, data}], note}",
|
|
1279
|
+
text: (d) => `${d.enabled ? "" : "performanceIssues is OFF for this project (settings set --performance-issues).\n\n"}${table(d.findings ?? [], [
|
|
1280
|
+
"kind",
|
|
1281
|
+
["release", (r) => r.release ?? "-"],
|
|
1282
|
+
["issue", (r) => r.issue?.id ?? "-"],
|
|
1283
|
+
["status", (r) => r.issue?.status ?? "-"],
|
|
1284
|
+
["what", (r) => String(r.issue?.title ?? r.data?.transaction ?? "").slice(0, 100)],
|
|
1285
|
+
["when", (r) => ago(r.created)]
|
|
1286
|
+
])}`,
|
|
1287
|
+
async run(ctx) {
|
|
1288
|
+
return ctx.api.get(await projectPath(ctx, "performance/findings"), { since: since(ctx.input.since), kind: ctx.input.kind, limit: ctx.input.limit });
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
var monitorsTiming = {
|
|
1292
|
+
path: ["monitors", "timing"],
|
|
1293
|
+
summary: "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.",
|
|
1294
|
+
context: "project",
|
|
1295
|
+
effect: "read",
|
|
1296
|
+
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)." }],
|
|
1298
|
+
flags: { since: { type: "string", value: TIME, description: "Default 24h for HTTP, 7d for jobs; compared with the window before." } },
|
|
1299
|
+
examples: [
|
|
1300
|
+
{ command: "tracksiy monitors timing home", description: "Is the home page slow at the server, or on the wire?" },
|
|
1301
|
+
{ command: "tracksiy monitors timing db-backup-s3 --since 30d", description: "Is the backup getting slower?" }
|
|
1302
|
+
],
|
|
1303
|
+
output: "{monitor, type, since, until, checks, http{phases{dns, connect, wait, ttfb, download, total: {p50, p95}}, bytes{p50, p95}, previousTtfbP95, ttfbChangeRatio, latest}, job{runs, p50Ms, p95Ms, maxMs, previousP95Ms, changeRatio, maxRuntimeMs, headroom, slowing}, note}",
|
|
1304
|
+
text: (d) => {
|
|
1305
|
+
if (d.job) return kv({ monitor: d.monitor, checks: d.checks, ...d.job });
|
|
1306
|
+
const phases = d.http?.phases ?? {};
|
|
1307
|
+
return `${kv({ monitor: d.monitor, type: d.type, checks: d.checks, "ttfb p95 before": d.http?.previousTtfbP95 ?? "-", "ttfb change": d.http?.ttfbChangeRatio ?? "-", bytes: d.http?.bytes?.p50 ?? "-" })}
|
|
1308
|
+
|
|
1309
|
+
${table(
|
|
1310
|
+
Object.entries(phases).map(([phase, v]) => ({ phase, p50: v.p50, p95: v.p95 })),
|
|
1311
|
+
["phase", "p50", "p95"]
|
|
1312
|
+
)}`;
|
|
1313
|
+
},
|
|
1314
|
+
async run(ctx) {
|
|
1315
|
+
return ctx.api.get(await projectPath(ctx, `monitors/${seg(ctx.input.slug)}/timing`), { since: since(ctx.input.since) });
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
var perf_default = [budgetsGet, budgetsSet, budgetsCheck, findings, monitorsTiming];
|
|
1319
|
+
|
|
1121
1320
|
// src/commands/issues.ts
|
|
1122
1321
|
import { writeFileSync } from "node:fs";
|
|
1123
1322
|
var FILTERS = {
|
|
@@ -1585,6 +1784,7 @@ var DECLARE = {
|
|
|
1585
1784
|
"new-issue": { type: "boolean", description: "Alert on every new issue (default true). --no-new-issue to turn off." },
|
|
1586
1785
|
threshold: { type: "string", value: "<events>/<minutes>", description: "Also alert when an issue gets this many events in this many minutes, e.g. 50/10." },
|
|
1587
1786
|
uptime: { type: "boolean", description: "Alert when this project's monitors go down (default true)." },
|
|
1787
|
+
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." },
|
|
1588
1788
|
"replace-recipients": { type: "boolean", description: "Make the recipient list exactly this one (default: add to it)." },
|
|
1589
1789
|
body: { type: "json", value: "<json|@file|->", description: "Raw request body; overrides the other flags." }
|
|
1590
1790
|
};
|
|
@@ -1620,6 +1820,7 @@ function declareBody(i) {
|
|
|
1620
1820
|
newIssue: i.newIssue,
|
|
1621
1821
|
threshold: threshold(i.threshold),
|
|
1622
1822
|
uptime: i.uptime,
|
|
1823
|
+
performance: i.performance,
|
|
1623
1824
|
replaceRecipients: i.replaceRecipients
|
|
1624
1825
|
});
|
|
1625
1826
|
}
|
|
@@ -1632,6 +1833,7 @@ ${table(d.rules ?? [], [
|
|
|
1632
1833
|
["new issue", (r) => r.newIssue],
|
|
1633
1834
|
["threshold", (r) => r.threshold ? `${r.threshold.events}/${r.threshold.minutes}m` : "-"],
|
|
1634
1835
|
["uptime", (r) => r.uptime],
|
|
1836
|
+
["perf", (r) => r.performance],
|
|
1635
1837
|
["recipients", (r) => (r.recipients ?? []).map((x) => `${x.type}${x.delivery === "digest" ? "(digest)" : ""}`).join(", ")]
|
|
1636
1838
|
])}${(d.notes ?? []).length ? `
|
|
1637
1839
|
|
|
@@ -1826,6 +2028,7 @@ var rotate = {
|
|
|
1826
2028
|
var keys_default = [list2, set2, rotate];
|
|
1827
2029
|
|
|
1828
2030
|
// src/commands/releases.ts
|
|
2031
|
+
import { execFileSync } from "node:child_process";
|
|
1829
2032
|
var list3 = {
|
|
1830
2033
|
path: ["releases", "list"],
|
|
1831
2034
|
aliases: [["releases"]],
|
|
@@ -1879,19 +2082,57 @@ var declare = {
|
|
|
1879
2082
|
ref: { type: "string", value: "<commit>", description: "Commit or tag it was built from." },
|
|
1880
2083
|
url: { type: "string", value: "<url>", description: "Link to the release (changelog, CI run)." },
|
|
1881
2084
|
released: { type: "boolean", description: "Mark it released now (if not already). --no-released clears it." },
|
|
1882
|
-
"released-at": { type: "string", value: "<iso>", description: "Mark it released at this time." }
|
|
2085
|
+
"released-at": { type: "string", value: "<iso>", description: "Mark it released at this time." },
|
|
2086
|
+
commits: {
|
|
2087
|
+
type: "list",
|
|
2088
|
+
value: "<prev>..<head> | <sha,...>",
|
|
2089
|
+
description: "The commits this release ships, so `issues resolve --in-commit` with any of them binds here (a push is more than its HEAD). A git range is expanded with `git rev-list` in the current directory (at most 1000). Replaces the list."
|
|
2090
|
+
}
|
|
1883
2091
|
},
|
|
1884
2092
|
examples: [
|
|
1885
|
-
{ command: "tracksiy releases declare shop@$(git rev-parse --short HEAD) --ref $(git rev-parse HEAD) --released", description: "From a deploy script." }
|
|
2093
|
+
{ command: "tracksiy releases declare shop@$(git rev-parse --short HEAD) --ref $(git rev-parse HEAD) --released", description: "From a deploy script." },
|
|
2094
|
+
{ command: "tracksiy releases declare shop@$(git rev-parse HEAD) --ref $(git rev-parse HEAD) --commits $LAST_DEPLOYED..HEAD --released", description: "Every commit of the push: a fix resolved --in-commit with any of them binds to this release." }
|
|
1886
2095
|
],
|
|
1887
|
-
output: "{version, ref, url, released, created, projects[], artifacts}",
|
|
2096
|
+
output: "{version, ref, url, released, commitCount, boundResolutions, created, projects[], artifacts}",
|
|
1888
2097
|
async run(ctx) {
|
|
1889
2098
|
const i = ctx.input;
|
|
1890
2099
|
if (i.released !== void 0 && i.releasedAt) throw usageError("conflict", "Pass --released or --released-at, not both.");
|
|
1891
2100
|
if (i.releasedAt && !Number.isFinite(Date.parse(i.releasedAt))) throw usageError("bad_value", `--released-at is not a time: ${i.releasedAt}`);
|
|
1892
|
-
return ctx.api.send(
|
|
2101
|
+
return ctx.api.send(
|
|
2102
|
+
"PUT",
|
|
2103
|
+
await projectPath(ctx, `releases/${seg(i.version)}`),
|
|
2104
|
+
compact({ ref: i.ref, url: i.url, released: i.releasedAt ?? i.released, commits: i.commits ? commitList(i.commits) : void 0 })
|
|
2105
|
+
);
|
|
1893
2106
|
}
|
|
1894
2107
|
};
|
|
2108
|
+
var MAX_COMMITS = 1e3;
|
|
2109
|
+
var SHA = /^[0-9a-f]{7,40}$/i;
|
|
2110
|
+
function commitList(values, git = runGit) {
|
|
2111
|
+
const out = [];
|
|
2112
|
+
for (const raw of values) {
|
|
2113
|
+
const value = raw.trim();
|
|
2114
|
+
if (!value) continue;
|
|
2115
|
+
if (value.includes("..")) {
|
|
2116
|
+
let listed;
|
|
2117
|
+
try {
|
|
2118
|
+
listed = git(["rev-list", `--max-count=${MAX_COMMITS + 1}`, value]);
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
throw usageError("bad_value", `--commits ${value}: git rev-list failed: ${String(err?.stderr || err?.message || err).trim().split("\n")[0]}`, "Run it inside the repository, with both ends fetched (e.g. git fetch --unshallow in CI).");
|
|
2121
|
+
}
|
|
2122
|
+
out.push(...listed.split(/\s+/).filter(Boolean));
|
|
2123
|
+
} else if (SHA.test(value)) {
|
|
2124
|
+
out.push(value.toLowerCase());
|
|
2125
|
+
} else {
|
|
2126
|
+
throw usageError("bad_value", `--commits: ${JSON.stringify(value)} is neither a sha nor a <prev>..<head> range.`);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
const unique = [...new Set(out.map((c) => c.toLowerCase()))];
|
|
2130
|
+
if (unique.length > MAX_COMMITS) throw usageError("bad_value", `--commits names more than ${MAX_COMMITS} commits.`, "Is the range's base the last deployed commit?");
|
|
2131
|
+
return unique;
|
|
2132
|
+
}
|
|
2133
|
+
function runGit(args) {
|
|
2134
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
2135
|
+
}
|
|
1895
2136
|
var releases_default = [list3, health2, declare];
|
|
1896
2137
|
|
|
1897
2138
|
// src/commands/artifacts.ts
|
|
@@ -3349,6 +3590,7 @@ var COMMANDS = [
|
|
|
3349
3590
|
...init_default,
|
|
3350
3591
|
...auth_default,
|
|
3351
3592
|
...fleet_default,
|
|
3593
|
+
...perf_default,
|
|
3352
3594
|
...issues_default,
|
|
3353
3595
|
...projects_default,
|
|
3354
3596
|
...alerts_default,
|