Skip to content

Commit a4fc7be

Browse files
feat: Added DQL Metadata like scannedBytes to execute_dql tool (#124)
* feat: Added DQL Metadata like scannedBytes to execute_dql tool * Review comments * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 262d626 commit a4fc7be

File tree

8 files changed

+139
-34
lines changed

8 files changed

+139
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased Changes
44

5+
- Added metadata output which includes scanned bytes (for cost tracking) to `execute_dql`
6+
57
## 0.5.0
68

79
**Highlights**:

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,29 @@ Bring real-time observability data directly into your development workflow.
2727
- Get more information about a monitored entity
2828
- Get Ownership of an entity
2929

30-
## Costs
30+
### Costs
3131

32-
**Important:** While this local MCP server is provided for free, using it to access data in Dynatrace Grail may incur additional costs based
32+
**Important:** While this local MCP server is provided for free, using certain capabilities to access data in Dynatrace Grail may incur additional costs based
3333
on your Dynatrace consumption model. This affects `execute_dql` tool and other capabilities that **query** Dynatrace Grail storage, and costs
34-
depend on the volume (GB scanned/billed).
34+
depend on the volume (GB scanned).
3535

3636
**Before using this MCP server extensively, please:**
3737

3838
1. Review your current Dynatrace consumption model and pricing
3939
2. Understand the cost implications of the specific data you plan to query (logs, events, metrics) - see [Dynatrace Pricing and Rate Card](https://www.dynatrace.com/pricing/)
4040
3. Start with smaller timeframes (e.g., 12h-24h) and make use of [buckets](https://docs.dynatrace.com/docs/discover-dynatrace/platform/grail/data-model#built-in-grail-buckets) to reduce the cost impact
4141

42-
**Note**: We will be providing a way to monitor Query Usage of the dynatrace-mcp-server in the future.
42+
**To understand costs that occured:**
43+
44+
Execute the following DQL statement in a notebook to see how much bytes have been queried from Grail (Logs, Events, etc...):
45+
46+
```
47+
fetch dt.system.events
48+
| filter event.kind == "QUERY_EXECUTION_EVENT" and contains(client.client_context, "dynatrace-mcp")
49+
| sort timestamp desc
50+
| fields timestamp, query_id, query_string, scanned_bytes, table, bucket, user.id, user.email, client.client_context
51+
| maketimeSeries sum(scanned_bytes), by: { user.email, user.id, table }
52+
```
4353

4454
### AI-Powered Assistance (Preview)
4555

integration-tests/execute-dql.integration.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,20 @@ describe('Execute DQL Integration Tests', () => {
9595
});
9696

9797
expect(executionResponse).toBeDefined();
98-
expect(Array.isArray(executionResponse)).toBe(true);
98+
expect(executionResponse?.records).toBeDefined();
99+
expect(Array.isArray(executionResponse?.records)).toBe(true);
99100

100101
// Should return an array of records (even if empty)
101-
if (executionResponse && executionResponse.length > 0) {
102+
if (executionResponse?.records && executionResponse.records.length > 0) {
102103
// Check that records have expected structure
103-
expect(typeof executionResponse[0]).toBe('object');
104+
expect(typeof executionResponse.records[0]).toBe('object');
104105
}
106+
107+
// Check that cost information is available
108+
expect(executionResponse?.scannedBytes).toBeDefined();
109+
expect(typeof executionResponse?.scannedBytes).toBe('number');
110+
expect(executionResponse?.scannedRecords).toBeDefined();
111+
expect(typeof executionResponse?.scannedRecords).toBe('number');
105112
});
106113

107114
test('should execute metrics query', async () => {
@@ -122,11 +129,12 @@ describe('Execute DQL Integration Tests', () => {
122129
});
123130

124131
expect(executionResponse).toBeDefined();
125-
expect(Array.isArray(executionResponse)).toBe(true);
132+
expect(executionResponse?.records).toBeDefined();
133+
expect(Array.isArray(executionResponse?.records)).toBe(true);
126134

127135
// Metrics might not always have data, so we just check structure
128-
if (executionResponse && executionResponse.length > 0) {
129-
expect(typeof executionResponse[0]).toBe('object');
136+
if (executionResponse?.records && executionResponse.records.length > 0) {
137+
expect(typeof executionResponse.records[0]).toBe('object');
130138
}
131139
});
132140

@@ -148,13 +156,14 @@ describe('Execute DQL Integration Tests', () => {
148156
});
149157

150158
expect(executionResponse).toBeDefined();
151-
expect(Array.isArray(executionResponse)).toBe(true);
159+
expect(executionResponse?.records).toBeDefined();
160+
expect(Array.isArray(executionResponse?.records)).toBe(true);
152161

153162
// Events might not always have data, so we just check structure
154-
if (executionResponse && executionResponse.length > 0) {
155-
expect(typeof executionResponse[0]).toBe('object');
163+
if (executionResponse?.records && executionResponse.records.length > 0) {
164+
expect(typeof executionResponse.records[0]).toBe('object');
156165
// Events should have common fields like timestamp, event.type, etc.
157-
const firstEvent = executionResponse[0] as Record<string, any>;
166+
const firstEvent = executionResponse.records[0] as Record<string, any>;
158167
expect(firstEvent).toHaveProperty('timestamp');
159168
}
160169
});

src/capabilities/execute-dql.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,70 @@ export const verifyDqlStatement = async (dtClient: HttpClient, dqlStatement: str
1414
return response;
1515
};
1616

17+
export interface DqlExecutionResult {
18+
records: QueryResult['records'];
19+
metadata: QueryResult['metadata'];
20+
/** Number of Bytes scanned = Bytes Billed */
21+
scannedBytes?: number;
22+
scannedRecords?: number;
23+
executionTimeMilliseconds?: number;
24+
queryId?: string;
25+
sampled?: boolean;
26+
}
27+
28+
/**
29+
* Helper function to create a DQL execution result and log metadata information.
30+
* @param queryResult - The query result from Dynatrace API
31+
* @param logPrefix - Prefix for the log message (e.g., "DQL Execution Metadata" or "DQL Execution Metadata (Polled)")
32+
* @returns DqlExecutionResult with extracted metadata
33+
*/
34+
const createResultAndLog = (queryResult: QueryResult, logPrefix: string): DqlExecutionResult => {
35+
const result: DqlExecutionResult = {
36+
records: queryResult.records,
37+
metadata: queryResult.metadata,
38+
scannedBytes: queryResult.metadata?.grail?.scannedBytes,
39+
scannedRecords: queryResult.metadata?.grail?.scannedRecords,
40+
executionTimeMilliseconds: queryResult.metadata?.grail?.executionTimeMilliseconds,
41+
queryId: queryResult.metadata?.grail?.queryId,
42+
sampled: queryResult.metadata?.grail?.sampled,
43+
};
44+
45+
console.error(
46+
`${logPrefix} scannedBytes=${result.scannedBytes} scannedRecords=${result.scannedRecords} executionTime=${result.executionTimeMilliseconds} queryId=${result.queryId}`,
47+
);
48+
49+
return result;
50+
};
51+
1752
/**
1853
* Execute a DQL statement against the Dynatrace API.
1954
* If the result is immediately available, it will be returned.
2055
* If the result is not immediately available, it will poll for the result until it is available.
2156
* @param dtClient
2257
* @param body - Contains the DQL statement to execute, and optional parameters like maxResultRecords and maxResultBytes
23-
* @returns the result without metadata and without notifications, or undefined if the query failed or no result was returned.
58+
* @returns the result with records, metadata and cost information, or undefined if the query failed or no result was returned.
2459
*/
2560
export const executeDql = async (
2661
dtClient: HttpClient,
2762
body: ExecuteRequest,
28-
): Promise<QueryResult['records'] | undefined> => {
63+
): Promise<DqlExecutionResult | undefined> => {
64+
// create a Dynatrace QueryExecutionClient
2965
const queryExecutionClient = new QueryExecutionClient(dtClient);
3066

67+
// and execute the query (part of body)
3168
const response = await queryExecutionClient.queryExecute({
3269
body,
70+
// define a dedicated user agent to enable tracking of DQL queries executed by the dynatrace-mcp-server
3371
dtClientContext: getUserAgent(),
3472
});
3573

74+
// check if we already got a result back
3675
if (response.result) {
37-
// return response result immediately
38-
return response.result.records;
76+
// yes - return response result immediately
77+
return createResultAndLog(response.result, 'execute_dql - Metadata:');
3978
}
40-
// else: We might have to poll
79+
80+
// no result yet? we have wait and poll (this requires requestToken to be set)
4181
if (response.requestToken) {
4282
// poll for the result
4383
let pollResponse;
@@ -48,12 +88,22 @@ export const executeDql = async (
4888
requestToken: response.requestToken,
4989
dtClientContext: getUserAgent(),
5090
});
51-
// done - let's return it
91+
92+
// check if we got a result from the polling endpoint
5293
if (pollResponse.result) {
53-
return pollResponse.result.records;
94+
// yes - let's return the polled result
95+
return createResultAndLog(pollResponse.result, 'execute_dql Metadata (polled):');
5496
}
5597
} while (pollResponse.state === 'RUNNING' || pollResponse.state === 'NOT_STARTED');
98+
99+
// state != RUNNING and != NOT_STARTED - we should log that
100+
console.error(
101+
`execute_dql with requestToken ${response.requestToken} ended with state ${pollResponse.state}, stopping...`,
102+
);
103+
return undefined;
56104
}
57-
// else: whatever happened - we have an error
105+
106+
// no requestToken set? This should not happen, but just in case, let's log it
107+
console.error(`execute_dql did not respond with a requestToken, stopping...`);
58108
return undefined;
59109
};

