Skip to content

Commit 051726c

Browse files
Feature: Added grail budget tracker (#126)
* feat: Added Grail Budget Tracking and a reset_grail_budget tool * fix: Allow setting Grail Budget to -1
1 parent cb52b08 commit 051726c

10 files changed

+660
-15
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
# @dynatrace-oss/dynatrace-mcp-server
22

3-
- Fixed an issue with stateless HTTP server only taking a single connection
4-
53
## Unreleased Changes
64

5+
- Fixed an issue with stateless HTTP server only taking a single connection
6+
- Added Grail budget tracking with `DT_GRAIL_QUERY_BUDGET_GB` environment variable (default: 1000 GB, setting it to `-1` disables it), as well as warnings and exceeded alerts in `execute_dql` tool responses
7+
- Enforce Grail budget by throwing an exception when the budget has been exceeded, preventing further DQL query execution
8+
79
## 0.6.0 (Release Candidate 1)
810

911
- Added metadata output to `execute_dql` tool which includes scanned bytes information, enabling better cost tracking for Dynatrace Grail data access
1012
- Added next-steps guidance to `get_entity_details` tool to help users discover related metrics, problems, and logs for entities
1113
- Added telemetry via Dynatrace OpenKit to improve the product with anonymous usage statistics and error information, enhancing product development while respecting user privacy (can be disabled via `DT_MCP_DISABLE_TELEMETRY` environment variable)
1214
- Added `server.json` configuration and published the MCP server to the official MCP Registry, making it easier for users to discover and install the server
15+
- Added metadata output which includes Grail scanned bytes (for cost tracking) to `execute_dql`
16+
- Added next-steps for `get_entity_details` to find out about metrics, problems and logs
17+
- Added Telemetry via Dynatrace OpenKit to improve the product with anonymous usage statistics and error information (can be disabled via `DT_MCP_DISABLE_TELEMETRY` environment variable)
1318

1419
## 0.5.0
1520

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ depend on the volume (GB scanned).
6060
1. Review your current Dynatrace consumption model and pricing
6161
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/)
6262
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
63+
4. Set an appropriate `DT_GRAIL_QUERY_BUDGET_GB` environment variable (default: 1000 GB) to control and monitor your Grail query consumption
64+
65+
**Grail Budget Tracking:**
66+
67+
The MCP server includes built-in budget tracking for Grail queries to help you monitor and control costs:
68+
69+
- Set `DT_GRAIL_QUERY_BUDGET_GB` (default: 1000 GB) to define your session budget limit
70+
- The server tracks bytes scanned across all Grail queries in the current session
71+
- You'll receive warnings when approaching 80% of your budget
72+
- Budget exceeded alerts help prevent unexpected high consumption
73+
- Budget resets when you restart the MCP server session
6374

6475
**To understand costs that occured:**
6576

@@ -321,6 +332,7 @@ You can set up authentication via **Platform Tokens** (recommended) or **OAuth C
321332
- `DT_PLATFORM_TOKEN` (string, e.g., `dt0s16.SAMPLE.abcd1234`) - **Recommended**: Dynatrace Platform Token
322333
- `OAUTH_CLIENT_ID` (string, e.g., `dt0s02.SAMPLE`) - Alternative: Dynatrace OAuth Client ID (for advanced use cases)
323334
- `OAUTH_CLIENT_SECRET` (string, e.g., `dt0s02.SAMPLE.abcd1234`) - Alternative: Dynatrace OAuth Client Secret (for advanced use cases)
335+
- `DT_GRAIL_QUERY_BUDGET_GB` (number, default: 1000) - Budget limit in GB (base 1000) for Grail query bytes scanned per session. The MCP server tracks your Grail usage and warns when approaching or exceeding this limit.
324336

325337
**Platform Tokens are recommended** for most use cases as they provide a simpler authentication flow. OAuth Clients should only be used when specific OAuth features are required.
326338

