Skip to content

feat(mcp): add denoise option to hyperdx_search tool#2371

Merged
kodiakhq[bot] merged 7 commits into
mainfrom
brandon/denoising
Jun 9, 2026
Merged

feat(mcp): add denoise option to hyperdx_search tool#2371
kodiakhq[bot] merged 7 commits into
mainfrom
brandon/denoising

Conversation

@brandon-pereira

@brandon-pereira brandon-pereira commented May 29, 2026

Copy link
Copy Markdown
Member

What

Adds a denoise boolean parameter to the MCP hyperdx_search tool that automatically filters out high-frequency repetitive event patterns from search results, mirroring the web app's "Denoise Results" checkbox.

Why

When investigating issues via MCP, LLM agents get back raw search results that are often dominated by repetitive log noise. This forces agents to either sift through noisy results or make a separate hyperdx_event_patterns call and then manually filter. The denoise option makes this a single-call workflow.

How it works

When denoise=true:

  1. Runs the normal search query to get result rows
  2. In parallel, samples 10k random events from the same source/time range and counts total events
  3. Mines patterns using the shared Drain algorithm (common-utils)
  4. Identifies "noisy" patterns — those accounting for >10% of sampled events (same threshold as the web app)
  5. Rebuilds a TemplateMiner, matches each search result row against learned patterns
  6. Filters out rows matching noisy patterns
  7. Returns filtered rows plus a denoised metadata block listing removed patterns, original row count, and filtered row count

Graceful degradation: if source/connection/body column can't be resolved, or if pattern sampling fails, the original results are returned unmodified.

Changes

File Change
packages/api/src/mcp/tools/query/denoise.ts New — Core denoiseSearchResults() function
packages/api/src/mcp/tools/query/search.ts Added denoise schema param + post-processing logic
packages/api/src/mcp/tools/query/helpers.ts Extracted shared resolveBodyExpression() + SAFE_BODY_EXPR_CHARS
packages/api/src/mcp/tools/query/runEventPatterns.ts Imports from helpers instead of local definitions

Example response (with denoise=true)

{
  "result": { "data": [...filtered rows...] },
  "denoised": {
    "removedPatterns": [
      { "pattern": "GET /health <*> <*>", "estimatedCount": 45000, "sampleCount": 4500 }
    ],
    "originalRowCount": 50,
    "filteredRowCount": 12
  }
}
Screenshot 2026-05-29 at 8 42 07 AM

Performance

Adds ~1-2s latency when denoise=true due to the pattern sampling queries. No impact when denoise=false (the default).

Closes HDX-4346

Add a `denoise` boolean parameter to the MCP `hyperdx_search` tool that
automatically filters out high-frequency repetitive event patterns from
search results, mirroring the web app's "Denoise Results" feature.

When `denoise=true`:
- Samples 10k random events from the same source/time range
- Mines patterns using the shared Drain algorithm (common-utils)
- Identifies "noisy" patterns (>10% of sampled events)
- Matches each search result row against learned patterns
- Filters out rows matching noisy patterns
- Returns filtered rows plus metadata listing removed patterns

Also extracts `resolveBodyExpression()` and `SAFE_BODY_EXPR_CHARS`
from runEventPatterns.ts into helpers.ts for reuse.
@changeset-bot

changeset-bot Bot commented May 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 27f0542

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/common-utils Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented May 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jun 9, 2026 7:45pm
hyperdx-storybook Ready Ready Preview, Comment Jun 9, 2026 7:45pm

Request Review

Resolve conflicts in helpers.ts and runEventPatterns.ts:
- helpers.ts: keep both our resolveBodyExpression/SAFE_BODY_EXPR_CHARS
  exports and main's mergeWhereIntoSelectItems/clickHouseErrorResult
- runEventPatterns.ts: import resolveBodyExpression, SAFE_BODY_EXPR_CHARS,
  and clickHouseErrorResult from helpers
- search.ts: update trimToolResponse usage to new { data, isTrimmed } API

Add changeset for the denoise feature (patch).
@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label May 29, 2026
@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 518 production lines changed (Tier 2 max: < 250)
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 7
  • Production lines changed: 518 (+ 104 in test files, excluded from tier calculation)
  • Branch: brandon/denoising
  • Author: brandon-pereira

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Deep Review

