Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/query/compile-kql.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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');
Expand All @@ -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];
}
Expand Down Expand Up @@ -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' : ''}`;
}

/**
Expand Down Expand Up @@ -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 })
};
}

Expand Down
13 changes: 12 additions & 1 deletion src/query/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,18 @@ 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. 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) chain.push(node.code);
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.');
}

Expand Down
21 changes: 19 additions & 2 deletions test/query-compile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -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'));
});
Expand Down
34 changes: 34 additions & 0 deletions test/query-run.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,40 @@ 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. 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'), {
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: "'DemiEventsHourly_CL' on workspace 4f6c9a3e-2b1d-4e7f-9a0c-8d5e1f2b3a4c"
}
}
}
}
});
});
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/);
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) => {
runner._setRunner(async () => ({
status: 'PartialFailure',
Expand Down