@indigoai-us/hq-cli 5.129.0 → 5.130.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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.130.0] — 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Hook-timeout reports remain visible as warning events with stable grouping and
|
|
10
|
+
diagnostic tags. Repeated reports are rate-limited while the next capture
|
|
11
|
+
records the suppressed count.
|
|
12
|
+
|
|
5
13
|
## [5.129.0] — 2026-09-22
|
|
6
14
|
|
|
7
15
|
### Added
|
package/dist/commands/core.js
CHANGED
|
@@ -483,7 +483,13 @@ export function registerCoreCommands(program) {
|
|
|
483
483
|
.action(async (options) => {
|
|
484
484
|
const stdin = await readSentryReportStdin(process.stdin);
|
|
485
485
|
const timeoutMs = options.timeoutMs === undefined ? undefined : Number(options.timeoutMs);
|
|
486
|
-
const
|
|
486
|
+
const scope = core.opts();
|
|
487
|
+
const hqRoot = scope.hqRoot ? resolveLiveRoot({ hqRoot: scope.hqRoot }) : undefined;
|
|
488
|
+
const result = await reportSentryEvent(stdin ?? "", {
|
|
489
|
+
dryRun: options.dryRun,
|
|
490
|
+
timeoutMs,
|
|
491
|
+
hqRoot,
|
|
492
|
+
});
|
|
487
493
|
if (result.code !== 0)
|
|
488
494
|
process.exitCode = result.code;
|
|
489
495
|
});
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The stdin event schema is deliberately closed. Hook payloads can contain
|
|
5
5
|
* commands, prompts, source content, transcript locations, and credentials, so
|
|
6
|
-
* this module accepts only a small set of scalar diagnostics
|
|
7
|
-
* hook payload or the process environment. The
|
|
6
|
+
* this module accepts only a small set of scalar diagnostics plus bounded hook
|
|
7
|
+
* history and never reads a hook payload or the process environment. The
|
|
8
|
+
* owner-approved `hook_path`
|
|
8
9
|
* diagnostic is the narrow exception: it names the slow hook script and may
|
|
9
10
|
* include its company directory, but never carries the hook payload itself.
|
|
10
11
|
*
|
|
11
|
-
* Invalid top-level input and structurally rich metadata are rejected
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Invalid top-level input and structurally rich metadata are rejected. The
|
|
13
|
+
* bounded hook sequence is the deliberate exception: it keeps only the most
|
|
14
|
+
* recent entries so diagnostics cannot expand the transport payload. Unknown
|
|
15
|
+
* scalar metadata is dropped instead: the allowlist, not a caller version
|
|
16
|
+
* match, determines what may leave the machine.
|
|
14
17
|
*/
|
|
15
18
|
import * as Sentry from "@sentry/node";
|
|
16
19
|
import type { CaptureContext, SeverityLevel } from "@sentry/node";
|
|
@@ -24,9 +27,15 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
|
|
|
24
27
|
* is owner-approved telemetry for the exact hook script and may include a
|
|
25
28
|
* company directory; it is never a substitute for hook input or a transcript.
|
|
26
29
|
*/
|
|
27
|
-
export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average"];
|
|
30
|
+
export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
|
|
28
31
|
export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
|
|
29
|
-
export type
|
|
32
|
+
export type HookSequenceEntry = {
|
|
33
|
+
script: string;
|
|
34
|
+
event: string;
|
|
35
|
+
ms: number;
|
|
36
|
+
};
|
|
37
|
+
export type SentryReportMetadataValue = string | number | boolean | HookSequenceEntry[];
|
|
38
|
+
export type SentryReportMetadata = Record<string, SentryReportMetadataValue>;
|
|
30
39
|
export type SentryReportEvent = {
|
|
31
40
|
type: string;
|
|
32
41
|
message: string;
|
|
@@ -38,15 +47,26 @@ export type SentryReportOptions = {
|
|
|
38
47
|
dryRun?: boolean;
|
|
39
48
|
/** Total time reserved for the report flush after validation. */
|
|
40
49
|
timeoutMs?: number;
|
|
50
|
+
/** Explicit live HQ root supplied by `hq core --hq-root`. */
|
|
51
|
+
hqRoot?: string;
|
|
52
|
+
/** Test seam for the bounded timeout-report stamp store. */
|
|
53
|
+
rateLimitDirectory?: string;
|
|
54
|
+
/** Test seam for deterministic rate-limit windows. */
|
|
55
|
+
rateLimitNow?: () => number;
|
|
41
56
|
};
|
|
42
57
|
export type SentryReportDependencies = {
|
|
43
58
|
sentry: {
|
|
44
59
|
captureMessage: (message: string, context?: CaptureContext | SeverityLevel) => string;
|
|
60
|
+
addBreadcrumb: (breadcrumb: Parameters<typeof Sentry.addBreadcrumb>[0]) => void;
|
|
45
61
|
metrics: Pick<typeof Sentry.metrics, "count">;
|
|
46
62
|
flush: (timeout?: number) => PromiseLike<boolean>;
|
|
47
63
|
};
|
|
48
64
|
stdout: Pick<NodeJS.WriteStream, "write">;
|
|
49
65
|
stderr: Pick<NodeJS.WriteStream, "write">;
|
|
66
|
+
/** Test seam; production resolves the live HQ workspace tmp directory. */
|
|
67
|
+
rateLimitDirectory?: string;
|
|
68
|
+
/** Test seam; production uses Date.now. */
|
|
69
|
+
rateLimitNow?: () => number;
|
|
50
70
|
};
|
|
51
71
|
export type SentryReportResult = {
|
|
52
72
|
code: 0 | 1;
|
|
@@ -3,17 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The stdin event schema is deliberately closed. Hook payloads can contain
|
|
5
5
|
* commands, prompts, source content, transcript locations, and credentials, so
|
|
6
|
-
* this module accepts only a small set of scalar diagnostics
|
|
7
|
-
* hook payload or the process environment. The
|
|
6
|
+
* this module accepts only a small set of scalar diagnostics plus bounded hook
|
|
7
|
+
* history and never reads a hook payload or the process environment. The
|
|
8
|
+
* owner-approved `hook_path`
|
|
8
9
|
* diagnostic is the narrow exception: it names the slow hook script and may
|
|
9
10
|
* include its company directory, but never carries the hook payload itself.
|
|
10
11
|
*
|
|
11
|
-
* Invalid top-level input and structurally rich metadata are rejected
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Invalid top-level input and structurally rich metadata are rejected. The
|
|
13
|
+
* bounded hook sequence is the deliberate exception: it keeps only the most
|
|
14
|
+
* recent entries so diagnostics cannot expand the transport payload. Unknown
|
|
15
|
+
* scalar metadata is dropped instead: the allowlist, not a caller version
|
|
16
|
+
* match, determines what may leave the machine.
|
|
14
17
|
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
15
22
|
import * as Sentry from "@sentry/node";
|
|
23
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
16
24
|
import { beforeSend } from "../../sentry-before-send.js";
|
|
25
|
+
import { resolveLiveRoot } from "../../utils/hq-roots.js";
|
|
17
26
|
export const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
18
27
|
export const MAX_SENTRY_REPORT_TIMEOUT_MS = 5_000;
|
|
19
28
|
export const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2_048;
|
|
@@ -30,8 +39,17 @@ const SENTRY_REPORT_LEVELS = new Set([
|
|
|
30
39
|
"debug",
|
|
31
40
|
]);
|
|
32
41
|
const HOOK_TIMEOUT_WARNING_TYPE = "hook_timeout_warning";
|
|
42
|
+
const HOOK_TIMEOUT_EXCEEDED_TYPE = "hook_timeout_exceeded";
|
|
43
|
+
const HOOK_LATE_FINISH_TYPE = "hook_late_finish";
|
|
33
44
|
const HOOK_TIMEOUT_WARNING_METRIC = "hq.hook_timeout_warning";
|
|
45
|
+
const HOOK_TIMEOUT_EXCEEDED_METRIC = "hq.hook_timeout_exceeded";
|
|
34
46
|
const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
|
|
47
|
+
const HOOK_TIMEOUT_WARNING_FINGERPRINT = "hook-timeout";
|
|
48
|
+
const MAX_HOOK_SEQUENCE_ENTRIES = 20;
|
|
49
|
+
const HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
|
50
|
+
const RATE_LIMIT_LOCK_WAIT_MS = 500;
|
|
51
|
+
const RATE_LIMIT_LOCK_POLL_MS = 5;
|
|
52
|
+
const RATE_LIMIT_LOCK_STALE_MS = 5_000;
|
|
35
53
|
/**
|
|
36
54
|
* The only hook diagnostics that can be attached as metadata. The list omits
|
|
37
55
|
* command text, prompts, payloads, company names, and repositories. `hook_path`
|
|
@@ -42,6 +60,7 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
|
|
|
42
60
|
"hook_name",
|
|
43
61
|
"hook_path",
|
|
44
62
|
"hook_event",
|
|
63
|
+
"hook_script",
|
|
45
64
|
"tool_name",
|
|
46
65
|
"session_id",
|
|
47
66
|
"declared_timeout_ms",
|
|
@@ -52,6 +71,13 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
|
|
|
52
71
|
"hq_version",
|
|
53
72
|
"platform",
|
|
54
73
|
"load_average",
|
|
74
|
+
"exit_code",
|
|
75
|
+
"final_elapsed_ms",
|
|
76
|
+
"bash_env_set",
|
|
77
|
+
"cwd_kind",
|
|
78
|
+
"shell",
|
|
79
|
+
"nproc",
|
|
80
|
+
"hook_sequence",
|
|
55
81
|
];
|
|
56
82
|
const ALLOWED_METADATA_KEYS = new Set(ALLOWED_SENTRY_REPORT_METADATA_KEYS);
|
|
57
83
|
const SENTRY_REPORT_METADATA_KEY_HEADROOM = 2;
|
|
@@ -96,7 +122,34 @@ function isScalarMetadataValue(value) {
|
|
|
96
122
|
return true;
|
|
97
123
|
return typeof value === "number" && Number.isFinite(value);
|
|
98
124
|
}
|
|
125
|
+
function isHookSequenceEntry(value) {
|
|
126
|
+
if (!isRecord(value))
|
|
127
|
+
return false;
|
|
128
|
+
return (typeof value.script === "string" &&
|
|
129
|
+
value.script.length > 0 &&
|
|
130
|
+
byteLength(value.script) <= MAX_SENTRY_REPORT_METADATA_VALUE_BYTES &&
|
|
131
|
+
typeof value.event === "string" &&
|
|
132
|
+
value.event.length > 0 &&
|
|
133
|
+
byteLength(value.event) <= MAX_SENTRY_REPORT_METADATA_VALUE_BYTES &&
|
|
134
|
+
typeof value.ms === "number" &&
|
|
135
|
+
Number.isFinite(value.ms) &&
|
|
136
|
+
value.ms >= 0);
|
|
137
|
+
}
|
|
138
|
+
function boundedHookSequence(value) {
|
|
139
|
+
if (!Array.isArray(value))
|
|
140
|
+
return null;
|
|
141
|
+
const recent = value.slice(-MAX_HOOK_SEQUENCE_ENTRIES);
|
|
142
|
+
if (!recent.every(isHookSequenceEntry))
|
|
143
|
+
return null;
|
|
144
|
+
return recent.map((entry) => ({
|
|
145
|
+
script: basenameFromPath(entry.script),
|
|
146
|
+
event: entry.event,
|
|
147
|
+
ms: entry.ms,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
99
150
|
function isAllowedMetadataValue(key, value) {
|
|
151
|
+
if (key === "hook_sequence")
|
|
152
|
+
return boundedHookSequence(value) !== null;
|
|
100
153
|
if (!isScalarMetadataValue(value))
|
|
101
154
|
return false;
|
|
102
155
|
if (key === "tool_name" || key === "session_id" || key === "hook_path") {
|
|
@@ -157,15 +210,18 @@ export function parseSentryReportEvent(stdin) {
|
|
|
157
210
|
const metadata = {};
|
|
158
211
|
let droppedMetadataKeys = 0;
|
|
159
212
|
for (const key of metadataKeys) {
|
|
160
|
-
if (!isScalarMetadataValue(rawMetadata[key]))
|
|
161
|
-
return invalidInput();
|
|
162
213
|
if (!ALLOWED_METADATA_KEYS.has(key)) {
|
|
214
|
+
if (!isScalarMetadataValue(rawMetadata[key]))
|
|
215
|
+
return invalidInput();
|
|
163
216
|
droppedMetadataKeys++;
|
|
164
217
|
continue;
|
|
165
218
|
}
|
|
166
219
|
if (!isAllowedMetadataValue(key, rawMetadata[key]))
|
|
167
220
|
return invalidInput();
|
|
168
|
-
metadata[key] =
|
|
221
|
+
metadata[key] =
|
|
222
|
+
key === "hook_sequence"
|
|
223
|
+
? boundedHookSequence(rawMetadata[key])
|
|
224
|
+
: rawMetadata[key];
|
|
169
225
|
}
|
|
170
226
|
return {
|
|
171
227
|
code: 0,
|
|
@@ -185,11 +241,139 @@ function resolveTimeout(timeoutMs) {
|
|
|
185
241
|
return null;
|
|
186
242
|
return timeout;
|
|
187
243
|
}
|
|
188
|
-
function
|
|
244
|
+
function isHookTimeoutEvent(event) {
|
|
245
|
+
return (event.type === HOOK_TIMEOUT_WARNING_TYPE ||
|
|
246
|
+
event.type === HOOK_TIMEOUT_EXCEEDED_TYPE ||
|
|
247
|
+
event.type === HOOK_LATE_FINISH_TYPE);
|
|
248
|
+
}
|
|
249
|
+
function stringMetadata(metadata, key, fallback = "unknown") {
|
|
250
|
+
const value = metadata[key];
|
|
251
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
252
|
+
}
|
|
253
|
+
function basenameFromPath(value) {
|
|
254
|
+
const normalized = value.replaceAll("\\", "/");
|
|
255
|
+
const last = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
256
|
+
return last || "unknown";
|
|
257
|
+
}
|
|
258
|
+
const SAFE_TAG_TEXT = /^[A-Za-z0-9][A-Za-z0-9._:/+() -]{0,127}$/;
|
|
259
|
+
const SAFE_HOOK_EVENT = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
260
|
+
const SAFE_HOOK_SCRIPT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
261
|
+
const SAFE_EXIT_CODE = /^(?:-?[0-9]+|running)$/;
|
|
262
|
+
const SAFE_NPROC = /^[0-9]+$/;
|
|
263
|
+
function safeTagValue(value, pattern = SAFE_TAG_TEXT) {
|
|
264
|
+
return pattern.test(value) ? value : "unknown";
|
|
265
|
+
}
|
|
266
|
+
function safeMetadataTag(metadata, key, pattern = SAFE_TAG_TEXT) {
|
|
267
|
+
const value = metadata[key];
|
|
268
|
+
return typeof value === "string" && value.length > 0 ? safeTagValue(value, pattern) : "unknown";
|
|
269
|
+
}
|
|
270
|
+
function hookEvent(metadata) {
|
|
271
|
+
return safeMetadataTag(metadata, "hook_event", SAFE_HOOK_EVENT);
|
|
272
|
+
}
|
|
273
|
+
function hookScript(metadata) {
|
|
274
|
+
const explicit = stringMetadata(metadata, "hook_script", "");
|
|
275
|
+
const path = explicit || stringMetadata(metadata, "hook_path", "");
|
|
276
|
+
return path ? safeTagValue(basenameFromPath(path), SAFE_HOOK_SCRIPT) : "unknown";
|
|
277
|
+
}
|
|
278
|
+
function timeoutElapsedMs(metadata) {
|
|
279
|
+
const finalElapsed = metadata.final_elapsed_ms;
|
|
280
|
+
if (typeof finalElapsed === "number" && Number.isFinite(finalElapsed))
|
|
281
|
+
return finalElapsed;
|
|
282
|
+
const elapsed = metadata.elapsed_ms;
|
|
283
|
+
return typeof elapsed === "number" && Number.isFinite(elapsed) ? elapsed : undefined;
|
|
284
|
+
}
|
|
285
|
+
function formatNumber(value) {
|
|
286
|
+
return value.toFixed(2).replace(/\.00$|(?<=\.[0-9])0$/, "");
|
|
287
|
+
}
|
|
288
|
+
function elapsedBucket(elapsedMs) {
|
|
289
|
+
if (elapsedMs === undefined)
|
|
290
|
+
return "unknown";
|
|
291
|
+
if (elapsedMs < 5_000)
|
|
292
|
+
return "<5s";
|
|
293
|
+
if (elapsedMs < 10_000)
|
|
294
|
+
return "5-10s";
|
|
295
|
+
if (elapsedMs < 20_000)
|
|
296
|
+
return "10-20s";
|
|
297
|
+
if (elapsedMs < 60_000)
|
|
298
|
+
return "20-60s";
|
|
299
|
+
return ">=60s";
|
|
300
|
+
}
|
|
301
|
+
function loadBucket(loadAverage) {
|
|
302
|
+
if (loadAverage === undefined)
|
|
303
|
+
return "unknown";
|
|
304
|
+
if (loadAverage < 2)
|
|
305
|
+
return "<2";
|
|
306
|
+
if (loadAverage < 5)
|
|
307
|
+
return "2-5";
|
|
308
|
+
if (loadAverage < 10)
|
|
309
|
+
return "5-10";
|
|
310
|
+
if (loadAverage < 20)
|
|
311
|
+
return "10-20";
|
|
312
|
+
return ">=20";
|
|
313
|
+
}
|
|
314
|
+
function bashEnvTag(metadata) {
|
|
315
|
+
const value = metadata.bash_env_set;
|
|
316
|
+
if (value === true || value === "set")
|
|
317
|
+
return "set";
|
|
318
|
+
if (value === false || value === "unset")
|
|
319
|
+
return "unset";
|
|
320
|
+
return "unknown";
|
|
321
|
+
}
|
|
322
|
+
function hookTimeoutTags(event) {
|
|
323
|
+
const metadata = event.metadata;
|
|
324
|
+
const elapsedMs = timeoutElapsedMs(metadata);
|
|
325
|
+
const declaredTimeoutMs = typeof metadata.declared_timeout_ms === "number" && Number.isFinite(metadata.declared_timeout_ms)
|
|
326
|
+
? metadata.declared_timeout_ms
|
|
327
|
+
: undefined;
|
|
328
|
+
const ratio = elapsedMs !== undefined && declaredTimeoutMs !== undefined && declaredTimeoutMs > 0
|
|
329
|
+
? Math.round((elapsedMs / declaredTimeoutMs) * 4) / 4
|
|
330
|
+
: undefined;
|
|
331
|
+
const shell = stringMetadata(metadata, "shell");
|
|
332
|
+
const nproc = metadata.nproc;
|
|
333
|
+
const loadAverage = metadata.load_average;
|
|
334
|
+
return {
|
|
335
|
+
hook_event: hookEvent(metadata),
|
|
336
|
+
hook_script: hookScript(metadata),
|
|
337
|
+
elapsed_bucket: elapsedBucket(elapsedMs),
|
|
338
|
+
declared_timeout_s: declaredTimeoutMs === undefined ? "unknown" : formatNumber(declaredTimeoutMs / 1_000),
|
|
339
|
+
watchdog_ratio: ratio === undefined ? "unknown" : formatNumber(ratio),
|
|
340
|
+
exit_code: typeof metadata.exit_code === "string" || typeof metadata.exit_code === "number"
|
|
341
|
+
? safeTagValue(String(metadata.exit_code), SAFE_EXIT_CODE)
|
|
342
|
+
: "unknown",
|
|
343
|
+
platform: safeMetadataTag(metadata, "platform"),
|
|
344
|
+
hq_cli_version: CLI_VERSION,
|
|
345
|
+
hq_core_version: safeMetadataTag(metadata, "hq_version"),
|
|
346
|
+
shell: safeTagValue(shell),
|
|
347
|
+
bash_env: bashEnvTag(metadata),
|
|
348
|
+
cwd_kind: safeMetadataTag(metadata, "cwd_kind"),
|
|
349
|
+
load_bucket: typeof loadAverage === "number" ? loadBucket(loadAverage) : "unknown",
|
|
350
|
+
nproc: typeof nproc === "number" || typeof nproc === "string"
|
|
351
|
+
? safeTagValue(String(nproc), SAFE_NPROC)
|
|
352
|
+
: "unknown",
|
|
353
|
+
tool_name: safeMetadataTag(metadata, "tool_name"),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function hookTimeoutKind(event) {
|
|
357
|
+
if (event.type === HOOK_TIMEOUT_EXCEEDED_TYPE || event.type === HOOK_LATE_FINISH_TYPE) {
|
|
358
|
+
return "exceeded";
|
|
359
|
+
}
|
|
360
|
+
const elapsedMs = event.metadata.elapsed_ms;
|
|
361
|
+
const declaredTimeoutMs = event.metadata.declared_timeout_ms;
|
|
362
|
+
return typeof elapsedMs === "number" &&
|
|
363
|
+
typeof declaredTimeoutMs === "number" &&
|
|
364
|
+
elapsedMs >= declaredTimeoutMs
|
|
365
|
+
? "exceeded"
|
|
366
|
+
: "warning";
|
|
367
|
+
}
|
|
368
|
+
function sentryEnvelope(event, overrides, additionalTags = {}) {
|
|
189
369
|
return {
|
|
190
370
|
level: overrides?.level ?? event.level,
|
|
191
|
-
fingerprint: overrides?.fingerprint ?? [event.fingerprint],
|
|
192
|
-
tags: {
|
|
371
|
+
fingerprint: overrides?.fingerprint ?? [safeTagValue(event.fingerprint)],
|
|
372
|
+
tags: {
|
|
373
|
+
sentry_event_type: safeTagValue(event.type),
|
|
374
|
+
...(isHookTimeoutEvent(event) ? hookTimeoutTags(event) : {}),
|
|
375
|
+
...additionalTags,
|
|
376
|
+
},
|
|
193
377
|
extra: event.metadata,
|
|
194
378
|
};
|
|
195
379
|
}
|
|
@@ -201,25 +385,23 @@ function scrubbedSentryEnvelope(event, overrides) {
|
|
|
201
385
|
}, {});
|
|
202
386
|
}
|
|
203
387
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* producer without restoring per-hook grouping.
|
|
388
|
+
* Keep timeout faults visible at warning level while grouping them by the
|
|
389
|
+
* hook event and installation-independent script basename. A genuine
|
|
390
|
+
* exceeded or late-finish payload gets its own stable condition group.
|
|
208
391
|
*/
|
|
209
392
|
function hookTimeoutExceededOverrides(event) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const elapsedMs = event.metadata.elapsed_ms;
|
|
213
|
-
const declaredTimeoutMs = event.metadata.declared_timeout_ms;
|
|
214
|
-
if (typeof elapsedMs !== "number" ||
|
|
215
|
-
typeof declaredTimeoutMs !== "number" ||
|
|
216
|
-
elapsedMs < declaredTimeoutMs) {
|
|
393
|
+
const eventName = hookEvent(event.metadata);
|
|
394
|
+
if (!isHookTimeoutEvent(event))
|
|
217
395
|
return undefined;
|
|
396
|
+
if (hookTimeoutKind(event) === "warning") {
|
|
397
|
+
return {
|
|
398
|
+
level: "warning",
|
|
399
|
+
fingerprint: [HOOK_TIMEOUT_WARNING_FINGERPRINT, eventName, hookScript(event.metadata)],
|
|
400
|
+
};
|
|
218
401
|
}
|
|
219
|
-
const hookEvent = typeof event.metadata.hook_event === "string" ? event.metadata.hook_event : "unknown";
|
|
220
402
|
return {
|
|
221
403
|
level: "warning",
|
|
222
|
-
fingerprint: [HOOK_TIMEOUT_EXCEEDED_FINGERPRINT,
|
|
404
|
+
fingerprint: [HOOK_TIMEOUT_EXCEEDED_FINGERPRINT, eventName, hookScript(event.metadata)],
|
|
223
405
|
};
|
|
224
406
|
}
|
|
225
407
|
/**
|
|
@@ -240,6 +422,202 @@ async function flushWithin(sentry, timeoutMs) {
|
|
|
240
422
|
});
|
|
241
423
|
});
|
|
242
424
|
}
|
|
425
|
+
function rateLimitDirectory(options, dependencies) {
|
|
426
|
+
if (options.rateLimitDirectory)
|
|
427
|
+
return options.rateLimitDirectory;
|
|
428
|
+
if (options.hqRoot)
|
|
429
|
+
return join(options.hqRoot, "workspace", "tmp", "sentry-hook-timeouts");
|
|
430
|
+
if (dependencies.rateLimitDirectory)
|
|
431
|
+
return dependencies.rateLimitDirectory;
|
|
432
|
+
try {
|
|
433
|
+
return join(resolveLiveRoot(), "workspace", "tmp", "sentry-hook-timeouts");
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
dependencies.stderr.write("Sentry timeout rate limit root unavailable; using process temp.\n");
|
|
437
|
+
return join(tmpdir(), "hq-cli-sentry-hook-timeouts");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function rateLimitStatePath(directory, key) {
|
|
441
|
+
const digest = createHash("sha256").update(key).digest("hex");
|
|
442
|
+
return join(directory, `${digest}.json`);
|
|
443
|
+
}
|
|
444
|
+
function readRateLimitState(statePath) {
|
|
445
|
+
try {
|
|
446
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
447
|
+
if (typeof parsed.lastCapturedAt !== "number" ||
|
|
448
|
+
!Number.isFinite(parsed.lastCapturedAt) ||
|
|
449
|
+
typeof parsed.suppressedCount !== "number" ||
|
|
450
|
+
!Number.isInteger(parsed.suppressedCount) ||
|
|
451
|
+
parsed.suppressedCount < 0) {
|
|
452
|
+
throw new Error("invalid timeout rate limit state");
|
|
453
|
+
}
|
|
454
|
+
return parsed;
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (error.code === "ENOENT")
|
|
458
|
+
return undefined;
|
|
459
|
+
throw new Error("Unable to read the timeout rate limit state", { cause: error });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function writeRateLimitState(statePath, state) {
|
|
463
|
+
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
464
|
+
const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
465
|
+
writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
466
|
+
renameSync(temporaryPath, statePath);
|
|
467
|
+
}
|
|
468
|
+
function sleepForRateLimitLock(milliseconds) {
|
|
469
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
470
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
471
|
+
}
|
|
472
|
+
function releaseRateLimitLock(lockPath) {
|
|
473
|
+
try {
|
|
474
|
+
rmdirSync(lockPath);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (error.code !== "ENOENT")
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function acquireRateLimitLock(directory) {
|
|
482
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
483
|
+
const lockPath = join(directory, ".lock");
|
|
484
|
+
const deadline = Date.now() + RATE_LIMIT_LOCK_WAIT_MS;
|
|
485
|
+
for (;;) {
|
|
486
|
+
try {
|
|
487
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
488
|
+
let released = false;
|
|
489
|
+
return () => {
|
|
490
|
+
if (released)
|
|
491
|
+
return;
|
|
492
|
+
released = true;
|
|
493
|
+
releaseRateLimitLock(lockPath);
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
if (error.code !== "EEXIST")
|
|
498
|
+
throw error;
|
|
499
|
+
try {
|
|
500
|
+
if (Date.now() - statSync(lockPath).mtimeMs >= RATE_LIMIT_LOCK_STALE_MS) {
|
|
501
|
+
releaseRateLimitLock(lockPath);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (statError) {
|
|
506
|
+
if (statError.code !== "ENOENT")
|
|
507
|
+
throw statError;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (Date.now() >= deadline)
|
|
511
|
+
throw new Error("Timed out acquiring the Sentry timeout rate limit lock");
|
|
512
|
+
sleepForRateLimitLock(RATE_LIMIT_LOCK_POLL_MS);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function pruneExpiredRateLimitState(directory, now, protectedStatePath) {
|
|
517
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
518
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
519
|
+
continue;
|
|
520
|
+
const statePath = join(directory, entry.name);
|
|
521
|
+
if (statePath === protectedStatePath)
|
|
522
|
+
continue;
|
|
523
|
+
const state = readRateLimitState(statePath);
|
|
524
|
+
if (state &&
|
|
525
|
+
now >= state.lastCapturedAt &&
|
|
526
|
+
now - state.lastCapturedAt >= HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS) {
|
|
527
|
+
try {
|
|
528
|
+
unlinkSync(statePath);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error.code !== "ENOENT")
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function timeoutRateLimitLease(event, kind, options, dependencies) {
|
|
538
|
+
const sessionId = stringMetadata(event.metadata, "session_id");
|
|
539
|
+
const key = `${sessionId}\0${hookEvent(event.metadata)}\0${hookScript(event.metadata)}\0${kind}`;
|
|
540
|
+
const directory = rateLimitDirectory(options, dependencies);
|
|
541
|
+
const statePath = rateLimitStatePath(directory, key);
|
|
542
|
+
const releaseLock = acquireRateLimitLock(directory);
|
|
543
|
+
const now = options.rateLimitNow?.() ?? dependencies.rateLimitNow?.() ?? Date.now();
|
|
544
|
+
try {
|
|
545
|
+
pruneExpiredRateLimitState(directory, now, statePath);
|
|
546
|
+
const current = readRateLimitState(statePath);
|
|
547
|
+
if (current &&
|
|
548
|
+
now >= current.lastCapturedAt &&
|
|
549
|
+
now - current.lastCapturedAt < HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS) {
|
|
550
|
+
writeRateLimitState(statePath, {
|
|
551
|
+
lastCapturedAt: current.lastCapturedAt,
|
|
552
|
+
suppressedCount: current.suppressedCount + 1,
|
|
553
|
+
});
|
|
554
|
+
releaseLock();
|
|
555
|
+
return {
|
|
556
|
+
capture: false,
|
|
557
|
+
suppressedSinceLast: 0,
|
|
558
|
+
commitCapture: () => undefined,
|
|
559
|
+
release: () => undefined,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
let released = false;
|
|
563
|
+
const release = () => {
|
|
564
|
+
if (released)
|
|
565
|
+
return;
|
|
566
|
+
released = true;
|
|
567
|
+
releaseLock();
|
|
568
|
+
};
|
|
569
|
+
let committed = false;
|
|
570
|
+
return {
|
|
571
|
+
capture: true,
|
|
572
|
+
suppressedSinceLast: current?.suppressedCount ?? 0,
|
|
573
|
+
commitCapture: () => {
|
|
574
|
+
if (committed)
|
|
575
|
+
return;
|
|
576
|
+
if (released)
|
|
577
|
+
throw new Error("Sentry timeout rate limit lease was released before capture");
|
|
578
|
+
writeRateLimitState(statePath, { lastCapturedAt: now, suppressedCount: 0 });
|
|
579
|
+
committed = true;
|
|
580
|
+
release();
|
|
581
|
+
},
|
|
582
|
+
release,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
releaseLock();
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function metricAttributes(metadata) {
|
|
591
|
+
return Object.fromEntries(Object.entries(metadata)
|
|
592
|
+
.filter(([, value]) => isScalarMetadataValue(value))
|
|
593
|
+
.map(([key, value]) => [key, typeof value === "string" ? safeTagValue(value) : value]));
|
|
594
|
+
}
|
|
595
|
+
function addHookSequenceBreadcrumbs(event, dependencies) {
|
|
596
|
+
const sequence = event.metadata.hook_sequence;
|
|
597
|
+
if (!Array.isArray(sequence))
|
|
598
|
+
return;
|
|
599
|
+
for (const entry of sequence) {
|
|
600
|
+
dependencies.sentry.addBreadcrumb({
|
|
601
|
+
category: "hq.hook",
|
|
602
|
+
message: `${entry.script} ${entry.event} ${entry.ms}ms`,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async function reportSuppressedTimeout(result, event, timeoutMs, dependencies, kind) {
|
|
607
|
+
const metricName = kind === "exceeded" ? HOOK_TIMEOUT_EXCEEDED_METRIC : HOOK_TIMEOUT_WARNING_METRIC;
|
|
608
|
+
try {
|
|
609
|
+
dependencies.sentry.metrics.count(metricName, 1, {
|
|
610
|
+
attributes: metricAttributes(event.metadata),
|
|
611
|
+
});
|
|
612
|
+
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
613
|
+
return result;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// Report failure below without exposing event fields or transport details.
|
|
617
|
+
}
|
|
618
|
+
dependencies.stderr.write("Sentry report failed.\n");
|
|
619
|
+
return invalidInput();
|
|
620
|
+
}
|
|
243
621
|
/**
|
|
244
622
|
* Capture one validated Sentry message and wait only for the supplied bounded
|
|
245
623
|
* flush budget. A zero result means the event entered the local transport and
|
|
@@ -266,15 +644,37 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
266
644
|
dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
|
|
267
645
|
return result;
|
|
268
646
|
}
|
|
269
|
-
if (result.event
|
|
647
|
+
if (isHookTimeoutEvent(result.event)) {
|
|
648
|
+
addHookSequenceBreadcrumbs(result.event, dependencies);
|
|
649
|
+
const kind = hookTimeoutKind(result.event);
|
|
650
|
+
let rateLimit;
|
|
270
651
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
652
|
+
rateLimit = timeoutRateLimitLease(result.event, kind, options, dependencies);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
dependencies.stderr.write("Sentry timeout rate limit state unavailable; sending without rate limiting.\n");
|
|
656
|
+
rateLimit = {
|
|
657
|
+
capture: true,
|
|
658
|
+
suppressedSinceLast: 0,
|
|
659
|
+
commitCapture: () => undefined,
|
|
660
|
+
release: () => undefined,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (!rateLimit.capture) {
|
|
664
|
+
return reportSuppressedTimeout(result, result.event, timeoutMs, dependencies, kind);
|
|
665
|
+
}
|
|
666
|
+
const tags = rateLimit.suppressedSinceLast > 0
|
|
667
|
+
? { suppressed_since_last: String(rateLimit.suppressedSinceLast) }
|
|
668
|
+
: {};
|
|
669
|
+
const envelope = sentryEnvelope(result.event, overrides, tags);
|
|
670
|
+
try {
|
|
671
|
+
dependencies.sentry.captureMessage(result.event.message, envelope);
|
|
672
|
+
rateLimit.commitCapture();
|
|
274
673
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
275
674
|
return result;
|
|
276
675
|
}
|
|
277
676
|
catch {
|
|
677
|
+
rateLimit.release();
|
|
278
678
|
// Report failure below without exposing event fields or transport details.
|
|
279
679
|
}
|
|
280
680
|
dependencies.stderr.write("Sentry report failed.\n");
|