No critical issues found. No P0/P1: there is no data loss, auth bypass, or injection introduced here (the SQL-interpolated body column is operator-configured, not end-user input), and the graceful-degradation contract holds on every path — a denoise failure is caught and the original search result is always preserved. The findings below are recommended improvements and nits.

🟡 P2 — recommended

  • packages/api/src/mcp/tools/query/search.ts:202filteredRowCount is set to denoised.rows.length before the second trimToolResponse(denoisedResult) runs, so when the filtered payload still exceeds the size budget the trim drops rows from result.data and the metadata count overstates the rows actually returned (reachable at maxResults up to 200 with wide/multiline bodies).
    • Fix: Derive filteredRowCount from the trimmed array length after trimToolResponse, and apply the same correction on the denoise_failed branch.
    • correctness, adversarial, api-contract
  • packages/api/src/mcp/tools/query/denoise.ts:264 — the sampleConfig/countConfig builder blocks (~12 fields each) are duplicated nearly verbatim from runEventPatterns.ts, so any future ChartConfigWithDateRange field must be edited in two places to stay in sync.
    • Fix: Extract a shared buildPatternSampleConfigs(source, bodyColumn, tsExpr, options, limit) helper and call it from both denoise.ts and runEventPatterns.ts.
    • maintainability
  • packages/api/src/mcp/tools/query/denoise.ts:383minePatterns already trains a TemplateMiner over the sample, then denoiseSearchResults constructs and re-trains a second miner over the identical sampleRows, doubling synchronous Drain CPU (up to ~20k addLogMessage calls) on the event loop per denoised request.
    • Fix: Return the trained miner from minePatterns and reuse it for row matching instead of rebuilding it.
    • performance, maintainability
  • packages/api/src/mcp/tools/query/denoise.ts:279 — the sample query uses orderBy: rand() over the full time range, forcing a full scan-and-sort that can exhaust the 30s max_execution_time on large tables and silently skip denoising (skipped: 'sampling_failed').
    • Fix: Use ClickHouse's native SAMPLE clause (or a bounded pre-filter before randomizing) so sampling does not scan and sort the entire range.
    • performance, reliability
  • packages/api/src/mcp/tools/query/search.ts:33 — denoise filters only the rows in the maxResults-limited page (default 50), not the full event corpus, but neither the denoise schema description nor the response says so, so an LLM consumer can misread a near-empty filtered page as "few interesting events exist."
    • Fix: State in the denoise field description (and/or response) that filtering applies to the returned page, and emit a human-readable reason alongside the opaque skipped slug.
    • agent-native, api-contract
  • packages/api/src/mcp/__tests__/queryTool.test.ts:51 — the empty-results test comments that the denoised block should be absent but only asserts the presence of result, so it would pass even if the denoise path were broken.
    • Fix: Add expect(output).not.toHaveProperty('denoised') to make the assertion meaningful.
    • testing
  • packages/api/src/mcp/tools/query/denoise.ts:210 — new graceful-degradation behavior is largely untested: the denoise_failed catch path, the skip reasons (source_not_found, no_body_column, connection_not_found, body_column_not_in_results, sampling_failed), and the all-rows-removed hint branch have no coverage.
    • Fix: Add tests that force a thrown denoiseSearchResults, each skip reason, and the 100%-noisy-rows case, asserting rows are preserved and the expected metadata is emitted.
    • testing, reliability
