From d95d13bccc70dd88b068e15068d0f6b660aa43c0 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Sun, 6 Sep 2026 06:00:20 +0000 Subject: [PATCH 1/2] fix(query): emit workspace() scope as a plain literal Log Analytics rejects workspace(@'guid') with SEM0260 "Unknown function: 'workspace'", so every errors measure and every DEMI union answered 502. The ref is pattern-checked, so a plain quoted string is safe. Log the inner error chain on a refused query, and tell a DEMI union apart from the same rollup query in the log line. --- src/query/compile-kql.js | 17 +++++++++++------ src/query/run.js | 12 +++++++++++- test/query-compile.test.js | 21 +++++++++++++++++++-- test/query-run.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/query/compile-kql.js b/src/query/compile-kql.js index 49c881d..6045db0 100644 --- a/src/query/compile-kql.js +++ b/src/query/compile-kql.js @@ -75,12 +75,15 @@ function stringValue(value, dimension, op) { * * Still pattern-checked: a GUID, a name and a resource id are all made of these characters, and a * mistyped setting must fail as a 400 here rather than as whatever KQL a stray quote would make. + * + * Returned plain-quoted, not through `literal()`: the service answers `workspace(@'...')` with + * SEM0260 unknown function, and the pattern above is what makes a plain literal safe. */ function workspaceRef(value, envName) { const ref = String(value || '').trim(); if (!ref) throw bad(`${envName} is not configured, so this measure is unavailable.`); if (!/^[A-Za-z0-9._/-]+$/.test(ref)) throw bad(`${envName} is not a usable workspace reference.`); - return ref; + return `'${ref}'`; } /** ISO from/to, bounded so no request can ask for a scan wider than raw retention. */ @@ -233,7 +236,7 @@ function demiFrom() { 'union', ` (${schema.SOURCES.daily} | project ${columns}),`, // DEMI's rollup is hourly and has no Day column, so its own bucket is folded down to one. - ` (workspace(${literal(ref)}).${schema.DEMI_TABLE}` + + ` (workspace(${ref}).${schema.DEMI_TABLE}` + ` | extend SourceApp = ${literal(schema.DEMI_SOURCE_APP)}, ${day} = bin(TimeGenerated, 1d)` + ` | project ${columns})` ].join('\n'); @@ -242,7 +245,7 @@ function demiFrom() { function tableFor(source, includeDemi) { if (source === 'errors') { const ref = workspaceRef(config.eagleLogsWorkspace, 'EAGLE_LOGS_WORKSPACE'); - return `workspace(${literal(ref)}).${schema.ERRORS_TABLE}`; + return `workspace(${ref}).${schema.ERRORS_TABLE}`; } return includeDemi ? demiFrom() : schema.SOURCES[source]; } @@ -272,10 +275,12 @@ function timespanFor(source, from, to) { * log line can be matched to the query `?debug=1` returns. The text itself never goes to a log, which * is read by more people than the analytics workspace — it names workspaces and tables. */ -function summarize(kql, { measure, source, bin, dimensions }) { +function summarize(kql, { measure, source, bin, dimensions, includeDemi }) { const id = crypto.createHash('sha256').update(kql).digest('hex').slice(0, 8); + // demi is named only when asked for: the union reads the same source, so otherwise both shapes log + // the same line. return `q=${id} measure=${measure} source=${source} bin=${bin || 'none'} ` + - `dims=${dimensions.length ? dimensions.join(',') : 'none'}`; + `dims=${dimensions.length ? dimensions.join(',') : 'none'}${includeDemi ? ' demi=true' : ''}`; } /** @@ -352,7 +357,7 @@ function compile(input) { return { kql, timespan: timespanFor(source, from, to), - summary: summarize(kql, { measure, source, bin, dimensions }) + summary: summarize(kql, { measure, source, bin, dimensions, includeDemi }) }; } diff --git a/src/query/run.js b/src/query/run.js index 1c259e2..64c25c5 100644 --- a/src/query/run.js +++ b/src/query/run.js @@ -72,7 +72,17 @@ async function run(kql, timespan, summary = '') { try { result = await execute(kql, timespan); } catch (err) { - logger.error(`[analytics] query ${summary} failed: ${err.message}`); + // The SDK's own message is just the status code; the reason the service refused (SEM0260 and + // friends) only exists in the nested error chain of the response body. + const chain = []; + let node = err.details?.error; + while (node && chain.length < 5) { + if (node.code || node.message) chain.push(`${node.code || '?'}: ${node.message || ''}`); + node = node.innerError || node.innererror; + } + const status = err.statusCode ? ` status=${err.statusCode}` : ''; + const reason = chain.length ? ` (${chain.join(' <- ')})` : ''; + logger.error(`[analytics] query ${summary} failed: ${err.message}${status}${reason}`); throw httpError(502, 'The analytics workspace did not answer.'); } diff --git a/test/query-compile.test.js b/test/query-compile.test.js index 3e5fe3a..b01dc50 100644 --- a/test/query-compile.test.js +++ b/test/query-compile.test.js @@ -104,7 +104,7 @@ test('p95Duration takes the 95th percentile of DurationMs', () => { test('errors read AppExceptions in the application workspace and rename AppRoleName', () => { assert.strictEqual(kql({ measure: 'errors', bin: 'day', groupBy: ['SourceApp'] }), [ - "workspace(@'4a1b3c5d-1111-4222-8333-aaaaaaaaaaaa').AppExceptions", + "workspace('4a1b3c5d-1111-4222-8333-aaaaaaaaaaaa').AppExceptions", '| summarize value = count() by t = bin(TimeGenerated, 1d), SourceApp = AppRoleName', '| order by t asc, SourceApp asc', '| limit 1000' @@ -154,7 +154,7 @@ test('includeDemi unions the DEMI hourly rollup and labels its rows', () => { assert.strictEqual(kql({ measure: 'events', bin: 'day', groupBy: ['SourceApp'], includeDemi: true }), [ 'union', ' (EagleEventsDaily_CL | project Day, SourceApp, EventName, ProjectId, Events, Users),', - " (workspace(@'7f2e9d8c-4444-4555-8666-bbbbbbbbbbbb').DemiEventsHourly_CL | extend SourceApp = @'eagle-demi'" + + " (workspace('7f2e9d8c-4444-4555-8666-bbbbbbbbbbbb').DemiEventsHourly_CL | extend SourceApp = @'eagle-demi'" + ", Day = bin(TimeGenerated, 1d) | project Day, SourceApp, EventName, ProjectId, Events, Users)", "| where Day >= todatetime(@'2026-08-29T00:00:00.000Z') and Day < todatetime(@'2026-09-05T00:00:00.000Z')", '| summarize value = sum(Events) by t = bin(Day, 1d), SourceApp', @@ -163,6 +163,23 @@ test('includeDemi unions the DEMI hourly rollup and labels its rows', () => { ].join('\n')); }); +// SEM0260 unknown function: Log Analytics resolves workspace() from a plain string literal only, so +// the cross-workspace scope is the one place a verbatim literal is wrong. +test('a cross-workspace scope is never emitted as a verbatim literal', () => { + const errors = kql({ measure: 'errors', bin: 'day', groupBy: ['SourceApp'] }); + const demi = kql({ measure: 'events', source: 'daily', bin: 'day', includeDemi: true }); + + assert.ok(!errors.includes('workspace(@'), errors); + assert.ok(!demi.includes('workspace(@'), demi); +}); + +test('the summary tells a DEMI union apart from the same query without it', () => { + const body = { measure: 'events', source: 'daily', bin: 'day', range: WEEK }; + + assert.ok(!compile(body).summary.includes('demi='), compile(body).summary); + assert.match(compile({ ...body, includeDemi: true }).summary, /source=daily bin=day dims=none demi=true$/); +}); + test('auto reads raw events for a short range', () => { assert.ok(kql({ measure: 'events', bin: 'day' }).startsWith('EagleEvents_CL')); }); diff --git a/test/query-run.test.js b/test/query-run.test.js index 26fb022..6b3c830 100644 --- a/test/query-run.test.js +++ b/test/query-run.test.js @@ -120,6 +120,34 @@ test('a failed query is logged by its summary, never by its text', async (t) => assert.doesNotMatch(lines.join(' '), /EagleEvents_CL/); }); +// The SDK message for a refused query is only 'Unexpected status code: 400'; the reason lives in the +// response body's error chain, and either casing of innererror shows up across Azure services. +test('a refused query is logged with its status and inner error chain', async (t) => { + runner._setRunner(async () => { + throw Object.assign(new Error('Unexpected status code: 400'), { + statusCode: 400, + details: { + error: { + code: 'BadArgumentError', + message: 'The request had some invalid properties', + innerError: { + code: 'SemanticError', + message: 'A semantic error occurred.', + innererror: { code: 'SEM0260', message: "Unknown function: 'workspace'" } + } + } + } + }); + }); + const lines = []; + t.mock.method(logger, 'error', (message) => lines.push(message)); + + await assert.rejects(runner.run('EagleEvents_CL', TIMESPAN, SUMMARY)); + + assert.match(lines[0], /status=400/); + assert.match(lines[0], /BadArgumentError: .* <- SemanticError: .* <- SEM0260: Unknown function: 'workspace'/); +}); + test('a partial result is warned about by its summary too', async (t) => { runner._setRunner(async () => ({ status: 'PartialFailure', From 4e4c36b951bb55c58f28ab0064c704200cc95b84 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Sun, 6 Sep 2026 06:04:55 +0000 Subject: [PATCH 2/2] fix(query): log refused-query error codes only Service messages can quote query text; codes carry the diagnosis. --- src/query/run.js | 5 +++-- test/query-run.test.js | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/query/run.js b/src/query/run.js index 64c25c5..1b1d0eb 100644 --- a/src/query/run.js +++ b/src/query/run.js @@ -73,11 +73,12 @@ async function run(kql, timespan, summary = '') { result = await execute(kql, timespan); } catch (err) { // The SDK's own message is just the status code; the reason the service refused (SEM0260 and - // friends) only exists in the nested error chain of the response body. + // friends) only exists in the nested error chain of the response body. Codes only, never + // message text: the service quotes table names and workspace GUIDs back in those messages. const chain = []; let node = err.details?.error; while (node && chain.length < 5) { - if (node.code || node.message) chain.push(`${node.code || '?'}: ${node.message || ''}`); + if (node.code) chain.push(node.code); node = node.innerError || node.innererror; } const status = err.statusCode ? ` status=${err.statusCode}` : ''; diff --git a/test/query-run.test.js b/test/query-run.test.js index 6b3c830..76cf7a0 100644 --- a/test/query-run.test.js +++ b/test/query-run.test.js @@ -121,7 +121,8 @@ test('a failed query is logged by its summary, never by its text', async (t) => }); // The SDK message for a refused query is only 'Unexpected status code: 400'; the reason lives in the -// response body's error chain, and either casing of innererror shows up across Azure services. +// response body's error chain, and either casing of innererror shows up across Azure services. Those +// inner messages quote table names and workspace GUIDs, so only the codes may reach the log. test('a refused query is logged with its status and inner error chain', async (t) => { runner._setRunner(async () => { throw Object.assign(new Error('Unexpected status code: 400'), { @@ -133,7 +134,10 @@ test('a refused query is logged with its status and inner error chain', async (t innerError: { code: 'SemanticError', message: 'A semantic error occurred.', - innererror: { code: 'SEM0260', message: "Unknown function: 'workspace'" } + innererror: { + code: 'SEM0260', + message: "'DemiEventsHourly_CL' on workspace 4f6c9a3e-2b1d-4e7f-9a0c-8d5e1f2b3a4c" + } } } } @@ -145,7 +149,9 @@ test('a refused query is logged with its status and inner error chain', async (t await assert.rejects(runner.run('EagleEvents_CL', TIMESPAN, SUMMARY)); assert.match(lines[0], /status=400/); - assert.match(lines[0], /BadArgumentError: .* <- SemanticError: .* <- SEM0260: Unknown function: 'workspace'/); + assert.match(lines[0], /BadArgumentError <- SemanticError <- SEM0260/); + assert.doesNotMatch(lines[0], /DemiEventsHourly_CL/); + assert.doesNotMatch(lines[0], /4f6c9a3e-2b1d-4e7f-9a0c-8d5e1f2b3a4c/); }); test('a partial result is warned about by its summary too', async (t) => {