integration-tests/davis-copilot-explain-dql.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('DQL Explanation Integration Tests', () => {
103103
expect(response.status === 'SUCCESSFUL' || response.status === 'SUCCESSFUL_WITH_WARNINGS').toBeTruthy();
104104

105105
expect(response.summary.toLowerCase()).toContain('group logs by');
106-
expect(response.summary.toLowerCase()).toContain('count the number of logs');
106+
expect(response.summary.toLowerCase()).toContain('calculate the total number of logs');
107107
// The explanation should be reasonably detailed
108108
expect(response.explanation.length).toBeGreaterThan(50);
109109
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { executeDql } from './execute-dql';
2+
import { HttpClient } from '@dynatrace-sdk/http-client';
3+
import { QueryExecutionClient, QueryStartResponse } from '@dynatrace-sdk/client-query';
4+
import { resetGrailBudgetTracker, getGrailBudgetTracker } from '../utils/grail-budget-tracker';
5+
6+
// Mock the external dependencies
7+
jest.mock('@dynatrace-sdk/client-query');
8+
jest.mock('../utils/user-agent', () => ({
9+
getUserAgent: () => 'test-user-agent',
10+
}));
11+
12+
describe('executeDql Budget Check', () => {
13+
let mockHttpClient: jest.Mocked<HttpClient>;
14+
let mockQueryExecutionClient: jest.Mocked<QueryExecutionClient>;
15+
16+
beforeEach(() => {
17+
// Reset budget tracker before each test
18+
resetGrailBudgetTracker();
19+
20+
// Create mock HTTP client
21+
mockHttpClient = {
22+
// Add any necessary properties/methods for HttpClient mock
23+
} as jest.Mocked<HttpClient>;
24+
25+
// Create mock QueryExecutionClient
26+
mockQueryExecutionClient = {
27+
queryExecute: jest.fn(),
28+
queryPoll: jest.fn(),
29+
queryCancel: jest.fn(),
30+
} as unknown as jest.Mocked<QueryExecutionClient>;
31+
32+
// Mock the QueryExecutionClient constructor
33+
(QueryExecutionClient as jest.MockedClass<typeof QueryExecutionClient>).mockImplementation(
34+
() => mockQueryExecutionClient,
35+
);
36+
});
37+
38+
afterEach(() => {
39+
jest.clearAllMocks();
40+
resetGrailBudgetTracker();
41+
});
42+
43+
it('should prevent execution when budget is exceeded', async () => {
44+
const budgetLimitGB = 0.001; // Very small budget limit (1 MB)
45+
46+
// First, exhaust the budget by adding bytes to tracker
47+
const tracker = getGrailBudgetTracker(budgetLimitGB);
48+
tracker.addBytesScanned(2 * 1000 * 1000); // Add 2 MB, exceeding the 1 MB limit
49+
50+
const dqlStatement = 'fetch logs | limit 10';
51+
const body = { query: dqlStatement };
52+
53+
// Execute DQL with budget limit and expect it to throw
54+
await expect(executeDql(mockHttpClient, body, budgetLimitGB)).rejects.toThrow(/budget/);
55+
56+
// Verify that queryExecute was NOT called
57+
expect(mockQueryExecutionClient.queryExecute).not.toHaveBeenCalled();
58+
});
59+
60+
it('should allow execution when budget is not exceeded', async () => {
61+
const budgetLimitGB = 1; // 1 GB budget limit
62+
const dqlStatement = 'fetch logs | limit 10';
63+
const body = { query: dqlStatement };
64+
65+
// Mock successful response
66+
const mockResponse: QueryStartResponse = {
67+
state: 'RUNNING',
68+
result: {
69+
records: [{ field1: 'value1' }],
70+
types: [],
71+
metadata: {
72+
grail: {
73+
scannedBytes: 1000,
74+
scannedRecords: 1,
75+
executionTimeMilliseconds: 100,
76+
queryId: 'test-query-id',
77+
},
78+
},
79+
},
80+
};
81+
82+
mockQueryExecutionClient.queryExecute.mockResolvedValue(mockResponse);
83+
84+
// Execute DQL with budget limit
85+
const result = await executeDql(mockHttpClient, body, budgetLimitGB);
86+
87+
// Verify that queryExecute WAS called
88+
expect(mockQueryExecutionClient.queryExecute).toHaveBeenCalledWith({
89+
body,
90+
dtClientContext: 'test-user-agent',
91+
});
92+
93+
// Verify the result is returned correctly
94+
expect(result).toBeDefined();
95+
expect(result?.records).toEqual([{ field1: 'value1' }]);
96+
expect(result?.scannedBytes).toBe(1000);
97+
expect(result?.budgetState?.isBudgetExceeded).toBe(false);
98+
});
99+
100+
it('should allow execution when no budget limit is provided', async () => {
101+
const dqlStatement = 'fetch logs | limit 10';
102+
const body = { query: dqlStatement };
103+
104+
// Mock successful response
105+
const mockResponse: QueryStartResponse = {
106+
state: 'RUNNING',
107+
result: {
108+
records: [{ field1: 'value1' }],
109+
types: [],
110+
metadata: {
111+
grail: {
112+
scannedBytes: 1000000000, // 1 GB - would exceed small budgets
113+
scannedRecords: 1000,
114+
executionTimeMilliseconds: 100,
115+
queryId: 'test-query-id',
116+
},
117+
},
118+
},
119+
};
120+
121+
mockQueryExecutionClient.queryExecute.mockResolvedValue(mockResponse);
122+
123+
// Execute DQL without budget limit
124+
const result = await executeDql(mockHttpClient, body);
125+
126+
// Verify that queryExecute WAS called
127+
expect(mockQueryExecutionClient.queryExecute).toHaveBeenCalledWith({
128+
body,
129+
dtClientContext: 'test-user-agent',
130+
});
131+
132+
// Verify the result is returned correctly
133+
expect(result).toBeDefined();
134+
expect(result?.records).toEqual([{ field1: 'value1' }]);
135+
expect(result?.scannedBytes).toBe(1000000000);
136+
expect(result?.budgetState).toBeUndefined(); // No budget tracking
137+
});
138+
});

src/capabilities/execute-dql.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HttpClient } from '@dynatrace-sdk/http-client';
22
import { QueryExecutionClient, QueryAssistanceClient, QueryResult, ExecuteRequest } from '@dynatrace-sdk/client-query';
33
import { getUserAgent } from '../utils/user-agent';
4+
import { getGrailBudgetTracker, GrailBudgetTracker, generateBudgetWarning } from '../utils/grail-budget-tracker';
45

56
export const verifyDqlStatement = async (dtClient: HttpClient, dqlStatement: string) => {
67
const queryAssistanceClient = new QueryAssistanceClient(dtClient);
@@ -23,23 +24,46 @@ export interface DqlExecutionResult {
2324
executionTimeMilliseconds?: number;
2425
queryId?: string;
2526
sampled?: boolean;
27+
/** Budget tracking information */
28+
budgetState?: GrailBudgetTracker;
29+
/** Budget warning message if applicable */
30+
budgetWarning?: string;
2631
}
2732

2833
/**
2934
* Helper function to create a DQL execution result and log metadata information.
3035
* @param queryResult - The query result from Dynatrace API
3136
* @param logPrefix - Prefix for the log message (e.g., "DQL Execution Metadata" or "DQL Execution Metadata (Polled)")
37+
* @param budgetLimitGB - Budget limit in GB for tracking purposes
3238
* @returns DqlExecutionResult with extracted metadata
3339
*/
34-
const createResultAndLog = (queryResult: QueryResult, logPrefix: string): DqlExecutionResult => {
40+
const createResultAndLog = (
41+
queryResult: QueryResult,
42+
logPrefix: string,
43+
budgetLimitGB?: number,
44+
): DqlExecutionResult => {
45+
const scannedBytes = queryResult.metadata?.grail?.scannedBytes || 0;
46+
47+
// Track budget if limit is provided
48+
let budgetState: GrailBudgetTracker | undefined;
49+
let budgetWarning: string | undefined;
50+
51+
if (budgetLimitGB !== undefined) {
52+
const tracker = getGrailBudgetTracker(budgetLimitGB);
53+
budgetState = tracker.addBytesScanned(scannedBytes);
54+
budgetWarning = generateBudgetWarning(budgetState, scannedBytes) || undefined;
55+
}
56+
3557
const result: DqlExecutionResult = {
3658
records: queryResult.records,
3759
metadata: queryResult.metadata,
38-
scannedBytes: queryResult.metadata?.grail?.scannedBytes,
60+
scannedBytes,
3961
scannedRecords: queryResult.metadata?.grail?.scannedRecords,
4062
executionTimeMilliseconds: queryResult.metadata?.grail?.executionTimeMilliseconds,
4163
queryId: queryResult.metadata?.grail?.queryId,
4264
sampled: queryResult.metadata?.grail?.sampled,
65+
budgetState,
66+
budgetWarning,
4367
};
4468

4569
console.error(
@@ -55,12 +79,27 @@ const createResultAndLog = (queryResult: QueryResult, logPrefix: string): DqlExe
5579
* If the result is not immediately available, it will poll for the result until it is available.
5680
* @param dtClient
5781
* @param body - Contains the DQL statement to execute, and optional parameters like maxResultRecords and maxResultBytes
82+
* @param budgetLimitGB - Optional budget limit in GB for tracking bytes scanned
5883
* @returns the result with records, metadata and cost information, or undefined if the query failed or no result was returned.
5984
*/
6085
export const executeDql = async (
6186
dtClient: HttpClient,
6287
body: ExecuteRequest,
88+
budgetLimitGB?: number,
6389
): Promise<DqlExecutionResult | undefined> => {
90+
// Check budget before executing the query if budget limit is provided
91+
if (budgetLimitGB !== undefined) {
92+
const tracker = getGrailBudgetTracker(budgetLimitGB);
93+
const currentState = tracker.getState();
94+
95+
if (currentState.isBudgetExceeded) {
96+
console.error('DQL execution aborted: Grail budget has been exceeded');
97+
const budgetWarning = generateBudgetWarning(currentState, 0);
98+
99+
throw new Error(budgetWarning || 'DQL execution aborted: Grail budget has been exceeded');
100+
}
101+
}
102+
64103
// create a Dynatrace QueryExecutionClient
65104
const queryExecutionClient = new QueryExecutionClient(dtClient);
66105

@@ -74,7 +113,7 @@ export const executeDql = async (
74113
// check if we already got a result back
75114
if (response.result) {
76115
// yes - return response result immediately
77-
return createResultAndLog(response.result, 'execute_dql - Metadata:');
116+
return createResultAndLog(response.result, 'execute_dql - Metadata:', budgetLimitGB);
78117
}
79118

80119
// no result yet? we have wait and poll (this requires requestToken to be set)
@@ -92,7 +131,7 @@ export const executeDql = async (
92131
// check if we got a result from the polling endpoint
93132
if (pollResponse.result) {
94133
// yes - let's return the polled result
95-
return createResultAndLog(pollResponse.result, 'execute_dql Metadata (polled):');
134+
return createResultAndLog(pollResponse.result, 'execute_dql Metadata (polled):', budgetLimitGB);
96135
}
97136
} while (pollResponse.state === 'RUNNING' || pollResponse.state === 'NOT_STARTED');
98137

src/getDynatraceEnv.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('getDynatraceEnv', () => {
1818
dtEnvironment: env.DT_ENVIRONMENT,
1919
dtPlatformToken: env.DT_PLATFORM_TOKEN,
2020
slackConnectionId: env.SLACK_CONNECTION_ID,
21+
grailBudgetGB: 1000, // Default value
2122
});
2223
});
2324

src/getDynatraceEnv.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface DynatraceEnv {
55
dtPlatformToken?: string;
66
dtEnvironment: string;
77
slackConnectionId: string;
8+
grailBudgetGB: number;
89
}
910

1011
/**
@@ -17,6 +18,7 @@ export function getDynatraceEnv(env: NodeJS.ProcessEnv = process.env): Dynatrace
1718
const dtPlatformToken = env.DT_PLATFORM_TOKEN;
1819
const dtEnvironment = env.DT_ENVIRONMENT;
1920
const slackConnectionId = env.SLACK_CONNECTION_ID || 'fake-slack-connection-id';
21+
const grailBudgetGB = parseFloat(env.DT_GRAIL_QUERY_BUDGET_GB || '1000'); // Default to 1000 GB
2022

2123
if (!dtEnvironment) {
2224
throw new Error('Please set DT_ENVIRONMENT environment variable to your Dynatrace Platform Environment');
@@ -28,6 +30,11 @@ export function getDynatraceEnv(env: NodeJS.ProcessEnv = process.env): Dynatrace
2830
);
2931
}
3032

33+
// ToDo: Allow the case of -1 for unlimited Budget
34+
if (isNaN(grailBudgetGB) || (grailBudgetGB <= 0 && grailBudgetGB !== -1)) {
35+
throw new Error('DT_GRAIL_QUERY_BUDGET_GB must be a positive number representing GB budget for Grail queries');
36+
}
37+
3138
if (!dtEnvironment.startsWith('https://')) {
3239
throw new Error(
3340
'Please set DT_ENVIRONMENT to a valid Dynatrace Environment URL (e.g., https://<environment-id>.apps.dynatrace.com)',
@@ -40,5 +47,5 @@ export function getDynatraceEnv(env: NodeJS.ProcessEnv = process.env): Dynatrace
4047
);
4148
}
4249

43-
return { oauthClientId, oauthClientSecret, dtPlatformToken, dtEnvironment, slackConnectionId };
50+
return { oauthClientId, oauthClientSecret, dtPlatformToken, dtEnvironment, slackConnectionId, grailBudgetGB };
4451
}

0 commit comments

Comments
 (0)