🔵 P3 nitpicks (9)
  • packages/api/src/mcp/tools/query/search.ts:125parsed.result is already typed, but it is re-cast to Record<string, unknown> and then .data re-cast again, erasing the declared shape and violating the repo's documented "avoid as casts" guidance.
    • Fix: Replace the double cast with const rows = parsed.result?.data; and rely on the existing Array.isArray guard.
    • kieran-typescript, project-standards
  • packages/api/src/mcp/tools/query/search.ts:132let denoised; is declared without a type annotation, so use-before-assignment safety relies entirely on the catch block always returning.
    • Fix: Annotate as let denoised: DenoiseResult; importing the type from ./denoise.
  • packages/api/src/mcp/tools/query/denoise.ts:196 — the skipped reason values are untyped string literals duplicated across denoise.ts and search.ts.
    • Fix: Define a DenoiseSkipReason string-union/const and type the skipped field with it.
    • maintainability, api-contract
  • packages/api/src/mcp/tools/query/search.ts:97 — when denoise=true but the search returns zero rows, the handler early-returns the raw result with no denoised block, so a caller that requested denoise must still guard for its absence.
    • Fix: Emit a denoised block with skipped: 'no_rows' for consistency.
    • api-contract
  • packages/api/src/mcp/tools/query/denoise.ts:236denoiseSearchResults re-fetches the source and connection already resolved by the upstream runConfigTile, adding a redundant round-trip and a TOCTOU window between the search and sample queries.
    • Fix: Pass the already-resolved source/client into the function rather than re-fetching by id.
  • packages/api/src/mcp/tools/query/denoise.ts:349new Date(String(tsRaw)).getTime() returns NaN (not null) for unparseable timestamps, bypassing the ?? startDate fallback; harmless in the denoise path today since the trend is unused, but fragile.
    • Fix: Return Number.isFinite(ms) ? ms : null.
  • packages/api/src/mcp/tools/query/denoise.ts:268 — the source-resolved bodyColumn is interpolated into the SQL select without the SAFE_BODY_EXPR_CHARS check that the caller-supplied path in runEventPatterns.ts applies; the value is operator-configured (not exploitable by an end user) but the two mining paths now differ in posture.
    • Fix: Apply SAFE_BODY_EXPR_CHARS to the resolved expression for defense-in-depth parity, or document why it is unnecessary here.
    • security, adversarial
  • packages/api/src/mcp/tools/query/denoise.ts:391findBodyColumnKey matches result-row keys only by exact/case-insensitive equality, so for bracket/map body expressions or searches whose columns omit the body, denoise silently no-ops via body_column_not_in_results.
    • Fix: Alias the body column under a known key in the sample/result select and match on that alias.
    • correctness
  • packages/api/src/mcp/tools/query/denoise.ts:344 — body strings are fed to Drain via String(raw) with no length cap, so 10k multi-KB bodies can create significant transient heap pressure.
    • Fix: Truncate body text to a bounded length before mining/matching.

Reviewers (12): correctness, security, adversarial, testing, maintainability, performance, api-contract, reliability, kieran-typescript, project-standards, agent-native, learnings-researcher.

Testing gaps:

  • Graceful-degradation paths for the new feature (thrown denoiseSearchResultsdenoise_failed; each skipped reason) are uncovered.
  • The all-rows-removed hint branch and the metadata-vs-trimmed-data consistency under >50KB payloads are untested.
  • resolveBodyExpression is untested for SourceKind.Trace and the multi-expression split branch; findBodyColumnKey's case-insensitive fallback is untested.
  • No docs/solutions/ learnings exist for this area yet — worth capturing Drain/ClickHouse-sampling/MCP graceful-degradation patterns after this lands.

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 198 passed • 3 skipped • 1320s

Status Count
✅ Passed 198
❌ Failed 0
⚠️ Flaky 4
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

- Key noisy patterns by template string (p.pattern / match.getTemplate())
  instead of per-instance cluster ID, eliminating fragile coupling to
  minePatterns() internal auto-increment ordering

- Always emit a 'denoised' metadata block when denoise=true, including a
  'skipped' field with the reason when denoising cannot proceed
  (source_not_found, no_body_column, body_column_not_in_results,
  connection_not_found, sampling_failed, no_sample_data, no_rows)

- Rename originalRowCount to returnedRowCountBeforeDenoise to make the
  post-trim semantics explicit

- Fix misleading maxSamples:0 comment (minePatterns always keeps at
  least one sample per cluster); use maxSamples:1 instead

- Add integration tests for denoise=true: schema exposure, empty results
  handling, noisy pattern filtering with seeded data, denoised metadata
  shape assertions, and denoise=false control case