src/capabilities/find-monitored-entity-by-name.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ export const findMonitoredEntityByName = async (dtClient: HttpClient, entityName
3838
// Note: This may be slow, as we are appending multiple entity types above
3939
const dqlResponse = await executeDql(dtClient, { query: dql });
4040

41-
if (dqlResponse && dqlResponse.length > 0) {
41+
if (dqlResponse && dqlResponse.records && dqlResponse.records.length > 0) {
4242
let resp = 'The following monitored entities were found:\n';
4343
// iterate over dqlResponse and create a string with the entity names
44-
dqlResponse.forEach((entity) => {
44+
dqlResponse.records.forEach((entity) => {
4545
if (entity) {
4646
resp += `- Entity '${entity['entity.name']}' of type '${entity['entity.type']} has entity id '${entity.id}'\n`;
4747
}

src/capabilities/get-monitored-entity-details.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@ export const getMonitoredEntityDetails = async (
3131
const dqlResponse = await executeDql(dtClient, { query: dql });
3232

3333
// verify response and length
34-
if (!dqlResponse || dqlResponse.length === 0) {
34+
if (!dqlResponse || !dqlResponse.records || dqlResponse.records.length === 0) {
3535
console.error(`No entity found for ID: ${entityId}`);
3636
return;
3737
}
3838

3939
// in case we have more than one entity -> log it
40-
if (dqlResponse.length > 1) {
40+
if (dqlResponse.records.length > 1) {
4141
console.error(
42-
`Multiple entities (${dqlResponse.length}) found for entity ID: ${entityId}. Returning the first one.`,
42+
`Multiple entities (${dqlResponse.records.length}) found for entity ID: ${entityId}. Returning the first one.`,
4343
);
4444
}
4545

46-
const entity = dqlResponse[0];
46+
const entity = dqlResponse.records[0];
4747
// make typescript happy; entity should never be null though
4848
if (!entity) {
4949
console.error(`No entity found for ID: ${entityId}`);

src/capabilities/list-vulnerabilities.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const listVulnerabilities = async (dtClient: HttpClient, additionalFilter
2323
maxResultBytes: 5_000_000, // 5 MB
2424
});
2525

26-
if (!response || response.length === 0) {
26+
if (!response || !response.records || response.records.length === 0) {
2727
return [];
2828
}
2929

30-
const vulnerabilities = response.map((vuln: any) => {
30+
const vulnerabilities = response.records.map((vuln: any) => {
3131
const vulnerabilityId = vuln['vulnerability.id'] || 'N/A';
3232
const vulnerabilityDisplayId = vuln['vulnerability.display_id'] || 'N/A';
3333
const riskScore = vuln['vulnerability.risk.score'] || 'N/A';

src/index.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,10 @@ const main = async () => {
283283
);
284284
// get problems (uses fetch)
285285
const result = await listProblems(dtClient, additionalFilter);
286-
if (result && result.length > 0) {
287-
let resp = `Found ${result.length} problems! Displaying the top ${maxProblemsToDisplay} problems:\n`;
286+
if (result && result.records && result.records.length > 0) {
287+
let resp = `Found ${result.records.length} problems! Displaying the top ${maxProblemsToDisplay} problems:\n`;
288288
// iterate over dqlResponse and create a string with the problem details, but only show the top maxProblemsToDisplay problems
289-
result.slice(0, maxProblemsToDisplay).forEach((problem) => {
289+
result.records.slice(0, maxProblemsToDisplay).forEach((problem) => {
290290
if (problem) {
291291
resp += `Problem ${problem['display_id']} (please refer to this problem with \`problemId\` or \`event.id\` ${problem['problem_id']}))
292292
with event.status ${problem['event.status']}, event.category ${problem['event.category']}: ${problem['event.name']} -
@@ -462,7 +462,41 @@ const main = async () => {
462462
);
463463
const response = await executeDql(dtClient, { query: dqlStatement });
464464

465-
return `DQL Response: ${JSON.stringify(response)}`;
465+
if (!response) {
466+
return 'DQL execution failed or returned no result.';
467+
}
468+
469+
let result = `📊 **DQL Query Results**\n\n`;
470+
471+
// Cost and Performance Information
472+
if (response.scannedRecords !== undefined) {
473+
result += `- **Scanned Records:** ${response.scannedRecords.toLocaleString()}\n`;
474+
}
475+
476+
if (response.scannedBytes !== undefined) {
477+
const scannedGB = response.scannedBytes / (1000 * 1000 * 1000);
478+
result += `- **Scanned Bytes:** ${scannedGB.toFixed(2)} GB`;
479+
480+
// Cost warning based on scanned bytes
481+
if (scannedGB > 500) {
482+
result += `\n ⚠️ **Very High Data Usage Warning:** This query scanned ${scannedGB.toFixed(1)} GB of data, which may impact your Dynatrace consumption. Please take measures to optimize your query, like limiting the timeframe or selecting a bucket.\n`;
483+
} else if (scannedGB > 50) {
484+
result += `\n ⚠️ **High Data Usage Warning:** This query scanned ${scannedGB.toFixed(2)} GB of data, which may impact your Dynatrace consumption.\n`;
485+
} else if (scannedGB > 5) {
486+
result += `\n 💡 **Moderate Data Usage:** This query scanned ${scannedGB.toFixed(2)} GB of data.\n`;
487+
} else if (response.scannedBytes === 0) {
488+
result += `\n 💡 **No Data consumed:** This query did not consume any data.\n`;
489+
}
490+
}
491+
492+
if (response.sampled !== undefined && response.sampled) {
493+
result += `- **⚠️ Sampling Used:** Yes (results may be approximate)\n`;
494+
}
495+
496+
result += `\n📋 **Query Results**: (${response.records?.length || 0} records):\n\n`;
497+
result += `\`\`\`json\n${JSON.stringify(response.records, null, 2)}\n\`\`\``;
498+
499+
return result;
466500
},
467501
);
468502

0 commit comments

Comments
 (0)