diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.tsx.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.tsx.snap index 00fe79eb53d..9facadeeacc 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.tsx.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-id.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`hashRulerRule should hash alerting rule 1`] = `"7317348"`; +exports[`hashRulerRule should hash alerting rule 1`] = `"852037155"`; -exports[`hashRulerRule should hash recording rules 1`] = `"-447747460"`; +exports[`hashRulerRule should hash recording rules 1`] = `"914562864"`; diff --git a/public/app/features/alerting/unified/utils/rule-id.test.tsx b/public/app/features/alerting/unified/utils/rule-id.test.tsx index e2652c11885..7fedf534b3b 100644 --- a/public/app/features/alerting/unified/utils/rule-id.test.tsx +++ b/public/app/features/alerting/unified/utils/rule-id.test.tsx @@ -13,7 +13,15 @@ import { RulerRecordingRuleDTO, } from 'app/types/unified-alerting-dto'; -import { equal, getRuleIdFromPathname, hashRule, hashRulerRule, parse, stringifyIdentifier } from './rule-id'; +import { + equal, + getRuleIdFromPathname, + hashQuery, + hashRule, + hashRulerRule, + parse, + stringifyIdentifier, +} from './rule-id'; const alertingRule = { prom: { @@ -258,3 +266,111 @@ describe('useRuleIdFromPathname', () => { expect(result.current).toBe('abc%25def'); }); }); + +describe('hashQuery', () => { + it('should produce the same hash for queries with different whitespace formatting', () => { + const query1 = `sum by (client,origin,destination,met_val)( + sum_over_time( + {client=~"PRU|RVSI"} + ) +)`; + const query2 = `sum by (client,origin,destination,met_val)(sum_over_time({client=~"PRU|RVSI"}))`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should produce the same hash for queries with and without outer parentheses', () => { + const query1 = `sum by (client)(rate(requests_total[5m]))`; + const query2 = `(sum by (client)(rate(requests_total[5m])))`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should produce the same hash for queries with different quote types in label formats', () => { + const query1 = `label_format origin=\`{{.app_host}}\``; + const query2 = `label_format origin="{{.app_host}}"`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should produce the same hash for queries with escaped vs unescaped quotes', () => { + const query1 = `label_format met_val=\`{{"REQ_SENT"}}\``; + const query2 = `label_format met_val="{{\"REQ_SENT\"}}"`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should handle complex Loki recording rules with all formatting differences', () => { + const query1 = `sum by (client,origin,destination,metric_type)( + sum_over_time( + {client=~"FOO|BAR|BAZ", service_name="app_sessions"} + |= "server" + |= "component" + | logfmt + | label_format origin=\`{{.host_name}}\` + | label_format destination=\`{{.component_name}}\` + | label_format metric_type=\`{{"REQUEST_COUNT"}}\` + | keep client,destination,origin,metric_type,response_time + | unwrap response_time + [5m]) +) > 0`; + + const query2 = `(sum by (client,origin,destination,metric_type)(sum_over_time({client=~"FOO|BAR|BAZ", service_name="app_sessions"} |= "server" |= "component" | logfmt | label_format origin="{{.host_name}}" | label_format destination="{{.component_name}}" | label_format metric_type="{{\"REQUEST_COUNT\"}}" | keep client,destination,origin,metric_type,response_time | unwrap response_time[5m])) > 0)`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should produce the same hash for queries with reordered label matchers', () => { + const query1 = `{job="prometheus", instance="localhost:9090"}`; + const query2 = `{instance="localhost:9090", job="prometheus"}`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should handle multiple types of brackets and quotes', () => { + const query1 = `rate(http_requests_total{method="GET"}[5m])`; + const query2 = `rate(http_requests_total{method=\`GET\`}[5m])`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should normalize backslashes properly', () => { + const query1 = `label_format path="{{.file_path}}"`; + const query2 = `label_format path="{{\.file_path}}"`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should handle empty queries', () => { + expect(hashQuery('')).toBe(''); + }); + + it('should handle queries with only parentheses', () => { + expect(hashQuery('()')).toBe(''); + }); + + it('should handle complex nested parentheses and brackets', () => { + const query1 = `((sum(rate(requests[5m]))))`; + const query2 = `sum(rate(requests[5m]))`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should handle mixed quote escaping scenarios', () => { + const query1 = `label_format msg=\`{{"error: \\"timeout\\""}}\``; + const query2 = `label_format msg="{{\"error: \\\"timeout\\\"\"}}"`; + + expect(hashQuery(query1)).toBe(hashQuery(query2)); + }); + + it('should produce consistent results for character sorting', () => { + const query1 = `abc{x="1",y="2"}`; + const query2 = `abc{y="2",x="1"}`; + + const hash1 = hashQuery(query1); + const hash2 = hashQuery(query2); + + expect(hash1).toBe(hash2); + expect(hash1).toBe(hash1.split('').sort().join('')); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index b2eb9a663c0..f8db7346df3 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -311,9 +311,21 @@ export function hashQuery(query: string) { if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') { query = query.slice(1, -1); } + // whitespace could be added or removed query = query.replace(/\s|\n/g, ''); - // labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts + + // normalize escaped quotes in template strings like {{\"REQ_SENT\"}} -> {{"REQ_SENT"}} + query = query.replace(/\\"/g, '"'); + + // normalize backtick template strings to double quotes for consistency + // Convert `{{.field}}` to "{{.field}}" + query = query.replace(/`([^`]*)`/g, '"$1"'); + + // remove quotes, brackets, parentheses, backslashes, and backticks + query = query.replace(/['"()\[\]\\`]/g, ''); + + // labels matchers can be reordered, so sort the entire string, essentially comparing just the character counts return query.split('').sort().join(''); }