Skip to content

fix: make "Denoise results" remove noise reliably - #3621

Open
teeohhem wants to merge 3 commits into
mainfrom
claude/fix-denoise-results
Open

teeohhem wants to merge 3 commits into
mainfrom
claude/fix-denoise-results

Conversation

@teeohhem

@teeohhem teeohhem commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

"Denoise results" on the search page often left noisy rows in the table. Three bugs caused it:

  • It mined the last table column, not the body. patternColumn = columns[columns.length - 1]. Adding a column from the side panel appends it, so denoise switched to that attribute. An added map column carries an alias (LogAttributes['k'] AS "k"), and the alias was then used as a SQL expression in the sample query. Denoise now uses the source's body expression (getEventBody), finds its result column by position in the select list, and says so in the panel when the body isn't in the table.
  • ANSI-colored rows never matched. Sampling strips ANSI codes; matching the visible rows didn't. In a local replay (drain3 wheel the app loads, 10k sampled rows, 200 visible rows), a colored template at ~27% of traffic had 0 of 54 rows removed. With this PR: 54 of 54.
  • Rows were re-matched with drain3's match(). That needs an exact template fit and can put a row in a different cluster from the one it was counted in. The MCP denoise already replaced this with buildNoisyBodyMatcher. That matcher moves to common-utils/src/drain/noisy-matcher.ts, gains an extraDelimiters option so it tokenizes like the web miner, and is now used by both.

The denoise logic moves out of DBRowTable.tsx into hooks/useDenoisedRows.ts and components/DenoiseSummary.tsx. If the pattern query fails, the table now shows all rows instead of an empty table.

Noise selection. A pattern used to count as noisy only if its cluster held more than 10% of the 10k-row sample. Drain splits one logical pattern across clusters by token count and first token, so on the demo logs no cluster passed 10% (the top one was 9.2%) and denoise removed nothing. selectNoisyPatterns (common-utils) now takes the most frequent patterns until they cover half the sample, ignoring any under 1%. On the demo data's 6-hour window, that removes 9 patterns covering 53% of the sample. The MCP denoise option uses the same rule, and its tool description is updated. Its integration test still passes: the seeded health-check pattern is 94% of the sample and is the only pattern removed.

Errors and warnings are never hidden. Frequency isn't importance: during an incident a flood of errors becomes the most common pattern. The app skips any pattern whose sampled lines include an error or warning (getLogLevelClass). The MCP denoise doesn't apply this yet, because its sample query doesn't select the level.

Readable panel. The raw template list is replaced by a one-line summary ("Hiding 8 common patterns, about 53% of events. Errors and warnings are never hidden.") with a "Show patterns" toggle. Each pattern is one truncated line with its share of events, and runs of wildcards collapse to … (simplifyPattern). Hover shows the full template.

Screenshots or video

Screenshots need to be attached by the human author. The "after" view on the preview shows the summary line, "Show patterns" expanded to 8 one-line patterns with percentages, and error rows ("Failed to place order", "Visa cache full") still in the table.

Before After

How to reproduce

Starting point: yarn app:dev:local connected to the demo server (onboarding → "Connect to Demo Server").

On main:

  1. Open Search with the Logs source and turn on "Denoise results" in the filters panel.
  2. Expand any row and add a log attribute as a column from the side panel.
  3. Before step 2, the panel already reads "No noisy patterns found" and no rows are removed. After step 2, denoise mines that attribute instead of the body, or the table stays empty if the sample query fails.

On this branch:

  1. Turn on "Denoise results". The panel reads "Hiding 8 common patterns, about 53% of events…". Click "Show patterns" to see them (e.g. "Browsing product"); those rows are gone from the table, and error rows remain.
  2. Repeat step 2 above. The removed patterns are still body patterns.
  3. Remove the Body column from the select. The panel reads "Add Body to the table columns to denoise results." and all rows are shown.

How to test on Vercel preview

Preview routes: /search

Steps:

  1. Select the Logs source.
  2. Turn on "Denoise results" in the filters panel.
  3. Verify the text starting "Hiding" and ending "Errors and warnings are never hidden." appears above the results table.
  4. Click "Show patterns" and verify a list of one-line patterns with percentages appears.
  5. Change the select to Timestamp, ServiceName and run the search.
  6. Verify the text "Add Body to the table columns to denoise results." appears and results are shown.

References

🤖 Generated with Claude Code

- Mine the source's body column instead of the last table column. Adding
  an attribute column from the side panel switched denoise to that column,
  or broke the sample query when the column carried an alias.
- Strip ANSI codes before matching, as sampling already did, so colored
  rows of a noisy pattern are removed.
