Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/trace-waterfall-time-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@hyperdx/api": patch
---

fix(mcp): time-bound the trace waterfall span/log fetches so they prune
partitions instead of scanning the full retention window. The
`clickstack_trace_waterfall` tool now threads the search window into its span
and correlated-log queries, adds a `max_execution_time` ceiling, and probes a
trace's `[min, max]` span extent so an explicit `traceId` older than the
default window — or a trace that ran longer than an hour — still resolves in
full. The probe also runs for auto-picked traces, so a picked trace whose root
predates the window is no longer truncated into a partial tree. The fetch window
is width-clamped to the recent tail so a reused or sentinel `traceId` (e.g. an
all-zero id from an uninstrumented emitter) can't widen the scan back toward the
retention edge or stitch unrelated occurrences into one tree. Empty-result hints
now name the recoverable action (pass an explicit `startTime`) instead of the
misleading "widen the window", and a probe that fails is surfaced rather than
silently swallowed. ClickHouse query timeouts are also reclassified from `user`
to `server` errors.
28 changes: 28 additions & 0 deletions packages/api/src/mcp/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@ describe('errorHint', () => {
expect(hint).toContain('too many rows');
});

it('should match TIMEOUT_EXCEEDED errors', () => {
const hint = errorHint(
'Code: 159. DB::Exception: Timeout exceeded: elapsed 30s (TIMEOUT_EXCEEDED)',
);
expect(hint).not.toBeNull();
expect(hint).toContain('execution-time limit');
expect(hint).toContain('Narrow the time range');
});

it('should match SETTING_CONSTRAINT_VIOLATION errors', () => {
const hint = errorHint(
"Setting max_result_rows shouldn't be greater than 1000. (SETTING_CONSTRAINT_VIOLATION)",
Expand All @@ -309,6 +318,14 @@ describe('errorHint', () => {
expect(hint).toContain('constraint');
});

it('routes a max_execution_time constraint error to the constraint hint, not the timeout hint', () => {
const hint = errorHint(
"Setting max_execution_time shouldn't be greater than 10. (SETTING_CONSTRAINT_VIOLATION)",
);
expect(hint).toContain('constraint');
expect(hint).not.toContain('execution-time limit');
});

it('should return null for unrecognized errors', () => {
const hint = errorHint('Connection refused');
expect(hint).toBeNull();
Expand Down Expand Up @@ -416,6 +433,17 @@ describe('isServerError', () => {
expect(isServerError(err)).toBe(false);
});

it('returns true for TIMEOUT_EXCEEDED (max_execution_time overrun)', () => {
// Query timeouts are a resource failure, not a user mistake.
const cause = new ClickHouseError({
message: 'Timeout exceeded: elapsed 30s',
code: '159',
type: 'TIMEOUT_EXCEEDED',
});
const err = new Error('query failed', { cause });
expect(isServerError(err)).toBe(true);
});

it('returns true for ECONNREFUSED (TCP connection failure)', () => {
const err = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), {
code: 'ECONNREFUSED',
Expand Down
Loading
Loading