Comment thread packages/api/src/mcp/tools/query/denoise.ts Outdated
teeohhem
teeohhem previously approved these changes Jun 2, 2026
- Resolve conflict in search.ts: keep clickstack_ prefix + denoise description
- Rename hyperdx_ to clickstack_ in denoise tests
- Extract DENOISE_SAMPLE_SIZE and DENOISE_NOISE_THRESHOLD to common-utils/drain
  (shared between FE DBRowTable and BE MCP denoise)
Wrap the denoiseSearchResults call in a try/catch so that a failure in
the denoise post-processing step (getSource, getConnectionById, pattern
mining, etc.) does not discard an already-successful search result.

On catch, return the original rows with a denoised metadata block
containing skipped: 'denoise_failed', following the same graceful-
degradation convention used by other failure paths in denoise.ts.
# Conflicts:
#	packages/api/src/mcp/tools/query/helpers.ts
@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a denoise boolean parameter to the clickstack_search MCP tool that post-processes search results by mining high-frequency log patterns from a random 10 k-row sample and filtering matching rows out. It also extracts DENOISE_SAMPLE_SIZE, DENOISE_NOISE_THRESHOLD, and flattenBody into @hyperdx/common-utils/drain so the web app and MCP server share the same values.

  • denoise.ts — new denoiseSearchResults() function that fires two parallel ClickHouse queries (random sample + total count), runs Drain via minePatterns(), and filters result rows matching patterns above the 10 % noise threshold.
  • helpers.ts — shared resolveBodyExpression() and SAFE_BODY_EXPR_CHARS extracted here so both search.ts and runEventPatterns.ts use a single implementation.
  • search.ts — adds denoise Zod schema field and post-processing logic with graceful degradation; always returns original rows on denoise failure.

Confidence Score: 4/5

The change is safe to merge — denoising is opt-in, all failure paths fall back to returning the original search results, and the integration tests cover the key scenarios.

The core logic is sound and graceful degradation is thorough. The two issues in denoise.ts (missing HTTP timeout on the ClickhouseClient and double Drain training on the same 10k rows) are real inefficiencies but neither causes incorrect results or data loss. The comment/behaviour inconsistency around the empty-rows early return is cosmetic. No correctness bugs were found.

packages/api/src/mcp/tools/query/denoise.ts — missing requestTimeout and double Drain training

Important Files Changed

Filename Overview
packages/api/src/mcp/tools/query/denoise.ts New core denoising function — trains Drain twice on the same 10k sample and omits requestTimeout on ClickhouseClient
packages/api/src/mcp/tools/query/search.ts Adds denoise schema param and post-processing logic; has a code comment claiming "always emit denoised block" that contradicts multiple early-return paths
packages/api/src/mcp/tools/query/helpers.ts Extracts resolveBodyExpression and SAFE_BODY_EXPR_CHARS into shared helpers; clean refactor
packages/api/src/mcp/tools/query/runEventPatterns.ts Migrated to import resolveBodyExpression and SAFE_BODY_EXPR_CHARS from helpers instead of local definitions; no logic changes
packages/common-utils/src/drain/mine-patterns.ts Extracts DENOISE_SAMPLE_SIZE, DENOISE_NOISE_THRESHOLD, and flattenBody into shared location so web app and MCP use the same constants
packages/api/src/mcp/tests/queryTool.test.ts Good integration tests covering schema exposure, empty-result behaviour, noisy-pattern filtering, and denoise=false passthrough

Sequence Diagram

sequenceDiagram
    participant Agent as LLM Agent
    participant Search as clickstack_search
    participant CH1 as ClickHouse (search)
    participant Denoise as denoiseSearchResults
    participant CH2 as ClickHouse (denoise)
    participant Drain as Drain/minePatterns

    Agent->>Search: "{sourceId, where, denoise:true, maxResults}"
    Search->>CH1: "queryChartConfig (search, limit=maxResults)"
    CH1-->>Search: rows[0..N]
    alt "denoise=false OR rows empty OR error"
        Search-->>Agent: "{result: {...}}"
    else "denoise=true AND rows non-empty"
        Search->>Denoise: rows, sourceId, dateRange, where
        par parallel sampling
            Denoise->>CH2: SAMPLE rand() LIMIT 10k
            CH2-->>Denoise: sampleRows
        and count query
            Denoise->>CH2: count() total
            CH2-->>Denoise: totalCount
        end
        Denoise->>Drain: minePatterns(sampleRows) → patterns
        Denoise->>Drain: new TemplateMiner().train(sampleRows)
        Denoise->>Denoise: "identify noisyTemplates (>10% threshold)"
        Denoise->>Denoise: filter rows by miner.match()
        Denoise-->>Search: "{rows: filteredRows, removedPatterns}"
        Search-->>Agent: "{result: {data: filteredRows}, denoised: {removedPatterns, ...}}"
    end