- Replace drain3's match() with the template-fit matcher the MCP denoise
  uses, moved to common-utils and shared by both.
- Say in the panel when results can't be denoised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teeohhem
teeohhem requested a review from a team as a code owner November 11, 2026 01:58
@vercel

vercel Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Nov 11, 2026 4:18am UTC
hyperdx-storybook Ready Ready Preview Nov 11, 2026 4:18am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1cad23c

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

This PR includes changesets to release 4 packages
Name Type
@hyperdx/app Patch
@hyperdx/common-utils Patch
@hyperdx/api 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

@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label Nov 11, 2026
@github-actions

github-actions Bot commented Nov 11, 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: 614 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: 10
  • Production lines changed: 614 (+ 321 in test files, excluded from tier calculation)
  • Branch: claude/fix-denoise-results
  • Author: teeohhem

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

Copy link
Copy Markdown
Contributor

Duplicate code analysis

No new duplicate code introduced by this PR.


jscpd compared this branch against origin/main
across app, api and common-utils. Only new clones are listed; the codebase
currently holds 110 in total (1.20%).
Run yarn dupes locally to reproduce, or yarn dupes:all for the full picture.

// Mirrors Drain's tokenizer: delimiters become spaces, then split(/\s+/).
// Templates are already space-joined tokens, and splitting one on a
// delimiter would break up `<*>`.
function tokenize(text: string, extraDelimiters: string[]): string[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — tokenize copies Drain.getContentAsTokens (prior art in the same package)

Drain.getContentAsTokens in packages/common-utils/src/drain/drain.ts:104 already does this: trim, replace each extra delimiter with a space, then split(/\s+/). Pull that logic into one exported helper and have both Drain and buildNoisyBodyMatcher call it. That follows the REQUIRED DRY rule and keeps the matcher's tokens in step with the miner's tokens, which is the point of the extraDelimiters option.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@@ -335,6 +335,7 @@ export function useGroupedPatterns({
isLoading,
error,
miner: results?.miner,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — miner is returned but nothing reads it any more

The only consumer was matchLogs in DBRowTable, which this PR removes. Grep finds no other use of .miner in packages/app/src. Remove miner from the usePatterns result (line 206) and from the useGroupedPatterns return. Then the react-query cache stops holding a Pyodide miner object for every query.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

import { useGroupedPatterns } from '@/hooks/usePatterns';

jest.mock('@/hooks/usePatterns', () => ({
DRAIN_EXTRA_DELIMITERS: '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'.split(''),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Test mock pastes its own copy of DRAIN_EXTRA_DELIMITERS

The mock re-declares the delimiter list instead of using the real export. If the miner's delimiters change in usePatterns.tsx, this test still passes, even though matching would then disagree with the templates. Use jest.mock('@/hooks/usePatterns', () => ({ ...jest.requireActual('@/hooks/usePatterns'), useGroupedPatterns: jest.fn() })) instead, or move the constant to a module that doesn't need mocking.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

) {
switch (skipped) {
case 'body_not_in_results':
return `Add ${bodyExpression} to the table columns to denoise results.`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Column name in the skip message is plain text, not Mantine <Code>

The code-snippet convention (REQUIRED) says short tokens in prose, such as a column name, use Mantine <Code>. Have skipMessage return a ReactNode, e.g. <>Add <Code>{bodyExpression}</Code> to the table columns to denoise results.</>.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

3 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 2 minor

1 posted as inline comment(s) on the changed lines. 2 listed below.

Findings outside the changed lines

  • 🟠 packages/app/src/hooks/useDenoisedRows.ts:1007 — Errors and warnings are only protected in the web app, so the MCP denoise option can now hide an error flood → Move the error/warning exclusion into common-utils, for example as a isProtected predicate on selectNoisyPatterns or a shared wrapper around it. Then select the level column in the MCP sample query (packages/api/src/mcp/tools/query/denoise.ts:106) so the server applies it too. Today MCP calls selectNoisyPatterns with the new 50%-coverage rule and no level filter. During an incident, a dominant connection refused to <*> error pattern is the first one selected and gets removed from the agent's results. The changeset says these patterns are "never hidden" and that the "same coverage rule applies to the MCP denoise option", which is not true for MCP.
1 minor
  • 🔵 packages/common-utils/src/drain/noisy-matcher.ts:1419 — Error rows can still be hidden even though the panel says 'Errors and warnings are never hidden' → Patterns with an error or warning sample are passed to buildNoisyBodyMatcher as ordinary non-noisy candidates, and a tie in wildcard count goes to the noisy template. So an error row that fits a protected template and a noisy template equally well is removed. Rows outside the 10k sample are also filtered by template only, never by their own level. Fix: let protected templates win ties (add a protectedTemplates set to the matcher), and keep any visible row whose level is error or warn. If neither is done, soften the copy in DenoiseSummary.tsx.

Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Scope: PR #3621 — "Denoise results" reliability fix across packages/app, packages/api, and packages/common-utils, reviewed against base ba7d0066f. The core logic is sound and well-covered: cluster_id keys line up between clusterTemplates and the pattern groups, sampleMultiplier is 1 in the denoise path so pattern counts and sampledRowCount share units, ANSI stripping is exercised end-to-end through the real matcher in the hook test, and selectNoisyPatterns coverage/crossing logic matches its tests.

✅ No critical (P0/P1) issues found.

🟡 P2 — recommended

  • packages/api/src/mcp/tools/query/denoise.ts:204 — the MCP denoise path selects noisy patterns over all patterns, with no error/warning exemption, while the web path filters error/warning patterns out before selection; the shared changeset states error/warning lines are never hidden without scoping that to the web app.
    • Fix: exclude patterns whose samples contain an error/warning level before calling selectNoisyPatterns in the MCP path, or scope the "never hidden" claim to the web app in the changeset and tool description.
🔵 P3 nitpicks (3)
  • packages/app/src/hooks/useDenoisedRows.ts:44 — findBodyResultColumn only position-matches when config.select is a string; SelectList can also be a DerivedColumn[], in which case it falls through to an exact-name match and returns undefined for an aliased body, surfacing body_not_in_results even though the body is selected.
    • Fix: handle the array SelectList shape by matching the body expression against each derived column's value expression and mapping to its position in columns.
  • packages/app/src/hooks/useDenoisedRows.ts:130 — denoisedRows returns [] while clusterTemplates/isNoisy is still resolving; this is currently masked only because isLoading is true in the same state, so any consumer that ignores isLoading would render an empty table instead of a spinner.
    • Fix: return rows (or a sentinel) rather than [] during the loading state so an empty result never escapes without the loading flag.
  • packages/app/src/hooks/useDenoisedRows.ts:96 — the new patterns_failed and no_body_expression skip branches are added behavior but have no unit coverage (only body_not_in_results is tested).
    • Fix: add useDenoisedRows cases that assert all rows are returned and the matching skipped reason is set for the pattern-query-error and no-body-expression paths.

Reviewers (6): correctness, adversarial, testing, maintainability, kieran-typescript, performance.

Testing gaps:

  • MCP denoise's lack of an error/warning exemption is untested, so a regression where a frequent error pattern is removed from MCP results would go uncaught.
  • The useDenoisedRows skip branches patterns_failed and no_body_expression are uncovered.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 466 passed • 1 skipped • 1204s

Status Count
✅ Passed 466
❌ Failed 0
⚠️ Flaky 0
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

Drain splits one logical pattern across clusters by token count and
first token, so on the demo logs no cluster passed 10% and denoise
removed nothing. Take the most frequent patterns until they cover half
the sample, ignoring patterns under 1%. Applies to the web app and the
MCP denoise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teeohhem teeohhem changed the title fix(app): make "Denoise results" match noisy rows reliably fix: make "Denoise results" remove noise reliably Nov 11, 2026

// Python's string.punctuation. Shared with the denoise matcher, which must
// split bodies into the same tokens the miner built its templates from.
export const DRAIN_EXTRA_DELIMITERS =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — DRAIN_EXTRA_DELIMITERS lives in the app hook, so the test mock has to copy it

The constant belongs next to buildNoisyBodyMatcher in common-utils/src/drain, since the matcher depends on it. Right now useDenoisedRows.test.tsx:12 retypes the whole punctuation string inside jest.mock, so the test checks a copy that can drift from the real value. Move it to common-utils and import it from there in usePatterns, useDenoisedRows and the test.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

- Never hide a pattern whose sampled lines include an error or warning,
  so a flood of errors during an incident stays visible.
- Replace the raw template list with a one-line summary ("Hiding N
  common patterns, about X% of events") and a collapsed list. Each
  pattern is shortened to one line, with runs of wildcards collapsed to
  an ellipsis and its share of events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -335,6 +335,7 @@ export function useGroupedPatterns({
isLoading,
error,
miner: results?.miner,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — miner is still kept and returned, but nothing uses it now that matchLogs is gone

No caller reads useGroupedPatterns().miner any more (grep finds only the test mock). Drop miner from mineEventPatterns, the query result and the hook's return value. Also del the m_<id> Python global after getTemplates(), so each search stops leaving a 10k-log TemplateMiner behind in the pyodide globals.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 1cad23c2 Deployed Nov 11, 2026 by vercel[bot]
Preview – hyperdx-storybook — 1cad23c2 Deployed Nov 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant