@indigoai-us/hq-cli 5.182.0 → 5.183.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 +6 -0
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.d.ts +20 -0
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.js +80 -0
- package/dist/lib/core-utils/sentry-report.d.ts +24 -2
- package/dist/lib/core-utils/sentry-report.js +254 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.183.0] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq core sentry report` can attach bounded hook-timeout timing context to Sentry events when the company-scoped `hook-timeout-debug-context` flag is enabled. The flag defaults off.
|
|
10
|
+
|
|
5
11
|
## [5.182.0] — 2026-09-24
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
export declare const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG = "hook-timeout-debug-context";
|
|
3
|
+
export declare const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK = false;
|
|
4
|
+
export declare const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_TIMEOUT_MS = 150;
|
|
5
|
+
type DebugContextFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface HookTimeoutDebugContextFlagDependencies {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
createClient?: (options: FlagClientOptions) => DebugContextFlagClient;
|
|
9
|
+
getToken?: FlagClientOptions["getToken"];
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
reportFailure?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read the optional diagnostic gate with a short, cancellable hq-flags request.
|
|
15
|
+
* Missing identity/configuration and every client failure use the default-off
|
|
16
|
+
* result so a flag outage cannot delay or suppress the underlying timeout.
|
|
17
|
+
*/
|
|
18
|
+
export declare function hookTimeoutDebugContextEnabled(dependencies?: HookTimeoutDebugContextFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=hook-timeout-debug-context-flag.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
export const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG = "hook-timeout-debug-context";
|
|
4
|
+
export const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK = false;
|
|
5
|
+
export const HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_TIMEOUT_MS = 150;
|
|
6
|
+
function safeErrorClass(error) {
|
|
7
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
8
|
+
return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
9
|
+
}
|
|
10
|
+
function reportFlagFailure(error) {
|
|
11
|
+
process.stderr.write(`HQ hook timeout debug flag lookup failed (${safeErrorClass(error)}); using default off.\n`);
|
|
12
|
+
}
|
|
13
|
+
function combineSignals(first, second) {
|
|
14
|
+
return first ? AbortSignal.any([first, second]) : second;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read the optional diagnostic gate with a short, cancellable hq-flags request.
|
|
18
|
+
* Missing identity/configuration and every client failure use the default-off
|
|
19
|
+
* result so a flag outage cannot delay or suppress the underlying timeout.
|
|
20
|
+
*/
|
|
21
|
+
export async function hookTimeoutDebugContextEnabled(dependencies = {}, callerSignal) {
|
|
22
|
+
const env = dependencies.env ?? process.env;
|
|
23
|
+
const endpoint = env.HQ_FLAGS_API_URL?.trim() || "";
|
|
24
|
+
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
25
|
+
if (!endpoint || !/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
26
|
+
return HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK;
|
|
27
|
+
}
|
|
28
|
+
const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
|
|
29
|
+
const deadline = AbortSignal.timeout(HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_TIMEOUT_MS);
|
|
30
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
|
|
31
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
32
|
+
let client;
|
|
33
|
+
let failed = false;
|
|
34
|
+
let failureReported = false;
|
|
35
|
+
let enabled = HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK;
|
|
36
|
+
const reportFailureOnce = (error) => {
|
|
37
|
+
failed = true;
|
|
38
|
+
if (failureReported)
|
|
39
|
+
return;
|
|
40
|
+
failureReported = true;
|
|
41
|
+
(dependencies.reportFailure ?? reportFlagFailure)(error);
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
45
|
+
endpoint,
|
|
46
|
+
companyUid,
|
|
47
|
+
...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
|
|
48
|
+
? { companyIdentifiers: [companySlug] }
|
|
49
|
+
: {}),
|
|
50
|
+
env: {},
|
|
51
|
+
getToken: dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? ""),
|
|
52
|
+
fetch: (input, init) => doFetch(input, {
|
|
53
|
+
...init,
|
|
54
|
+
signal: combineSignals(init?.signal ?? undefined, signal),
|
|
55
|
+
}),
|
|
56
|
+
refreshIntervalMs: 0,
|
|
57
|
+
requestTimeoutMs: HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_TIMEOUT_MS,
|
|
58
|
+
onError: reportFailureOnce,
|
|
59
|
+
});
|
|
60
|
+
await client.ready();
|
|
61
|
+
if (!failed && !signal.aborted) {
|
|
62
|
+
enabled = client.isEnabled(HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG, {
|
|
63
|
+
fallback: HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
reportFailureOnce(error);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
try {
|
|
72
|
+
client?.close();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
reportFailureOnce(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return failed || signal.aborted ? HOOK_TIMEOUT_DEBUG_CONTEXT_FLAG_FALLBACK : enabled;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=hook-timeout-debug-context-flag.js.map
|
|
@@ -27,14 +27,32 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
|
|
|
27
27
|
* is owner-approved telemetry for the exact hook script and may include a
|
|
28
28
|
* company directory; it is never a substitute for hook input or a transcript.
|
|
29
29
|
*/
|
|
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", "spawn_ms", "slow_child", "slow_child_ms", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
|
|
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", "spawn_ms", "slow_child", "slow_child_ms", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence", "hook_timeout_debug_context"];
|
|
31
31
|
export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
|
|
32
32
|
export type HookSequenceEntry = {
|
|
33
33
|
script: string;
|
|
34
34
|
event: string;
|
|
35
35
|
ms: number;
|
|
36
36
|
};
|
|
37
|
-
export type
|
|
37
|
+
export type HookTimeoutDebugPhaseTiming = {
|
|
38
|
+
phase: string;
|
|
39
|
+
elapsed_ms: number;
|
|
40
|
+
};
|
|
41
|
+
export type HookTimeoutDebugContext = {
|
|
42
|
+
hook_name: string;
|
|
43
|
+
hook_event: string;
|
|
44
|
+
budget_ms: number;
|
|
45
|
+
elapsed_ms: number;
|
|
46
|
+
remaining_ms: number;
|
|
47
|
+
phase_timings: HookTimeoutDebugPhaseTiming[];
|
|
48
|
+
wait_point: string;
|
|
49
|
+
waiting_child_basename: string;
|
|
50
|
+
waiting_child_elapsed_ms: number;
|
|
51
|
+
load_average?: number | "unavailable";
|
|
52
|
+
spawn_ms?: number | "unavailable";
|
|
53
|
+
process_count?: number | "unavailable";
|
|
54
|
+
};
|
|
55
|
+
export type SentryReportMetadataValue = string | number | boolean | HookSequenceEntry[] | HookTimeoutDebugContext;
|
|
38
56
|
export type SentryReportMetadata = Record<string, SentryReportMetadataValue>;
|
|
39
57
|
export type SentryReportEvent = {
|
|
40
58
|
type: string;
|
|
@@ -53,6 +71,8 @@ export type SentryReportOptions = {
|
|
|
53
71
|
rateLimitDirectory?: string;
|
|
54
72
|
/** Test seam for deterministic rate-limit windows. */
|
|
55
73
|
rateLimitNow?: () => number;
|
|
74
|
+
/** Optional caller cancellation forwarded to the bounded flag lookup. */
|
|
75
|
+
signal?: AbortSignal;
|
|
56
76
|
};
|
|
57
77
|
export type SentryReportDependencies = {
|
|
58
78
|
sentry: {
|
|
@@ -67,6 +87,8 @@ export type SentryReportDependencies = {
|
|
|
67
87
|
rateLimitDirectory?: string;
|
|
68
88
|
/** Test seam; production uses Date.now. */
|
|
69
89
|
rateLimitNow?: () => number;
|
|
90
|
+
/** Test seam for the company-scoped, bounded debug-context gate. */
|
|
91
|
+
timeoutDebugContextEnabled?: (signal?: AbortSignal) => Promise<boolean>;
|
|
70
92
|
};
|
|
71
93
|
export type SentryReportResult = {
|
|
72
94
|
code: 0 | 1;
|
|
@@ -23,6 +23,7 @@ import * as Sentry from "@sentry/node";
|
|
|
23
23
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
24
24
|
import { beforeSend } from "../../sentry-before-send.js";
|
|
25
25
|
import { resolveLiveRoot } from "../../utils/hq-roots.js";
|
|
26
|
+
import { hookTimeoutDebugContextEnabled } from "./hook-timeout-debug-context-flag.js";
|
|
26
27
|
export const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
27
28
|
export const MAX_SENTRY_REPORT_TIMEOUT_MS = 5_000;
|
|
28
29
|
export const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2_048;
|
|
@@ -47,6 +48,8 @@ const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
|
|
|
47
48
|
const HOOK_TIMEOUT_WARNING_FINGERPRINT = "hook-timeout";
|
|
48
49
|
const HOOK_TIMEOUT_MACHINE_WIDE_FINGERPRINT = "hq-hook-slow";
|
|
49
50
|
const MAX_HOOK_SEQUENCE_ENTRIES = 20;
|
|
51
|
+
const MAX_HOOK_TIMEOUT_DEBUG_PHASES = 24;
|
|
52
|
+
const MAX_HOOK_TIMEOUT_DEBUG_MS = 86_400_000;
|
|
50
53
|
const HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
|
51
54
|
const RATE_LIMIT_LOCK_WAIT_MS = 500;
|
|
52
55
|
const RATE_LIMIT_LOCK_POLL_MS = 5;
|
|
@@ -65,6 +68,72 @@ const KNOWN_HOOK_SCRIPTS = new Set([
|
|
|
65
68
|
"45-lanes-senior-monitor.sh",
|
|
66
69
|
"lanes-senior-monitor-stop-gate.sh",
|
|
67
70
|
]);
|
|
71
|
+
const DEBUG_CONTEXT_HOOK_NAMES = new Set([
|
|
72
|
+
...KNOWN_HOOK_SCRIPTS,
|
|
73
|
+
"reindex.sh",
|
|
74
|
+
]);
|
|
75
|
+
const DEBUG_CONTEXT_CHILD_NAMES = new Set([
|
|
76
|
+
...DEBUG_CONTEXT_HOOK_NAMES,
|
|
77
|
+
"bash",
|
|
78
|
+
"bash.exe",
|
|
79
|
+
"sh",
|
|
80
|
+
"cmd",
|
|
81
|
+
"cmd.exe",
|
|
82
|
+
"powershell",
|
|
83
|
+
"powershell.exe",
|
|
84
|
+
"wmic.exe",
|
|
85
|
+
"jq",
|
|
86
|
+
"jq.exe",
|
|
87
|
+
"node",
|
|
88
|
+
"node.exe",
|
|
89
|
+
"git",
|
|
90
|
+
"git.exe",
|
|
91
|
+
"python",
|
|
92
|
+
"python.exe",
|
|
93
|
+
"python3",
|
|
94
|
+
"python3.exe",
|
|
95
|
+
"true",
|
|
96
|
+
"sleep",
|
|
97
|
+
"cat",
|
|
98
|
+
"grep",
|
|
99
|
+
"sed",
|
|
100
|
+
"awk",
|
|
101
|
+
"find",
|
|
102
|
+
"timeout",
|
|
103
|
+
"curl",
|
|
104
|
+
"gh",
|
|
105
|
+
"hq",
|
|
106
|
+
"npm",
|
|
107
|
+
"pnpm",
|
|
108
|
+
"npx",
|
|
109
|
+
]);
|
|
110
|
+
const DEBUG_CONTEXT_HOOK_EVENTS = new Set([
|
|
111
|
+
"PreToolUse",
|
|
112
|
+
"PostToolUse",
|
|
113
|
+
"UserPromptSubmit",
|
|
114
|
+
"SessionStart",
|
|
115
|
+
"SessionEnd",
|
|
116
|
+
"Stop",
|
|
117
|
+
"PreCompact",
|
|
118
|
+
"Notification",
|
|
119
|
+
"PermissionRequest",
|
|
120
|
+
"SubagentStart",
|
|
121
|
+
"SubagentStop",
|
|
122
|
+
"TaskCompleted",
|
|
123
|
+
"TeammateIdle",
|
|
124
|
+
]);
|
|
125
|
+
const DEBUG_CONTEXT_PHASES = new Set([
|
|
126
|
+
"startup",
|
|
127
|
+
"source",
|
|
128
|
+
"config_load",
|
|
129
|
+
"policy_load",
|
|
130
|
+
"external_command",
|
|
131
|
+
"output_write",
|
|
132
|
+
"wait",
|
|
133
|
+
"child_wait",
|
|
134
|
+
"probe",
|
|
135
|
+
"parse",
|
|
136
|
+
]);
|
|
68
137
|
/**
|
|
69
138
|
* The only hook diagnostics that can be attached as metadata. The list omits
|
|
70
139
|
* command text, prompts, payloads, company names, and repositories. `hook_path`
|
|
@@ -96,6 +165,7 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
|
|
|
96
165
|
"shell",
|
|
97
166
|
"nproc",
|
|
98
167
|
"hook_sequence",
|
|
168
|
+
"hook_timeout_debug_context",
|
|
99
169
|
];
|
|
100
170
|
const ALLOWED_METADATA_KEYS = new Set(ALLOWED_SENTRY_REPORT_METADATA_KEYS);
|
|
101
171
|
const SENTRY_REPORT_METADATA_KEY_HEADROOM = 2;
|
|
@@ -107,6 +177,7 @@ const defaultDependencies = {
|
|
|
107
177
|
sentry: Sentry,
|
|
108
178
|
stdout: process.stdout,
|
|
109
179
|
stderr: process.stderr,
|
|
180
|
+
timeoutDebugContextEnabled: (signal) => hookTimeoutDebugContextEnabled({}, signal),
|
|
110
181
|
};
|
|
111
182
|
/**
|
|
112
183
|
* Read no more than the accepted event size. Returning early keeps a malformed
|
|
@@ -153,6 +224,90 @@ function isHookSequenceEntry(value) {
|
|
|
153
224
|
Number.isFinite(value.ms) &&
|
|
154
225
|
value.ms >= 0);
|
|
155
226
|
}
|
|
227
|
+
function boundedDebugName(value, known) {
|
|
228
|
+
if (typeof value !== "string" || value.length === 0)
|
|
229
|
+
return "other";
|
|
230
|
+
return known.has(basenameFromPath(value)) ? basenameFromPath(value) : "other";
|
|
231
|
+
}
|
|
232
|
+
function boundedDebugPhase(value) {
|
|
233
|
+
return typeof value === "string" && DEBUG_CONTEXT_PHASES.has(value) ? value : "other";
|
|
234
|
+
}
|
|
235
|
+
function boundedDebugHookEvent(value) {
|
|
236
|
+
return typeof value === "string" && DEBUG_CONTEXT_HOOK_EVENTS.has(value) ? value : "other";
|
|
237
|
+
}
|
|
238
|
+
function boundedDebugMilliseconds(value, allowNegative = false) {
|
|
239
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
240
|
+
return null;
|
|
241
|
+
const bounded = allowNegative
|
|
242
|
+
? Math.abs(value) <= MAX_HOOK_TIMEOUT_DEBUG_MS
|
|
243
|
+
: value >= 0 && value <= MAX_HOOK_TIMEOUT_DEBUG_MS;
|
|
244
|
+
return bounded ? value : null;
|
|
245
|
+
}
|
|
246
|
+
function boundedDebugProbe(value) {
|
|
247
|
+
if (value === "unavailable")
|
|
248
|
+
return value;
|
|
249
|
+
return boundedDebugMilliseconds(value) ?? undefined;
|
|
250
|
+
}
|
|
251
|
+
function boundedDebugPhaseTimings(value) {
|
|
252
|
+
if (!Array.isArray(value) || value.length > MAX_HOOK_TIMEOUT_DEBUG_PHASES)
|
|
253
|
+
return null;
|
|
254
|
+
const timings = [];
|
|
255
|
+
for (const entry of value) {
|
|
256
|
+
if (!isRecord(entry))
|
|
257
|
+
return null;
|
|
258
|
+
const elapsed = boundedDebugMilliseconds(entry.elapsed_ms);
|
|
259
|
+
if (elapsed === null)
|
|
260
|
+
return null;
|
|
261
|
+
const timing = {
|
|
262
|
+
phase: boundedDebugPhase(entry.phase),
|
|
263
|
+
elapsed_ms: elapsed,
|
|
264
|
+
};
|
|
265
|
+
timings.push(timing);
|
|
266
|
+
}
|
|
267
|
+
return timings;
|
|
268
|
+
}
|
|
269
|
+
/** Return only the closed, bounded shell-to-CLI context shape. */
|
|
270
|
+
function boundedHookTimeoutDebugContext(value) {
|
|
271
|
+
if (!isRecord(value))
|
|
272
|
+
return null;
|
|
273
|
+
const budget = boundedDebugMilliseconds(value.budget_ms);
|
|
274
|
+
const elapsed = boundedDebugMilliseconds(value.elapsed_ms);
|
|
275
|
+
const remaining = boundedDebugMilliseconds(value.remaining_ms, true);
|
|
276
|
+
const childElapsed = boundedDebugMilliseconds(value.waiting_child_elapsed_ms);
|
|
277
|
+
const phaseTimings = boundedDebugPhaseTimings(value.phase_timings);
|
|
278
|
+
if (budget === null ||
|
|
279
|
+
elapsed === null ||
|
|
280
|
+
remaining === null ||
|
|
281
|
+
childElapsed === null ||
|
|
282
|
+
phaseTimings === null) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
const loadAverage = boundedDebugProbe(value.load_average);
|
|
286
|
+
const spawnMs = boundedDebugProbe(value.spawn_ms);
|
|
287
|
+
const processCount = boundedDebugProbe(value.process_count);
|
|
288
|
+
const hasLoadAverage = typeof loadAverage === "number";
|
|
289
|
+
const hasWindowsProbe = spawnMs !== undefined && processCount !== undefined;
|
|
290
|
+
if (!hasLoadAverage && !hasWindowsProbe)
|
|
291
|
+
return null;
|
|
292
|
+
if (typeof processCount === "number" &&
|
|
293
|
+
(!Number.isInteger(processCount) || processCount > 1_000_000)) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
hook_name: boundedDebugName(value.hook_name, DEBUG_CONTEXT_HOOK_NAMES),
|
|
298
|
+
hook_event: boundedDebugHookEvent(value.hook_event),
|
|
299
|
+
budget_ms: budget,
|
|
300
|
+
elapsed_ms: elapsed,
|
|
301
|
+
remaining_ms: remaining,
|
|
302
|
+
phase_timings: phaseTimings,
|
|
303
|
+
wait_point: boundedDebugPhase(value.wait_point),
|
|
304
|
+
waiting_child_basename: boundedDebugName(value.waiting_child_basename, DEBUG_CONTEXT_CHILD_NAMES),
|
|
305
|
+
waiting_child_elapsed_ms: childElapsed,
|
|
306
|
+
...(loadAverage !== undefined ? { load_average: loadAverage } : {}),
|
|
307
|
+
...(spawnMs !== undefined ? { spawn_ms: spawnMs } : {}),
|
|
308
|
+
...(processCount !== undefined ? { process_count: processCount } : {}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
156
311
|
function boundedHookSequence(value) {
|
|
157
312
|
if (!Array.isArray(value))
|
|
158
313
|
return null;
|
|
@@ -168,6 +323,8 @@ function boundedHookSequence(value) {
|
|
|
168
323
|
function isAllowedMetadataValue(key, value) {
|
|
169
324
|
if (key === "hook_sequence")
|
|
170
325
|
return boundedHookSequence(value) !== null;
|
|
326
|
+
if (key === "hook_timeout_debug_context")
|
|
327
|
+
return boundedHookTimeoutDebugContext(value) !== null;
|
|
171
328
|
if (!isScalarMetadataValue(value))
|
|
172
329
|
return false;
|
|
173
330
|
if (key === "tool_name" || key === "session_id" || key === "hook_path") {
|
|
@@ -236,9 +393,10 @@ export function parseSentryReportEvent(stdin) {
|
|
|
236
393
|
}
|
|
237
394
|
if (!isAllowedMetadataValue(key, rawMetadata[key]))
|
|
238
395
|
return invalidInput();
|
|
239
|
-
metadata[key] =
|
|
240
|
-
key
|
|
241
|
-
|
|
396
|
+
metadata[key] = key === "hook_sequence"
|
|
397
|
+
? boundedHookSequence(rawMetadata[key])
|
|
398
|
+
: key === "hook_timeout_debug_context"
|
|
399
|
+
? boundedHookTimeoutDebugContext(rawMetadata[key])
|
|
242
400
|
: rawMetadata[key];
|
|
243
401
|
}
|
|
244
402
|
if (typeof metadata.slow_child === "string") {
|
|
@@ -319,6 +477,11 @@ function elapsedBucket(elapsedMs) {
|
|
|
319
477
|
return "20-60s";
|
|
320
478
|
return ">=60s";
|
|
321
479
|
}
|
|
480
|
+
function remainingBucket(remainingMs) {
|
|
481
|
+
return remainingMs < 0
|
|
482
|
+
? `overdue-${elapsedBucket(Math.abs(remainingMs))}`
|
|
483
|
+
: elapsedBucket(remainingMs);
|
|
484
|
+
}
|
|
322
485
|
function loadBucket(loadAverage) {
|
|
323
486
|
if (loadAverage === undefined)
|
|
324
487
|
return "unknown";
|
|
@@ -368,6 +531,53 @@ function spawnMsBucket(metadata) {
|
|
|
368
531
|
return "500-999ms";
|
|
369
532
|
return ">=1000ms";
|
|
370
533
|
}
|
|
534
|
+
function debugSpawnMsBucket(value) {
|
|
535
|
+
if (typeof value !== "number")
|
|
536
|
+
return "unavailable";
|
|
537
|
+
if (value < 100)
|
|
538
|
+
return "<100ms";
|
|
539
|
+
if (value < 500)
|
|
540
|
+
return "100-499ms";
|
|
541
|
+
if (value < 1_000)
|
|
542
|
+
return "500-999ms";
|
|
543
|
+
return ">=1000ms";
|
|
544
|
+
}
|
|
545
|
+
function processCountBucket(value) {
|
|
546
|
+
if (typeof value !== "number")
|
|
547
|
+
return "unavailable";
|
|
548
|
+
if (value < 5)
|
|
549
|
+
return "0-4";
|
|
550
|
+
if (value < 17)
|
|
551
|
+
return "5-16";
|
|
552
|
+
if (value < 65)
|
|
553
|
+
return "17-64";
|
|
554
|
+
return "65+";
|
|
555
|
+
}
|
|
556
|
+
function hookTimeoutDebugContextTags(context) {
|
|
557
|
+
const tags = {
|
|
558
|
+
timeout_debug_hook_name: context.hook_name,
|
|
559
|
+
timeout_debug_hook_event: context.hook_event,
|
|
560
|
+
timeout_debug_budget_bucket: elapsedBucket(context.budget_ms),
|
|
561
|
+
timeout_debug_elapsed_bucket: elapsedBucket(context.elapsed_ms),
|
|
562
|
+
timeout_debug_remaining_bucket: remainingBucket(context.remaining_ms),
|
|
563
|
+
timeout_debug_wait_point: context.wait_point,
|
|
564
|
+
timeout_debug_waiting_child: context.waiting_child_basename,
|
|
565
|
+
timeout_debug_waiting_child_elapsed_bucket: elapsedBucket(context.waiting_child_elapsed_ms),
|
|
566
|
+
timeout_debug_load_bucket: context.load_average === "unavailable"
|
|
567
|
+
? "unavailable"
|
|
568
|
+
: loadBucket(context.load_average),
|
|
569
|
+
timeout_debug_spawn_ms_bucket: debugSpawnMsBucket(context.spawn_ms),
|
|
570
|
+
timeout_debug_process_count_bucket: processCountBucket(context.process_count),
|
|
571
|
+
};
|
|
572
|
+
const phaseElapsed = new Map();
|
|
573
|
+
for (const timing of context.phase_timings) {
|
|
574
|
+
phaseElapsed.set(timing.phase, Math.max(phaseElapsed.get(timing.phase) ?? 0, timing.elapsed_ms));
|
|
575
|
+
}
|
|
576
|
+
for (const [phase, elapsedMs] of phaseElapsed) {
|
|
577
|
+
tags[`timeout_debug_phase_${phase}_bucket`] = elapsedBucket(elapsedMs);
|
|
578
|
+
}
|
|
579
|
+
return tags;
|
|
580
|
+
}
|
|
371
581
|
function safeSlowChild(metadata) {
|
|
372
582
|
const value = metadata.slow_child;
|
|
373
583
|
if (typeof value !== "string" || value.length === 0)
|
|
@@ -464,10 +674,10 @@ function sentryEnvelope(event, overrides, additionalTags = {}) {
|
|
|
464
674
|
};
|
|
465
675
|
}
|
|
466
676
|
/** Build the same scrubbed event projection that the live SDK path uses. */
|
|
467
|
-
function scrubbedSentryEnvelope(event, overrides) {
|
|
677
|
+
function scrubbedSentryEnvelope(event, overrides, additionalTags = {}) {
|
|
468
678
|
return beforeSend({
|
|
469
679
|
message: event.message,
|
|
470
|
-
...sentryEnvelope(event, overrides),
|
|
680
|
+
...sentryEnvelope(event, overrides, additionalTags),
|
|
471
681
|
}, {});
|
|
472
682
|
}
|
|
473
683
|
/**
|
|
@@ -740,19 +950,47 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
740
950
|
const noun = result.droppedMetadataKeys === 1 ? "key" : "keys";
|
|
741
951
|
dependencies.stderr.write(`Dropped ${result.droppedMetadataKeys} unsupported Sentry report metadata ${noun}.\n`);
|
|
742
952
|
}
|
|
743
|
-
|
|
953
|
+
let event = result.event;
|
|
954
|
+
let debugContextTags = {};
|
|
955
|
+
const debugContext = event.metadata.hook_timeout_debug_context;
|
|
956
|
+
if (debugContext && isHookTimeoutEvent(event)) {
|
|
957
|
+
let enabled;
|
|
958
|
+
try {
|
|
959
|
+
enabled =
|
|
960
|
+
(await dependencies.timeoutDebugContextEnabled?.(options.signal)) ?? false;
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
// An optional diagnostic flag read must never suppress the base report.
|
|
964
|
+
enabled = false;
|
|
965
|
+
}
|
|
966
|
+
if (enabled) {
|
|
967
|
+
debugContextTags = hookTimeoutDebugContextTags(debugContext);
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
const metadata = { ...event.metadata };
|
|
971
|
+
delete metadata.hook_timeout_debug_context;
|
|
972
|
+
event = { ...event, metadata };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
else if (debugContext) {
|
|
976
|
+
const metadata = { ...event.metadata };
|
|
977
|
+
delete metadata.hook_timeout_debug_context;
|
|
978
|
+
event = { ...event, metadata };
|
|
979
|
+
}
|
|
980
|
+
result.event = event;
|
|
981
|
+
const overrides = hookTimeoutExceededOverrides(event);
|
|
744
982
|
if (options.dryRun) {
|
|
745
|
-
const scrubbed = scrubbedSentryEnvelope(
|
|
983
|
+
const scrubbed = scrubbedSentryEnvelope(event, overrides, debugContextTags);
|
|
746
984
|
if (scrubbed)
|
|
747
985
|
dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
|
|
748
986
|
return result;
|
|
749
987
|
}
|
|
750
|
-
if (isHookTimeoutEvent(
|
|
751
|
-
addHookSequenceBreadcrumbs(
|
|
752
|
-
const kind = hookTimeoutKind(
|
|
988
|
+
if (isHookTimeoutEvent(event)) {
|
|
989
|
+
addHookSequenceBreadcrumbs(event, dependencies);
|
|
990
|
+
const kind = hookTimeoutKind(event);
|
|
753
991
|
let rateLimit;
|
|
754
992
|
try {
|
|
755
|
-
rateLimit = timeoutRateLimitLease(
|
|
993
|
+
rateLimit = timeoutRateLimitLease(event, kind, options, dependencies);
|
|
756
994
|
}
|
|
757
995
|
catch {
|
|
758
996
|
dependencies.stderr.write("Sentry timeout rate limit state unavailable; sending without rate limiting.\n");
|
|
@@ -764,14 +1002,14 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
764
1002
|
};
|
|
765
1003
|
}
|
|
766
1004
|
if (!rateLimit.capture) {
|
|
767
|
-
return reportSuppressedTimeout(result,
|
|
1005
|
+
return reportSuppressedTimeout(result, event, timeoutMs, dependencies, kind);
|
|
768
1006
|
}
|
|
769
1007
|
const tags = rateLimit.suppressedSinceLast > 0
|
|
770
1008
|
? { suppressed_since_last: String(rateLimit.suppressedSinceLast) }
|
|
771
1009
|
: {};
|
|
772
|
-
const envelope = sentryEnvelope(
|
|
1010
|
+
const envelope = sentryEnvelope(event, overrides, { ...debugContextTags, ...tags });
|
|
773
1011
|
try {
|
|
774
|
-
dependencies.sentry.captureMessage(
|
|
1012
|
+
dependencies.sentry.captureMessage(event.message, envelope);
|
|
775
1013
|
rateLimit.commitCapture();
|
|
776
1014
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
777
1015
|
return result;
|
|
@@ -783,9 +1021,9 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
783
1021
|
dependencies.stderr.write("Sentry report failed.\n");
|
|
784
1022
|
return invalidInput();
|
|
785
1023
|
}
|
|
786
|
-
const envelope = sentryEnvelope(
|
|
1024
|
+
const envelope = sentryEnvelope(event, overrides, debugContextTags);
|
|
787
1025
|
try {
|
|
788
|
-
dependencies.sentry.captureMessage(
|
|
1026
|
+
dependencies.sentry.captureMessage(event.message, envelope);
|
|
789
1027
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
790
1028
|
return result;
|
|
791
1029
|
}
|