From e7d9860ced54d3b3aac6707d150279ef95033a28 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:28:57 -0700 Subject: [PATCH 1/3] fix: only split a query at a standalone SETTINGS keyword extractSettingsClauseFromEnd took the first case-insensitive "settings" anywhere in the SQL, so a string literal ('app.settings.reloads') or an identifier (AppSettings) split the query. Multi-series metric charts then emitted an unterminated string, and fastifySQL rewrote a SQL filter to reference a truncated column. Match the keyword only outside quotes and on word boundaries. --- .changeset/settings-keyword-in-identifiers.md | 13 +++++++++ .../src/__tests__/renderChartConfig.test.ts | 18 ++++++++++++ .../common-utils/src/__tests__/utils.test.ts | 13 +++++++++ packages/common-utils/src/core/utils.ts | 28 ++++++++++++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .changeset/settings-keyword-in-identifiers.md diff --git a/.changeset/settings-keyword-in-identifiers.md b/.changeset/settings-keyword-in-identifiers.md new file mode 100644 index 0000000000..a7ff5fb66a --- /dev/null +++ b/.changeset/settings-keyword-in-identifiers.md @@ -0,0 +1,13 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/api': patch +'@hyperdx/app': patch +--- + +fix: only split a query at a standalone SETTINGS keyword + +`extractSettingsClauseFromEnd` cut the query at the first "settings" anywhere in +it, including inside a string or an identifier. A multi-series metric chart with +a metric such as `app.settings.reloads` produced SQL with an unterminated +string, and a SQL filter on a column such as `AppSettings` was rewritten to +reference `App`. The keyword now has to stand alone outside quotes. diff --git a/packages/common-utils/src/__tests__/renderChartConfig.test.ts b/packages/common-utils/src/__tests__/renderChartConfig.test.ts index 2264838536..20b8566135 100644 --- a/packages/common-utils/src/__tests__/renderChartConfig.test.ts +++ b/packages/common-utils/src/__tests__/renderChartConfig.test.ts @@ -4313,6 +4313,24 @@ describe('renderChartConfig', () => { expect(sql).toContain('GROUP BY ALL ORDER BY `__hdx_time_bucket`'); }); + it('keeps a metric name containing "settings" intact in its branch', async () => { + const generatedSql = await renderChartConfig( + { + ...baseMultiSeriesConfig, + select: [ + gaugeSelect('app.settings.reloads'), + gaugeSelect('metric.beta'), + ], + }, + mockMetadata, + querySettings, + ); + const sql = parameterizedQueryToSql(generatedSql); + + expect(sql).toContain("MetricName = 'app.settings.reloads'"); + expect(sql.match(/SETTINGS/g)).toHaveLength(1); + }); + it('renders a metric ratio as a SQL-side division', async () => { const generatedSql = await renderChartConfig( { diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 11dc1a7c58..850d4b873b 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -2766,6 +2766,19 @@ describe('utils', () => { // This test case illustrates that subsequent clauses will also be extracted. settingsClause: 'SETTINGS opt = 1, cast = 1 FORMAT json', }, + { + label: 'settings inside an identifier', + sql: 'SELECT * FROM table WHERE AppSettings = 1 SETTINGS opt = 1', + withoutSettingsClause: 'SELECT * FROM table WHERE AppSettings = 1', + settingsClause: 'SETTINGS opt = 1', + }, + { + label: 'settings inside a string literal', + sql: "SELECT * FROM table WHERE MetricName = 'app.settings.reloads'", + withoutSettingsClause: + "SELECT * FROM table WHERE MetricName = 'app.settings.reloads'", + settingsClause: undefined, + }, ])( 'Extracts SETTINGS clause from: "$label" query', ({ sql, settingsClause, withoutSettingsClause }) => { diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index a18d9c7b95..3df2d94c7d 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -1209,6 +1209,32 @@ export function isDateRangeEqual(range1: [Date, Date], range2: [Date, Date]) { ); } +/** + * Index of the first standalone SETTINGS keyword, or -1. Occurrences inside + * quoted strings or identifiers (e.g. `'app.settings.reloads'`, `AppSettings`) + * are not the clause and must not split the query. + */ +function findSettingsKeyword(sql: string): number { + const isWordChar = (c: string) => /\w/.test(c); + let quote: string | undefined; + for (let i = 0; i < sql.length; i++) { + const c = sql.charAt(i); + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = undefined; + } else if (c === "'" || c === '"' || c === '`') { + quote = c; + } else if ( + sql.substring(i, i + 8).toUpperCase() === 'SETTINGS' && + !isWordChar(sql.charAt(i - 1)) && + !isWordChar(sql.charAt(i + 8)) + ) { + return i; + } + } + return -1; +} + /* This function extracts the SETTINGS clause from the end(!) of the sql string. */ @@ -1219,7 +1245,7 @@ export function extractSettingsClauseFromEnd( ? sqlInput.trim().slice(0, -1) : sqlInput.trim(); - const settingsIndex = sql.toUpperCase().indexOf('SETTINGS'); + const settingsIndex = findSettingsKeyword(sql); if (settingsIndex === -1) { return [sql, undefined] as const; From 542bb8568cb92226abbfecbb99a5894e129642fd Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:39:05 -0700 Subject: [PATCH 2/3] fix: skip comments and dotted settings paths when finding SETTINGS --- .../common-utils/src/__tests__/utils.test.ts | 14 ++++++++++++++ packages/common-utils/src/core/utils.ts | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 850d4b873b..951732b9fc 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -2779,6 +2779,20 @@ describe('utils', () => { "SELECT * FROM table WHERE MetricName = 'app.settings.reloads'", settingsClause: undefined, }, + { + label: 'settings as a dotted path or map column', + sql: "SELECT * FROM table WHERE LogAttributes.settings = 'x' AND settings['k'] = 'y'", + withoutSettingsClause: + "SELECT * FROM table WHERE LogAttributes.settings = 'x' AND settings['k'] = 'y'", + settingsClause: undefined, + }, + { + label: 'apostrophe in a comment before SETTINGS', + sql: "SELECT * FROM table -- don't count retries\nWHERE a = 1 SETTINGS max_threads = 1", + withoutSettingsClause: + "SELECT * FROM table -- don't count retries\nWHERE a = 1", + settingsClause: 'SETTINGS max_threads = 1', + }, ])( 'Extracts SETTINGS clause from: "$label" query', ({ sql, settingsClause, withoutSettingsClause }) => { diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 3df2d94c7d..59835e6ec4 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -1211,11 +1211,13 @@ export function isDateRangeEqual(range1: [Date, Date], range2: [Date, Date]) { /** * Index of the first standalone SETTINGS keyword, or -1. Occurrences inside - * quoted strings or identifiers (e.g. `'app.settings.reloads'`, `AppSettings`) - * are not the clause and must not split the query. + * quoted strings, comments or identifiers (e.g. `'app.settings.reloads'`, + * `AppSettings`, `LogAttributes.settings`) are not the clause and must not + * split the query. */ function findSettingsKeyword(sql: string): number { const isWordChar = (c: string) => /\w/.test(c); + const nextNonSpace = (from: number) => sql.slice(from).trimStart().charAt(0); let quote: string | undefined; for (let i = 0; i < sql.length; i++) { const c = sql.charAt(i); @@ -1224,10 +1226,20 @@ function findSettingsKeyword(sql: string): number { else if (c === quote) quote = undefined; } else if (c === "'" || c === '"' || c === '`') { quote = c; + } else if (c === '-' && sql.charAt(i + 1) === '-') { + const lineEnd = sql.indexOf('\n', i + 2); + if (lineEnd === -1) break; + i = lineEnd; + } else if (c === '/' && sql.charAt(i + 1) === '*') { + const blockEnd = sql.indexOf('*/', i + 2); + if (blockEnd === -1) break; + i = blockEnd + 1; } else if ( sql.substring(i, i + 8).toUpperCase() === 'SETTINGS' && !isWordChar(sql.charAt(i - 1)) && - !isWordChar(sql.charAt(i + 8)) + !isWordChar(sql.charAt(i + 8)) && + sql.slice(0, i).trimEnd().slice(-1) !== '.' && + !['.', '['].includes(nextNonSpace(i + 8)) ) { return i; } From ff0d6bd8c724b334741b2029a54a72fcac29edee Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Tue, 29 Sep 2026 13:27:28 -0400 Subject: [PATCH 3/3] test: Add more extractSettingsClauseFromEnd tests --- .../common-utils/src/__tests__/utils.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 951732b9fc..a85a649ddf 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -2793,6 +2793,58 @@ describe('utils', () => { "SELECT * FROM table -- don't count retries\nWHERE a = 1", settingsClause: 'SETTINGS max_threads = 1', }, + { + label: 'apostrophe in a block comment before SETTINGS', + sql: "SELECT * FROM table /* don't count retries */ WHERE a = 1 SETTINGS max_threads = 1", + withoutSettingsClause: + "SELECT * FROM table /* don't count retries */ WHERE a = 1", + settingsClause: 'SETTINGS max_threads = 1', + }, + { + label: 'settings inside a comment', + sql: 'SELECT * FROM table -- SETTINGS in a comment\nWHERE a = 1', + withoutSettingsClause: + 'SELECT * FROM table -- SETTINGS in a comment\nWHERE a = 1', + settingsClause: undefined, + }, + { + label: 'escaped quote in a string literal before settings', + sql: "SELECT * FROM table WHERE a = 'it\\'s settings' SETTINGS max_threads = 1", + withoutSettingsClause: + "SELECT * FROM table WHERE a = 'it\\'s settings'", + settingsClause: 'SETTINGS max_threads = 1', + }, + { + label: 'doubled quote in a string literal before settings', + sql: "SELECT * FROM table WHERE a = 'it''s settings' SETTINGS max_threads = 1", + withoutSettingsClause: "SELECT * FROM table WHERE a = 'it''s settings'", + settingsClause: 'SETTINGS max_threads = 1', + }, + { + label: 'settings as a quoted identifier', + sql: 'SELECT `settings`, "settings" FROM table SETTINGS max_threads = 1', + withoutSettingsClause: 'SELECT `settings`, "settings" FROM table', + settingsClause: 'SETTINGS max_threads = 1', + }, + { + label: 'settings as a table name', + sql: 'SELECT name FROM system.settings WHERE changed SETTINGS max_threads = 1', + withoutSettingsClause: 'SELECT name FROM system.settings WHERE changed', + settingsClause: 'SETTINGS max_threads = 1', + }, + { + label: 'lowercase keyword followed by a newline', + sql: 'SELECT * FROM table WHERE a = 1 settings\n max_threads = 1', + withoutSettingsClause: 'SELECT * FROM table WHERE a = 1', + settingsClause: 'settings\n max_threads = 1', + }, + { + label: 'string value inside the SETTINGS clause', + sql: "SELECT * FROM table SETTINGS short_circuit_function_evaluation = 'force_enable'", + withoutSettingsClause: 'SELECT * FROM table', + settingsClause: + "SETTINGS short_circuit_function_evaluation = 'force_enable'", + }, ])( 'Extracts SETTINGS clause from: "$label" query', ({ sql, settingsClause, withoutSettingsClause }) => {