Loading

Fix All in Claude Code Fix All in Conductor Fix All in Cursor Fix All in Codex

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +84 to +88
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing requestTimeout on the ClickhouseClient created for denoise queries. helpers.ts establishes a MCP_REQUEST_TIMEOUT (32 s) specifically as a belt-and-suspenders above the server-side max_execution_time: 30 — without it the HTTP connection can hang indefinitely if ClickHouse is slow to acknowledge the server-side cancellation.

Suggested change
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
requestTimeout: 32_000, // 30s query limit + 2s buffer (mirrors MCP_REQUEST_TIMEOUT)
});

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment on lines +178 to +227
const { patterns } = minePatterns(sampleRows, {
totalCount,
startDate,
endDate,
maxSamples: 1,
getBody: row => {
const raw = row.__hdx_pattern_body;
return raw != null ? String(raw) : '';
},
getTimestamp: row => {
const tsRaw = row.__hdx_pattern_ts;
return tsRaw != null ? new Date(String(tsRaw)).getTime() : null;
},
});

if (patterns.length === 0) {
return { rows, removedPatterns: [] };
}

// ── Identify noisy patterns (>10% of sampled events) ──
// Key by template string rather than cluster ID so we are not coupled to
// the auto-incrementing IDs generated inside minePatterns(). The matching
// miner below produces its own IDs; comparing template strings is stable.
const sampledRowCount = sampleRows.length;
const noisyTemplates = new Set<string>();
const removedPatterns: DenoiseResult['removedPatterns'] = [];

for (const p of patterns) {
if (p.sampleCount / sampledRowCount > DENOISE_NOISE_THRESHOLD) {
noisyTemplates.add(p.pattern);
removedPatterns.push({
pattern: p.pattern,
estimatedCount: p.estimatedCount,
sampleCount: p.sampleCount,
});
}
}

if (noisyTemplates.size === 0) {
return { rows, removedPatterns: [] };
}

// ── Build a miner trained on the same sample for row matching ──
const drainConfig = new TemplateMinerConfig();
const miner = new TemplateMiner(drainConfig);
for (const row of sampleRows) {
const raw = row.__hdx_pattern_body;
const bodyText = flattenBody(raw != null ? String(raw) : '');
miner.addLogMessage(bodyText);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double Drain training on same 10 k rows

minePatterns() trains a TemplateMiner internally (and discards it), then immediately the function builds a second TemplateMiner from scratch by re-iterating the same sampleRows. For the default 10 k sample this doubles memory allocation and Drain processing time. The workaround is necessary because minePatterns doesn't expose the trained miner. Consider either (a) adding a getMiner() return value to MinePatternResult, or (b) extracting a lower-level helper that accepts an already-trained miner so the second pass is just a match loop without re-training.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment on lines +128 to +130
if (!rows || !Array.isArray(rows) || rows.length === 0) {
return result;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The comment directly above says "Always emit a denoised block when denoise=true" but this early return sends back the plain result object — no denoised key — when the search succeeds but returns zero rows. An LLM agent that always expects the denoised key when it requested denoise=true will see an inconsistent response shape. Either update the comment to document the exception, or emit an empty denoised block here.

Suggested change
if (!rows || !Array.isArray(rows) || rows.length === 0) {
return result;
}
if (!rows || !Array.isArray(rows) || rows.length === 0) {
// No rows to denoise — return result as-is (no denoised block).
// NOTE: this is intentional; the "always emit" comment below applies
// only to the non-empty-rows path.
return result;
}

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

@kodiakhq kodiakhq Bot merged commit 750b8af into main Jun 9, 2026
24 checks passed
@kodiakhq kodiakhq Bot deleted the brandon/denoising branch June 9, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants