From 501c982c5c6db84143d34edff4ca38b1251075eb Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Fri, 16 Jan 2026 11:56:02 +0000 Subject: [PATCH 01/17] Initial work - Added ManagedAuthClientManager, used as a wrapper to handle several environments. - environment.ts updated to use new JSON array data structure. - Updated all APIs to use ManagedAuthClientManager wrapper functions. - formatResponse functions updated to handle several data sources - Minimal updates to instructions for LLM. More to do here --- .env.template | 27 +- src/authentication/managed-auth-client.ts | 125 ++++++- src/capabilities/entities-api.ts | 291 ++++++++-------- src/capabilities/events-api.ts | 152 +++++---- src/capabilities/logs-api.ts | 102 +++--- src/capabilities/metrics-api.ts | 204 +++++------ src/capabilities/problems-api.ts | 106 +++--- src/capabilities/security-api.ts | 125 +++---- src/capabilities/slo-api.ts | 131 ++++---- src/index.ts | 390 +++++++++++++--------- src/utils/environment.ts | 59 +++- 11 files changed, 994 insertions(+), 718 deletions(-) diff --git a/.env.template b/.env.template index 0d01a9e..eb002cc 100644 --- a/.env.template +++ b/.env.template @@ -1,8 +1,23 @@ ## Dynatrace Managed Configuration -# DT_MANAGED_ENVIRONMENT=01234567-89ab-cdef-abcd-ef0123456789 -# DT_API_ENDPOINT_URL=https://abc123.dynatrace-managed.example.com:9999 -# DT_DYNATRACE_URL=https://dmz123.dynatrace-managed.example.com -# DT_MANAGED_API_TOKEN=dt0s16.SAMPLE.abcd1234 +# Proxy configuration optional (for corporate environments) +DT_ENVIRONMENT_CONFIGS='[ +{ + "dynatraceUrl": "https://dmz456.dynatrace-managed.com", + "apiEndpointUrl": "https://dmz456-ag.dynatrace-managed.com", + "environmentId": "abcdef-0124-4567-890a-bcdef0124", + "alias": "ENVIRONMENT 1", + "apiToken": "dt0s16.SAMPLE.efgh4567", + "httpProxyUrl": "", + "httpsProxyUrl": "" + }, + { + "dynatraceUrl": "https://dmz456.dynatrace-managed.com", + "apiEndpointUrl": "https://dmz456-ag.dynatrace-managed.com", + "environmentId": "abcdef-0124-4567-890a-bcdef0124", + "alias": "ENVIRONMENT 2", + "apiToken": "dt0s16.SAMPLE.efgh4567", + "httpProxyUrl": "", + "httpsProxyUrl": "" + } +]' -# Proxy configuration (optional - for corporate environments) -# HTTPS_PROXY=http://proxy.example.com:8080 diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index a21219d..41ee340 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -1,5 +1,17 @@ import axios, { AxiosInstance, AxiosProxyConfig } from 'axios'; import { logger } from '../utils/logger'; +import { ManagedEnvironmentConfig } from '../utils/environment'; + +const MANAGED_API_SCOPES = [ + 'DataExport', // Read metrics and topology + 'ReadConfig', // Read configuration and cluster version + 'ReadSyntheticData', // Read synthetic monitoring data + 'ReadLogContent', // Read log content + 'ReadEvents', // Read events + 'ReadProblems', // Read problems and root cause analysis + 'ReadSecurityProblems', // Read security problems + 'ReadSLO', // Read Service Level Objectives +]; export interface ClusterVersion { version: string; @@ -9,11 +21,67 @@ export interface ManagedAuthClientParams { apiBaseUrl: string; dashboardBaseUrl: string; apiToken: string; + alias: string; + httpProxy?: string; + httpsProxy?: string; + isValid?: boolean; +} + +export class ManagedAuthClientManager { + public readonly rawClients: ManagedAuthClient[]; + public clients: ManagedAuthClient[]; + public validAliases: string[] = []; + + constructor(managedEnvironments: ManagedEnvironmentConfig[]) { + this.rawClients = [] + this.clients = [] + this.validAliases = [] + + logger.warn('Validating Environments'); + for (let managedEnvironment of managedEnvironments) { + let newClient = new ManagedAuthClient({ + apiBaseUrl: managedEnvironment.apiUrl, + dashboardBaseUrl: managedEnvironment.dashboardUrl, + apiToken: managedEnvironment.apiToken, + alias: managedEnvironment.alias + }); + this.rawClients.push(newClient); + } + } + + async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { + let responses = [] + const selectedAliases = environments ? environments.split(';') : this.validAliases; + + for (const client of this.clients) { + if (selectedAliases.indexOf(client.alias) > -1) { + responses.push({ + "alias": client.alias, + "data": await client.makeRequest(endpoint, params) + }); + } + } + return responses; + } + + async validateClients(): Promise { + for (let client of this.rawClients) { + let validClient = await client.isConfigured() + if (validClient) { + client.isValid = true; + this.clients.push(client); + this.validAliases.push(client.alias); + } + } + } } export class ManagedAuthClient { public apiBaseUrl: string; public dashboardBaseUrl: string; + public alias: string; + public isValid: boolean; + public validationError: string; private proxy: AxiosProxyConfig | undefined; private httpClient: AxiosInstance; public readonly MINIMUM_VERSION = '1.328.0'; @@ -21,8 +89,10 @@ export class ManagedAuthClient { constructor(params: ManagedAuthClientParams) { this.apiBaseUrl = params.apiBaseUrl; this.dashboardBaseUrl = params.dashboardBaseUrl; - - this.proxy = getAxiosProxyFromEnv(); + this.alias = params.alias; + this.proxy = setAxiosProxy(params.httpProxy, params.httpsProxy); + this.isValid = params.isValid ? params.isValid : false; + this.validationError = ''; this.httpClient = axios.create({ baseURL: this.apiBaseUrl, @@ -42,13 +112,13 @@ export class ManagedAuthClient { const response = await this.httpClient.get('/api/v1/config/clusterversion'); return response.status === 200; } catch (error) { - logger.error('Failed calling /api/v1/config/clusterversion; falling back to /api/v2/metrics', { error: error }); + logger.error(`[Alias: ${this.alias}] Failed calling /api/v1/config/clusterversion; falling back to /api/v2/metrics`, { error: error }); // Fallback: try a basic API endpoint that exists in both SaaS and Managed try { const response = await this.httpClient.get('/api/v2/metrics', { params: { pageSize: 1 } }); return response.status === 200; } catch (fallbackError) { - logger.error('Failed calling /api/v2/metrics', { error: fallbackError }); + logger.error(`[Alias: ${this.alias}] Failed calling /api/v2/metrics`, { error: fallbackError }); return false; } } @@ -78,10 +148,6 @@ export class ManagedAuthClient { return true; // Equal versions } - getHttpClient(): AxiosInstance { - return this.httpClient; - } - cleanup(): void { // Destroy the axios instance to close connections if (this.httpClient) { @@ -99,21 +165,54 @@ export class ManagedAuthClient { async makeRequest(endpoint: string, params?: Record): Promise { const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; - const response = await this.httpClient.get(url, { proxy: this.proxy ?? undefined, params: params || {}, }); return response.data; } -} -export function getAxiosProxyFromEnv(): AxiosProxyConfig | undefined { - const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY; - const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY; + async isConfigured() { + // Test connection to Managed cluster + logger.info(`Testing connection to Dynatrace Managed cluster "${this.alias}": ${this.apiBaseUrl}...`); + try { + const isConnected = await this.validateConnection(); + if (!isConnected) { + //throw new Error('Connection validation failed'); // CANT CONNECT + this.validationError = "Connection validation failed: Can't connect to environment " + this.alias; + return false; + } + logger.info(`Called validateConnection`); + + const clusterVersion = await this.getClusterVersion(); + logger.info(`Connected to Managed cluster version ${clusterVersion.version}`); + + const isValidVersion = this.validateMinimumVersion(clusterVersion); + if (!isValidVersion) { + const invalidVersionMessage = `Cluster "${this.alias}" version ${clusterVersion.version} may not support all features. Minimum recommended version is ${this.MINIMUM_VERSION}` + logger.info(invalidVersionMessage); + this.validationError = invalidVersionMessage + return false; + } + return true; + } catch (error: any) { + logger.error(`[CONNECTION ERROR] Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`); + logger.error('Please verify:'); + logger.error('1. DT_MANAGED_ENVIRONMENTS is correct'); + logger.error(`2. API Token has required scopes: ${MANAGED_API_SCOPES.join(', ')}`); + logger.error('3. Network connectivity to the Managed cluster'); + this.validationError = `Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}. Please verify connection details are correct.` + return false; + } + } +} +export function setAxiosProxy(httpProxy = "", httpsProxy = ""): AxiosProxyConfig | undefined { if (httpsProxy && httpProxy) { throw Error('Cannot specify both HTTPS_PROXY and HTTP_PROXY, use only one.'); + } else if (!httpsProxy && !httpProxy) { + // No proxy configured, nothing to do + return undefined; } try { diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index d13ebc0..e30da4e 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -1,6 +1,5 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; -import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; export interface EntityQueryParams { @@ -73,42 +72,38 @@ export class EntitiesApiClient { static readonly MAX_PROPERTIES_DISPLAY = 11; static readonly MAX_MANAGEMENT_ZONES_DISPLAY = 11; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async listEntityTypes(): Promise { + async listEntityTypes(environment_aliases?: string): Promise<[]> { // Deliberately large page size; will format this concisely rather than returning all json in tool response. // Want to get all of them (with reason), otherwise trying to pull out common types won't work. const params: Record = { pageSize: 500, }; - const response = await this.authClient.makeRequest('/api/v2/entityTypes', params); - logger.debug('listEntityTypes response', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/entityTypes', params, environment_aliases); + logger.debug('listEntityTypes response', { data: responses }); + return responses; } - async getEntityTypeDetails(entityType: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/entityTypes/${encodeURIComponent(entityType)}`); - logger.debug(`getEntityTypeDetails response, entityType=${entityType}`, { data: response }); - return response; + async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/entityTypes/${encodeURIComponent(entityType)}`, undefined, environment_aliases); + logger.debug(`getEntityTypeDetails response, entityType=${entityType}`, { data: responses }); + return responses; } - async getEntityDetails(entityId: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/entities/${encodeURIComponent(entityId)}`); - logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: response }); - return response; + async getEntityDetails(entityId: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/entities/${encodeURIComponent(entityId)}`, undefined, environment_aliases); + logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: responses }); + return responses; } - async getEntityRelationships(entityId: string): Promise { - const response = await this.getEntityDetails(entityId); - - return { - entityId: response.entityId, - fromRelationships: response.fromRelationships, - toRelationships: response.toRelationships, - }; + async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { + const responses = await this.getEntityDetails(entityId, environment_aliases); +// TODO: THIS IS NOT GONNA WORK, GOTTA REFACTOR SINCE WE GETTING SEVERAL RESPONSES NOW FROM makeRequests + return responses } - async queryEntities(params: EntityQueryParams): Promise { + async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise<[]> { const queryParams = { pageSize: params.pageSize || EntitiesApiClient.API_PAGE_SIZE, entitySelector: params.entitySelector, @@ -118,87 +113,86 @@ export class EntitiesApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authClient.makeRequest('/api/v2/entities', queryParams); - logger.debug('queryEntities response: ', { queryParams: queryParams, data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/entities', queryParams, environment_aliases); + logger.debug('queryEntities response: ', { queryParams: queryParams, data: responses }); + return responses; } - formatEntityList(response: ListEntitiesResponse): string { - let totalCount = response.totalCount || -1; - let numEntities = response.entities?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numEntities; - - let result = 'Listing ' + numEntities + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entities.\n'; - if (isLimited) { - result += - 'Not showing all matching entities. Consider using more specific filters (entitySelector) to get complete results.\n'; - } - - response.entities?.forEach((entity: any) => { - // Truncate very long names for readability - let displayName = entity.displayName; - if (displayName.length > 60) { - displayName = displayName.substring(0, 57) + '...'; + formatEntityList(responses: {'alias': string, 'data': ListEntitiesResponse}[]): string { + let result = ""; + let totalNumEntities = 0; + let anyLimited = false + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numEntities = response.data.entities?.length || 0; + totalNumEntities += numEntities; + let isLimited = totalCount != 0 - 1 && totalCount > numEntities; + + result += 'Listing ' + numEntities + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entities from '+ response.alias +'.\n'; + if (isLimited) { + result += + 'Not showing all matching entities. Consider using more specific filters (entitySelector) to get complete results.\n'; + anyLimited = true; } - result += `entityId: ${entity.entityId}\n`; - result += ` type: ${entity.type || entity.entityType}\n`; - result += ` displayName: ${displayName}\n`; - - if (entity.tags && entity.tags.length > 0) { - const tags = entity.tags - .slice(0, EntitiesApiClient.MAX_TAGS_DISPLAY) - .map((tag: any) => (tag.value ? `${tag.key}:${tag.value}` : tag.key)) - .join(', '); - result += ` tags: ${tags}${entity.tags.length > EntitiesApiClient.MAX_TAGS_DISPLAY ? ` (+${entity.tags.length - EntitiesApiClient.MAX_TAGS_DISPLAY} more)` : ''}\n`; - } - - if (entity.properties && Object.keys(entity.properties).length > 0) { - const props = Object.entries(entity.properties) - .slice(0, EntitiesApiClient.MAX_PROPERTIES_DISPLAY) - .map(([k, v]) => `${k}=${v}`) - .join(', '); - result += ` properties: ${props}${Object.keys(entity.properties).length > EntitiesApiClient.MAX_PROPERTIES_DISPLAY ? ` (+${Object.keys(entity.properties).length - EntitiesApiClient.MAX_PROPERTIES_DISPLAY} more)` : ''}\n`; - } - - if (entity.managementZones && entity.managementZones.length > 0) { - const zones = entity.managementZones - .slice(0, EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) - .map((zone: any) => zone.name || zone.id || zone) - .join(', '); - result += ` Management Zones: ${zones}${entity.managementZones.length > EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${entity.managementZones.length - EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; - } - - result += '\n'; - }); + response.data.entities?.forEach((entity: any) => { + // Truncate very long names for readability + let displayName = entity.displayName; + if (displayName.length > 60) { + displayName = displayName.substring(0, 57) + '...'; + } + + result += `entityId: ${entity.entityId}\n`; + result += ` type: ${entity.type || entity.entityType}\n`; + result += ` displayName: ${displayName}\n`; + + if (entity.tags && entity.tags.length > 0) { + const tags = entity.tags + .slice(0, EntitiesApiClient.MAX_TAGS_DISPLAY) + .map((tag: any) => (tag.value ? `${tag.key}:${tag.value}` : tag.key)) + .join(', '); + result += ` tags: ${tags}${entity.tags.length > EntitiesApiClient.MAX_TAGS_DISPLAY ? ` (+${entity.tags.length - EntitiesApiClient.MAX_TAGS_DISPLAY} more)` : ''}\n`; + } + + if (entity.properties && Object.keys(entity.properties).length > 0) { + const props = Object.entries(entity.properties) + .slice(0, EntitiesApiClient.MAX_PROPERTIES_DISPLAY) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + result += ` properties: ${props}${Object.keys(entity.properties).length > EntitiesApiClient.MAX_PROPERTIES_DISPLAY ? ` (+${Object.keys(entity.properties).length - EntitiesApiClient.MAX_PROPERTIES_DISPLAY} more)` : ''}\n`; + } + + if (entity.managementZones && entity.managementZones.length > 0) { + const zones = entity.managementZones + .slice(0, EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) + .map((zone: any) => zone.name || zone.id || zone) + .join(', '); + result += ` Management Zones: ${zones}${entity.managementZones.length > EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${entity.managementZones.length - EntitiesApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; + } + result += '\n'; + }); + } result += '\n' + 'Next Steps:\n' + - (numEntities == 0 + (totalNumEntities == 0 ? '* Verify that the filters such as entitySelector were correct, and search again with different filters.\n' : '') + - (isLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + + (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + '* If the user is interested in a specific entity, use the get_entity_details tool. ' + 'Use the entityId (UUID) for detailed analysis\n' + '* If this has returned the entities that the user wanted, consider using the same entitySelector in subsequent calls such as to list_problems tool if that has not already been done.\n' + 'Use the entityId (UUID) for detailed analysis\n' + - '* Suggest to the user that they view the entities in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/` + + '* Suggest to the user that they view the entities in the Dynatrace UI.' + '\n'; return result; } - formatEntityTypeList(response: ListEntityTypesResponse): string { - let totalCount = response.totalCount || -1; - let numTypes = response.types?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numTypes; - - let entityTypes = response.types as any[]; - - // Produce a simple strong list of all the types from the json (excluding all details). - // Also call out some common types (that are available). + formatEntityTypeList(responses: {'alias': string, 'data': ListEntityTypesResponse}[]): string { + let result = ""; + let totalNumTypes = 0; const commonTypes = [ 'SERVICE', 'PROCESS_GROUP', @@ -209,93 +203,104 @@ export class EntitiesApiClient { 'AWS_LAMBDA_FUNCTION', 'AZURE_WEB_APP', ]; - let conciseList = ''; - let availableCommonTypes: string[] = []; - entityTypes?.forEach((entityType: any) => { - conciseList += `${entityType?.type}`; - if (entityType.displayName && entityType.displayName !== entityType.type) { - conciseList += ` - ${entityType.displayName}`; - } - conciseList += '\n'; - if (commonTypes.includes(entityType)) { - availableCommonTypes.push(entityType); + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numTypes = response.data.types?.length || 0; + totalNumTypes += numTypes + let isLimited = totalCount != 0 - 1 && totalCount > numTypes; + + let entityTypes = response.data.types as any[]; + let conciseList = ''; + let availableCommonTypes: string[] = []; + + result += 'Listing ' + numTypes + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entity types.\n'; + if (isLimited) { + result += 'Not showing all matching entity types as there are too many.\n'; } - }); - let result = 'Listing ' + numTypes + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entity types.\n'; - if (isLimited) { - result += 'Not showing all matching entity types as there are too many.\n'; + if (availableCommonTypes.length > 0) { + result += '\n' + `Common entity types include: ${availableCommonTypes}\n`; + } + entityTypes?.forEach((entityType: any) => { + conciseList += `${entityType?.type}`; + if (entityType.displayName && entityType.displayName !== entityType.type) { + conciseList += ` - ${entityType.displayName}`; + } + conciseList += '\n'; + + if (commonTypes.includes(entityType)) { + availableCommonTypes.push(entityType); + } + }); + result += '\n' + conciseList } - if (availableCommonTypes.length > 0) { - result += '\n' + `Common entity types include: ${availableCommonTypes}\n`; - } + // Produce a simple strong list of all the types from the json (excluding all details). + // Also call out some common types (that are available). result += - '\n' + - conciseList + - '\n' + - '\n' + 'Next Steps:\n' + '* To get details of a particular entity type, use the get_entity_type_details tool, passing in the type name\n' + - '* For subsequent user queries, consider using the entity type in the the entitySelector parameter like "type(HOST)" or "type(SERVICE)".\n'; - '* Suggest to the user that they look in the Dynatrace UI at ' + `${this.authClient.dashboardBaseUrl}/` + '\n'; + '* For subsequent user queries, consider using the entity type in the the entitySelector parameter like "type(HOST)" or "type(SERVICE)".\n' + + '* Suggest to the user that they look in the Dynatrace UI \n'; return result; } - formatEntityTypeDetails(response: any): string { - let result = - `Entity type details in the following json:\n` + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + + formatEntityTypeDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Entity type details from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + '* To find entities of this type, use discover_entities tool, using the type in the entitySelector such as type("HOST") or type("SERVICE")\n'; - return result; } - formatEntityDetails(response: any): string { - let result = - `Entity details in the following json:\n` + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + + formatEntityDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Entity details from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + '* Use list_problems or list_events tools with the same entitySelector to check for relates issues and events.\n' + - '* Suggest to the user that they view the entity in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/entity/, using the entityId in the URL` + + '* Suggest to the user that they view the entity in the Dynatrace UI using the entityId in the URL' + '\n'; - return result; } - formatEntityRelationships(response: GetEntityRelationshipsResponse): string { - const from = response.fromRelationships; - const to = response.toRelationships; - const numFrom = this.countRelationships(from); - const numTo = this.countRelationships(to); + formatEntityRelationships(responses: {'alias': string, 'data': GetEntityRelationshipsResponse}[]): string { + let result = ''; + for (const response of responses) { + const from = response.data.fromRelationships; + const to = response.data.toRelationships; + const numFrom = this.countRelationships(from); + const numTo = this.countRelationships(to); + + if (numFrom == 0 && numTo == 0) { + result += `No relationships found for entity ${response.data.entityId} in environment ${response.alias}\n`; + } - if (numFrom == 0 && numTo == 0) { - return `No relationships found for entity ${response.entityId}.\n`; - } + result += `Relationships found for entity ${response.data.entityId} in environment ${response.alias}:\n`; - let result = ''; - if (numFrom > 0) { - result += `Found ${numFrom} fromRelationships:\n`; - result += `* ${JSON.stringify(from)}\n`; - } - if (numTo > 0) { - result += `Found ${numTo} toRelationships:\n`; - result += `* ${JSON.stringify(to)}\n`; + if (numFrom > 0) { + result += `Found ${numFrom} fromRelationships:\n`; + result += `* ${JSON.stringify(from)}\n`; + } + if (numTo > 0) { + result += `Found ${numTo} toRelationships:\n`; + result += `* ${JSON.stringify(to)}\n`; + } } result += 'Next Steps:\n' + '* Use get_entity_details tool to get more details of this entity, or of entities that it has a relationship to/from.\n' + '* Use list_problems or list_events tools with the same entitySelector by entityId to check for related issues and events.\n' + - '* Suggest to the user that they view the entity in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/entity/, using the entityId in the URL` + + '* Suggest to the user that they view the entity in the Dynatrace UI using the entityId in the URL' + '\n'; return result; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index fe93149..c044453 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -31,20 +31,14 @@ export interface Event { customProperties?: Record; } -export interface EventSearchResult { - events: Event[]; - totalCount: number; - nextPageKey?: string; -} - export class EventsApiClient { static readonly API_PAGE_SIZE = 100; static readonly MAX_PROPERTIES_DISPLAY = 11; static readonly MAX_MANAGEMENT_ZONES_DISPLAY = 11; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async queryEvents(params: EventQueryParams): Promise { + async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise<[]> { const queryParams = { from: params.from, to: params.to, @@ -53,98 +47,102 @@ export class EventsApiClient { ...(params.entitySelector && { entitySelector: params.entitySelector }), }; - const response = await this.authClient.makeRequest('/api/v2/events', queryParams); - logger.debug('queryEvents response: ', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/events', queryParams, environment_aliases); + logger.debug('queryEvents response: ', { data: responses }); + return responses; } - async getEventDetails(eventId: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/events/${encodeURIComponent(eventId)}`); - logger.debug('getEventDetails response: ', { data: response }); - return response; + async getEventDetails(eventId: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/events/${encodeURIComponent(eventId)}`, undefined, environment_aliases); + logger.debug('getEventDetails response: ', { data: responses }); + return responses; } - formatList(response: ListEventsResponse): string { - let totalCount = response.totalCount || -1; - let numEvents = response.events?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numEvents; + formatList(responses: {'alias': string, 'data': ListEventsResponse}[]): string { + let result = ""; + let totalNumEvents = 0; + let anyLimited = false - let result = 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events.\n'; + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numEvents = response.data.events?.length || 0; + totalNumEvents += numEvents; + let isLimited = totalCount != 0 - 1 && totalCount > numEvents; - if (isLimited) { - result += - 'Not showing all matching problems. Consider using more specific filters (eventType, entitySelector) to get complete results.\n'; - } + result += 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events from ' + response.alias + '.\n\n'; - response.events?.forEach((event: any) => { - result += `eventId: ${event.eventId}\n`; - result += ` eventType: ${event.eventType}\n`; - result += ` status: ${event.status}\n`; - result += ` title: ${event.title}\n`; - if (event.description) { - result += ` ${event.description}\n`; - } - if (event.startTime && event.startTime !== -1) { - result += `. startTime: ${formatTimestamp(event.startTime)}`; - } - if (event.endTime && event.endTime !== -1) { - result += `. endTime: ${formatTimestamp(event.endTime)}`; - } - // High Priority: Add severity and impact levels - if (event.severityLevel) { - result += `severityLevel: ${event.severityLevel}\n`; - } - if (event.impactLevel) { - result += `impactLevel: ${event.impactLevel}\n`; - } - if (event.properties && Object.keys(event.properties).length > 0) { - const props = Object.entries(event.properties) - .slice(0, EventsApiClient.MAX_PROPERTIES_DISPLAY) - .map(([k, v]) => `${k}=${v}`) - .join(', '); - result += `Properties: ${props}${Object.keys(event.properties).length > EventsApiClient.MAX_PROPERTIES_DISPLAY ? ` (+${Object.keys(event.properties).length - EventsApiClient.MAX_PROPERTIES_DISPLAY} more)` : ''}\n`; + if (isLimited) { + result += + 'Not showing all matching problems. Consider using more specific filters (eventType, entitySelector) to get complete results.\n'; + anyLimited = true; } - if (event.managementZones && event.managementZones.length > 0) { - const zones = event.managementZones - .slice(0, EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) - .map((zone: any) => zone.name || zone.id || zone) - .join(', '); - result += `Management Zones: ${zones}${event.managementZones.length > EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${event.managementZones.length - EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; - } - - result += '\n'; - }); + response.data.events?.forEach((event: any) => { + result += `eventId: ${event.eventId}\n`; + result += ` eventType: ${event.eventType}\n`; + result += ` status: ${event.status}\n`; + result += ` title: ${event.title}\n`; + if (event.description) { + result += ` ${event.description}\n`; + } + if (event.startTime && event.startTime !== -1) { + result += `. startTime: ${formatTimestamp(event.startTime)}`; + } + if (event.endTime && event.endTime !== -1) { + result += `. endTime: ${formatTimestamp(event.endTime)}`; + } + // High Priority: Add severity and impact levels + if (event.severityLevel) { + result += `severityLevel: ${event.severityLevel}\n`; + } + if (event.impactLevel) { + result += `impactLevel: ${event.impactLevel}\n`; + } + if (event.properties && Object.keys(event.properties).length > 0) { + const props = Object.entries(event.properties) + .slice(0, EventsApiClient.MAX_PROPERTIES_DISPLAY) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + result += `Properties: ${props}${Object.keys(event.properties).length > EventsApiClient.MAX_PROPERTIES_DISPLAY ? ` (+${Object.keys(event.properties).length - EventsApiClient.MAX_PROPERTIES_DISPLAY} more)` : ''}\n`; + } + if (event.managementZones && event.managementZones.length > 0) { + const zones = event.managementZones + .slice(0, EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) + .map((zone: any) => zone.name || zone.id || zone) + .join(', '); + result += `Management Zones: ${zones}${event.managementZones.length > EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${event.managementZones.length - EventsApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; + } + + result += '\n'; + }); + } result += '\n' + 'Next Steps:\n' + - (numEvents == 0 + (totalNumEvents == 0 ? '* Try broader search terms or expand the time range; if using an entitySelector, check with discover_entities which entities that matches.\n' : '') + - (isLimited + (anyLimited ? '* Use more restrictive filters, such as a narrower time range or more specific search terms.\n' : '') + - (numEvents > 0 + (totalNumEvents > 0 ? '* If the user is interested in a specific event, use the get_event_details tool. Use the event id for this.\n ' : '') + - '* Suggest to the user that they use the Dynatrace UI to view events at ' + - `${this.authClient.dashboardBaseUrl}/ by navigating to the relevant entity\n` + + '* Suggest to the user that they use the Dynatrace UI to view events by navigating to the relevant entity\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; return result; } - formatDetails(response: any): string { - let result = - `Event details in the following json:\n` + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + - '* Suggest to the user that they explore this further in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/` + - '\n' + + formatDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Event details from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + + '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; - return result; } } diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 30e4c30..8f159ae 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -8,6 +8,7 @@ export interface LogQueryParams { to: string; limit?: number; sort?: string; + environment_aliases?: ''; } export interface ListLogsResponse { @@ -28,19 +29,12 @@ export interface LogEntry { [key: string]: any; } -export interface LogSearchResult { - results: LogEntry[]; - totalCount: number; - nextPageKey?: string; - sliceSize?: number; -} - export class LogsApiClient { private static readonly API_PAGE_SIZE = 1000; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async queryLogs(params: LogQueryParams): Promise { + async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise<[]> { const queryParams = { query: params.query || '', from: params.from, @@ -49,62 +43,70 @@ export class LogsApiClient { sort: params.sort || '-timestamp', }; - const response = await this.authClient.makeRequest('/api/v2/logs/search', queryParams); - logger.debug('queryLogs response', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/logs/search', queryParams, environment_aliases); + logger.debug('queryLogs response: ', { data: responses }); + return responses; } - formatList(response: ListLogsResponse): string { - let numLogs = response.results?.length || 0; - let isLimited = response.nextSliceKey != undefined; + formatList(responses: {'alias': string, 'data': ListLogsResponse}[]): string { + let result = ""; + let totalNumLogs = 0; + let anyLimited = false + for (const response of responses) { + let numLogs = response.data.results?.length || 0; + totalNumLogs = totalNumLogs + numLogs; + let isLimited = response.data.nextSliceKey != undefined; - let result = 'Listing ' + numLogs + ' log records.\n'; + result += 'Listing ' + numLogs + ' log records from ' + response.alias + '.\n\n'; - if (isLimited) { - result += 'Results likely restricted due to maximum response size, consider using a more specific filter.'; - } + if (isLimited) { + result += 'Results likely restricted due to maximum response size, consider using a more specific filter.'; + anyLimited = true; + } - response.results?.forEach((log: any) => { - const timestamp = formatTimestamp(log.timestamp); - // Enhanced level detection for better error identification - let level = log.additionalColumns?.loglevel?.[0] || log.status || log.log_level || 'NONE'; + response.data.results?.forEach((log: any) => { + const timestamp = formatTimestamp(log.timestamp); + // Enhanced level detection for better error identification + let level = log.additionalColumns?.loglevel?.[0] || log.status || log.log_level || 'NONE'; - result += `**${timestamp}** [${level}]\n`; - result += `${log.content}\n`; + result += `**${timestamp}** [${level}]\n`; + result += `${log.content}\n`; - // Medium Priority: Show eventType if available - if (log.eventType) { - result += `Event Type: ${log.eventType}\n`; - } + // Medium Priority: Show eventType if available + if (log.eventType) { + result += `Event Type: ${log.eventType}\n`; + } - const metadata: string[] = []; - if (log.additionalColumns) { - Object.entries(log.additionalColumns).forEach(([key, value]) => { - if (Array.isArray(value) && value.length > 0) { - metadata.push(`${key}: ${value[0]}`); + const metadata: string[] = []; + if (log.additionalColumns) { + Object.entries(log.additionalColumns).forEach(([key, value]) => { + if (Array.isArray(value) && value.length > 0) { + metadata.push(`${key}: ${value[0]}`); + } + }); + } + if (metadata.length > 0) { + // Medium Priority: Increased from 5 to 8 metadata fields + result += `_${metadata.slice(0, 8).join(', ')}_\n`; + if (metadata.length > 8) { + result += `_... and ${metadata.length - 8} more metadata fields_\n`; } - }); - } - if (metadata.length > 0) { - // Medium Priority: Increased from 5 to 8 metadata fields - result += `_${metadata.slice(0, 8).join(', ')}_\n`; - if (metadata.length > 8) { - result += `_... and ${metadata.length - 8} more metadata fields_\n`; } - } - result += '\n'; - }); + result += '\n\n'; + }); + } + - result += + result = result + '\n' + 'Next Steps:\n' + - (numLogs == 0 ? '* Try broader search terms or expand the time range\n' : '') + - (isLimited + (totalNumLogs == 0 ? '* Try broader search terms or expand the time range\n' : '') + + (anyLimited ? '* Use more restrictive filters, such as a narrower time range or more specific search terms\n' : '') + - (numLogs > 1 ? '* Use sort (e.g. with "-timestamp" for newest logs first).\n' : '') + + (totalNumLogs > 1 ? '* Use sort (e.g. with "-timestamp" for newest logs first).\n' : '') + '* Suggest to the user that they use the Dynatrace UI to view metric data at ' + - `${this.authClient.dashboardBaseUrl}/ui/log-monitoring` + + //`${this.authManager.dashboardBaseUrl}/ui/log-monitoring` + '\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so\n'; diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index d58b315..68a0aa2 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -1,5 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; -import { formatTimestamp } from '../utils/date-formatter'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; export interface MetricListParams { @@ -63,9 +62,9 @@ export class MetricsApiClient { static readonly API_PAGE_SIZE = 500; static readonly MAX_DIMENSIONS_DISPLAY = 11; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async listAvailableMetrics(params: MetricListParams = {}): Promise { + async listAvailableMetrics(params: MetricListParams = {}, environment_aliases?: string): Promise<[]> { const queryParams = { pageSize: params.pageSize || MetricsApiClient.API_PAGE_SIZE, ...(params.entitySelector && { entitySelector: params.entitySelector }), @@ -76,18 +75,18 @@ export class MetricsApiClient { ...(params.nextPageKey && { nextPageKey: params.nextPageKey }), }; - const response = await this.authClient.makeRequest('/api/v2/metrics', queryParams); - logger.debug('listAvailableMetrics response', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/metrics', queryParams, environment_aliases); + logger.debug(`listAvailableMetrics responses from ${this.authManager.clients.length} sources: `, { data: responses }); + return responses; } - async getMetricDetails(metricId: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/metrics/${encodeURIComponent(metricId)}`); + async getMetricDetails(metricId: string, environment_aliases?: string): Promise<[]> { + const response = await this.authManager.makeRequests(`/api/v2/metrics/${encodeURIComponent(metricId)}`, undefined, environment_aliases); logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: response }); return response; } - async queryMetrics(params: MetricQueryParams): Promise { + async queryMetrics(params: MetricQueryParams, environment_aliases?: string): Promise<[]> { const queryParams = { metricSelector: params.metricSelector, resolution: params.resolution || 'Inf', @@ -96,132 +95,143 @@ export class MetricsApiClient { ...(params.entitySelector && { entitySelector: params.entitySelector }), }; - const response = await this.authClient.makeRequest('/api/v2/metrics/query', queryParams); + const response = await this.authManager.makeRequests('/api/v2/metrics/query', queryParams, environment_aliases); logger.debug(`queryMetrics response, params=${JSON.stringify(params)}`, { data: response }); return response; } - formatMetricList(response: ListMetricsResponse): string { - let totalCount = response?.totalCount || -1; - let numMetrics = response?.metrics?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; - - let result = 'Listing ' + numMetrics + (totalCount == -1 ? '' : ' of ' + totalCount) + ' metrics.\n'; + formatMetricList(responses: {'alias': string, 'data': ListMetricsResponse}[]): string { + let result = ""; + let totalNumMetrics = 0; + let anyLimited = false - if (isLimited) { - result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; - } + for (const response of responses) { + let totalCount = response?.data.totalCount || -1; + let numMetrics = response?.data.metrics?.length || 0; + totalNumMetrics += numMetrics; + let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; - response.metrics?.forEach((metric: any) => { - result += `metricId: ${metric.metricId}\n`; - if (metric.displayName) result += ` displayName: ${metric.displayName}\n`; - if (metric.description) result += ` description: ${metric.description}\n`; - if (metric.unit) result += ` unit: ${metric.unit}\n`; + result += 'Listing ' + numMetrics + (totalCount == -1 ? '' : ' of ' + totalCount) + ' metrics from ' + response.alias + '.\n\n'; - // High Priority: Add aggregation types - if (metric.aggregationTypes && metric.aggregationTypes.length > 0) { - result += ` aggregationTypes: ${metric.aggregationTypes.join(', ')}\n`; + if (isLimited) { + result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; + anyLimited = true; } - // High Priority: Add dimension definitions (key for filtering) - if (metric.dimensionDefinitions && metric.dimensionDefinitions.length > 0) { - const dims = metric.dimensionDefinitions - .slice(0, MetricsApiClient.MAX_DIMENSIONS_DISPLAY) - .map((dim: any) => dim.name) - .join(', '); - result += ` dimensions: ${dims}${metric.dimensionDefinitions.length > MetricsApiClient.MAX_DIMENSIONS_DISPLAY ? ` (+${metric.dimensionDefinitions.length - MetricsApiClient.MAX_DIMENSIONS_DISPLAY} more)` : ''}\n`; - } + response.data.metrics?.forEach((metric: any) => { + result += `metricId: ${metric.metricId}\n`; + if (metric.displayName) result += ` displayName: ${metric.displayName}\n`; + if (metric.description) result += ` description: ${metric.description}\n`; + if (metric.unit) result += ` unit: ${metric.unit}\n`; - result += '\n'; - }); + // High Priority: Add aggregation types + if (metric.aggregationTypes && metric.aggregationTypes.length > 0) { + result += ` aggregationTypes: ${metric.aggregationTypes.join(', ')}\n`; + } + + // High Priority: Add dimension definitions (key for filtering) + if (metric.dimensionDefinitions && metric.dimensionDefinitions.length > 0) { + const dims = metric.dimensionDefinitions + .slice(0, MetricsApiClient.MAX_DIMENSIONS_DISPLAY) + .map((dim: any) => dim.name) + .join(', '); + result += ` dimensions: ${dims}${metric.dimensionDefinitions.length > MetricsApiClient.MAX_DIMENSIONS_DISPLAY ? ` (+${metric.dimensionDefinitions.length - MetricsApiClient.MAX_DIMENSIONS_DISPLAY} more)` : ''}\n`; + } + result += '\n'; + }); + } result += '\n' + 'Next Steps:\n' + - (numMetrics == 0 ? '* Verify that the filters were correct, and search again with different filters\n' : '') + - (isLimited + (totalNumMetrics == 0 ? '* Verify that the filters were correct, and search again with different filters\n' : '') + + (anyLimited ? '* To filter the list of metrics, use list_available_metrics tool with sorting and with specific filters (e.g. entitySelector and searchText).\n' : '') + '* Use get_metric_details tool for detailed information of a particular metric.\n' + - '* Suggest to the user that they use the Dynatrace UI to:\n' + - ` * Browse the list of metrics at ${this.authClient.dashboardBaseUrl}/ui/metrics' + '\n` + - ` * View metric data at ${this.authClient.dashboardBaseUrl}/ui/data-explorer' + '\n`; + '* Suggest to the user that they use the Dynatrace UI to:\n' + //` * Browse the list of metrics at ${this.authClient.dashboardBaseUrl}/ui/metrics' + '\n` + + //` * View metric data at ${this.authClient.dashboardBaseUrl}/ui/data-explorer' + '\n`; return result; } - formatMetricDetails(response: any): string { - let result = - 'Details of metric in the following json.\n' + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + - '* Suggest to the user that they use the Dynatrace UI to view metric data at ' + - `${this.authClient.dashboardBaseUrl}/ui/data-explorer`; + formatMetricDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Details of metric from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + //`${this.authClient.dashboardBaseUrl}/ui/data-explorer`; + } + result += 'Next Steps:\n* Suggest to the user that they use the Dynatrace UI to view metric data'; return result; } - formatMetricData(response: MetricDataResponse): string { - let resolution = response.resolution; - let isNonEmpty = - response.result && response.result.length > 0 && response.result[0].data && response.result[0].data.length > 0; - - let result = 'Listing data series'; - - if (!isNonEmpty) { - result += ' (no datapoints found)\n'; - } else { - result += ', each with timestamped datapoints of the form timestamp: value, timestamp: value, ...\n'; - } + formatMetricData(responses: {'alias': string, 'data': MetricDataResponse}[]): string { + let result = ""; + let allEmpty = true; + for (const response of responses) { + let resolution = response.data.resolution; + let isNonEmpty = + response.data.result && response.data.result.length > 0 && response.data.result[0].data && response.data.result[0].data.length > 0; + + result += 'Listing data series from environment ' + response.alias; + + if (!isNonEmpty) { + result += ' (no datapoints found)\n'; + } else { + result += ', each with timestamped datapoints of the form timestamp: value, timestamp: value, ...\n'; + allEmpty = false; + } - if (resolution) { - result += `resolution: ${resolution}\n`; - } + if (resolution) { + result += `resolution: ${resolution}\n`; + } - response.result?.forEach((metric: any) => { - let numDataseries = metric.data?.length || 0; + response.data.result?.forEach((metric: any) => { + let numDataseries = metric.data?.length || 0; - result += 'Listing ' + numDataseries + ' data series\n'; - result += `metricId: ${metric.metricId}\n`; + result += 'Listing ' + numDataseries + ' data series\n'; + result += `metricId: ${metric.metricId}\n`; - metric.data?.forEach((series: any) => { - let timestamps = series.timestamps || []; - let values = series.values || []; + metric.data?.forEach((series: any) => { + let timestamps = series.timestamps || []; + let values = series.values || []; - if (series.dimensionMap) { - result += ` dimensionData: ${JSON.stringify(series.dimensionMap)}\n`; - } - if (series.dimensions) { - result += ` dimensions: ${JSON.stringify(series.dimensions)}\n`; - } - if (timestamps.length > 0) { - let formattedDatapoints = ''; - let numDatapoints = Math.min(timestamps.length, values.length); - for (let i = 0; i < Math.min(numDatapoints, MetricsApiClient.MAX_DATA_POINTS); i++) { - formattedDatapoints += `${timestamps[i]}: ${values[i]}, `; + if (series.dimensionMap) { + result += ` dimensionData: ${JSON.stringify(series.dimensionMap)}\n`; } - result += ` timestamped datapoints: ${formattedDatapoints}`; - if (numDatapoints > MetricsApiClient.MAX_DATA_POINTS) { - result += ` and ${numDatapoints - MetricsApiClient.MAX_DATA_POINTS} more data points`; + if (series.dimensions) { + result += ` dimensions: ${JSON.stringify(series.dimensions)}\n`; } - result += '\n'; - } else { - result += ` No datapoints\n`; - } - result += '\n'; + if (timestamps.length > 0) { + let formattedDatapoints = ''; + let numDatapoints = Math.min(timestamps.length, values.length); + for (let i = 0; i < Math.min(numDatapoints, MetricsApiClient.MAX_DATA_POINTS); i++) { + formattedDatapoints += `${timestamps[i]}: ${values[i]}, `; + } + result += ` timestamped datapoints: ${formattedDatapoints}`; + if (numDatapoints > MetricsApiClient.MAX_DATA_POINTS) { + result += ` and ${numDatapoints - MetricsApiClient.MAX_DATA_POINTS} more data points`; + } + result += '\n'; + } else { + result += ` No datapoints\n`; + } + result += '\n\n'; + }); }); - }); + } + result += '\n' + 'Next Steps:\n' + - (!isNonEmpty + (allEmpty ? '* Verify that the filters were correct, and search again with different filters\n' : '* Use query_metrics_data with more specific filters, such as a narrower time range with to and from, and an entitySelector\n') + - '* Suggest to the user that they use the Dynatrace UI to view metric data at ' + - `${this.authClient.dashboardBaseUrl}/ui/data-explorer` + - '\n'; + '* Suggest to the user that they use the Dynatrace UI to view metric data'; return result; } diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index a18889c..25f5300 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -71,9 +71,9 @@ export interface Evidence { export class ProblemsApiClient { static readonly API_PAGE_SIZE = 50; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async listProblems(params: ProblemQueryParams = {}): Promise { + async listProblems(params: ProblemQueryParams = {}, environment_aliases? : string): Promise<[]> { const queryParams = { pageSize: params.pageSize || ProblemsApiClient.API_PAGE_SIZE, ...(params.from && { from: params.from }), @@ -84,78 +84,80 @@ export class ProblemsApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authClient.makeRequest('/api/v2/problems', queryParams); + const response = await this.authManager.makeRequests('/api/v2/problems', queryParams, environment_aliases); logger.debug('listProblems response', { data: response }); return response; } - async getProblemDetails(problemId: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/problems/${encodeURIComponent(problemId)}`); + async getProblemDetails(problemId: string, environment_aliases? : string): Promise { + const response = await this.authManager.makeRequests(`/api/v2/problems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); logger.debug('getProblemDetails response', { data: response }); return response; } - formatList(response: ListProblemResponse): string { - let totalCount = response.totalCount || -1; - let numProblems = response.problems?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numProblems; - - let result = 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems.\n'; + formatList(responses: {'alias': string, 'data': ListProblemResponse}[]): string { + let result = ""; + let totalNumProblems = 0; + let anyLimited = false + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numProblems = response.data.problems?.length || 0; + totalNumProblems += numProblems; + let isLimited = totalCount != 0 - 1 && totalCount > numProblems; + + result += 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems from ' + response.alias + '.\n\n'; + + if (isLimited) { + result += + 'Not showing all matching problems. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\n'; + anyLimited = true; + } - if (isLimited) { - result += - 'Not showing all matching problems. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\n'; + response.data.problems?.forEach((problem: any) => { + result += `problemId: ${problem.problemId}\n`; + result += ` displayId: ${problem.displayId}\n`; + result += ` title: ${problem.title}\n`; + result += ` status: ${problem.status}\n`; + result += ` severityLevel: ${problem.severityLevel}\n`; + result += ` impactLevel: ${problem.impactLevel}\n`; + result += ` severityLevel: ${problem.severityLevel}\n`; + if (problem.startTime) { + result += ` startTime: ${formatTimestamp(problem.startTime)}\n`; + } + if (problem.endTime && problem.endTime != -1) { + result += ` endTime: ${formatTimestamp(problem.endTime)}\n`; + } + result += '\n'; + }); } - response.problems?.forEach((problem: any) => { - result += `problemId: ${problem.problemId}\n`; - result += ` displayId: ${problem.displayId}\n`; - result += ` title: ${problem.title}\n`; - result += ` status: ${problem.status}\n`; - result += ` severityLevel: ${problem.severityLevel}\n`; - result += ` impactLevel: ${problem.impactLevel}\n`; - result += ` severityLevel: ${problem.severityLevel}\n`; - if (problem.startTime) { - result += ` startTime: ${formatTimestamp(problem.startTime)}\n`; - } - if (problem.endTime && problem.endTime != -1) { - result += ` endTime: ${formatTimestamp(problem.endTime)}\n`; - } - result += '\n'; - }); - result += '\n' + 'Next Steps:\n' + - (numProblems == 0 + (totalNumProblems == 0 ? '* Verify that the filters such as entitySelector and time range were correct, and search again with different filters.\n' : '') + - (isLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + - (numProblems > 1 ? '* Use sort (e.g. with "+status" for open problems first).\n' : '') + - '* Suggest to the user that they view the problems in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/problems` + - '\n' + - '* If the user is interested in a specific problem, use the get_problem_details tool. ' + + (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + + (totalNumProblems > 1 ? '* Use sort (e.g. with "+status" for open problems first).\n' : '') + + '* Suggest to the user that they view the problems in the Dynatrace UI.' + + '\n' + '* If the user is interested in a specific problem, use the get_problem_details tool. ' + 'Use the problemId (UUID) for detailed analysis.\n'; return result; } - formatDetails(response: any): string { - let result = - 'Details of problem in the following json.\n' + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + - '* If the affectedEntities is not empty, suggest to the user that they could investigate those entities further. For example with:\n'; - (" * list_events tool, using the affected entity's entityId in the entitySelector.\n"); - (' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n'); - '* Suggest to the user that they view the problem in the Dynatrace UI ' + - `${this.authClient.dashboardBaseUrl}/#problems/problemdetails;pid=, using the problemId in the URL` + - '\n'; - + formatDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Details of problem from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + + '* If the affectedEntities is not empty, suggest to the user that they could investigate those entities further. For example with:\n' + + (" * list_events tool, using the affected entity's entityId in the entitySelector.\n") + + (' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n'); return result; } } diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 47ee1fe..5581988 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -79,9 +79,9 @@ export class SecurityApiClient { static readonly API_PAGE_SIZE = 200; static readonly MAX_CVES_DISPLAY = 11; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async listSecurityProblems(params: SecurityProblemQueryParams = {}): Promise { + async listSecurityProblems(params: SecurityProblemQueryParams = {}, environment_aliases?: string): Promise<[]> { const queryParams = { pageSize: params.pageSize || SecurityApiClient.API_PAGE_SIZE, ...(params.riskLevel && { riskLevel: params.riskLevel }), @@ -92,84 +92,91 @@ export class SecurityApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authClient.makeRequest('/api/v2/securityProblems', queryParams); - logger.debug('listSecurityProblems response', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/securityProblems', queryParams, environment_aliases); + logger.debug('listSecurityProblems response', { data: responses }); + return responses; } - async getSecurityProblemDetails(problemId: string): Promise { - const response = await this.authClient.makeRequest(`/api/v2/securityProblems/${encodeURIComponent(problemId)}`); - logger.debug('getSecurityProblemDetails response', { data: response }); - return response; + async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/securityProblems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); + logger.debug('getSecurityProblemDetails response', { data: responses }); + return responses; } - formatList(response: ListSecurityProblemsResponse): string { - let totalCount = response.totalCount || -1; - let numProblems = response.securityProblems?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numProblems; + formatList(responses: {'alias': string, 'data': ListSecurityProblemsResponse}[]): string { + let result = ""; + let totalNumProblems = 0; + let anyLimited = false + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numProblems = response.data.securityProblems?.length || 0; + totalNumProblems += numProblems; + let isLimited = totalCount != 0 - 1 && totalCount > numProblems; - let result = - 'Listing ' + - numProblems + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' security vulnerabilities in the following json.\n'; - - if (isLimited) { result += - 'Not showing all matching vulnerabilities. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\n'; - } + 'Listing ' + + numProblems + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' security vulnerabilities from ' + response.alias + ' in the following json.\n'; - response.securityProblems?.forEach((problem: any) => { - result += `securityProblemId: ${problem.securityProblemId}\n`; - result += ` displayId: ${problem.displayId}\n`; - result += ` title: ${problem.title}\n`; - result += ` status: ${problem.status}\n`; - result += ` vulnerabilityType: ${problem.vulnerabilityType}\n`; - result += ` technology: ${problem.technology}\n`; - if (problem.riskAssessment) { - result += - ` riskLevel: ${problem.riskAssessment.riskLevel}; ` + - `riskScore: ${problem.riskAssessment.riskScore}; ` + - `exposure: ${problem.riskAssessment.exposure}\n`; - } - if (problem.cveIds && problem.cveIds.length > 0) { + if (isLimited) { result += - ` cveIds: ${problem.cveIds.slice(0, SecurityApiClient.MAX_CVES_DISPLAY).join(', ')}` + - `${problem.cveIds.length > SecurityApiClient.MAX_CVES_DISPLAY ? ` (+${problem.cveIds.length - SecurityApiClient.MAX_CVES_DISPLAY} more)` : ''}\n`; - } - if (problem.firstSeenTimestamp) { - result += ` firstSeen: ${formatTimestamp(problem.firstSeenTimestamp)}\n`; + 'Not showing all matching vulnerabilities. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\n'; + anyLimited = true; } - result += '\n'; - }); + + response.data.securityProblems?.forEach((problem: any) => { + result += `securityProblemId: ${problem.securityProblemId}\n`; + result += ` displayId: ${problem.displayId}\n`; + result += ` title: ${problem.title}\n`; + result += ` status: ${problem.status}\n`; + result += ` vulnerabilityType: ${problem.vulnerabilityType}\n`; + result += ` technology: ${problem.technology}\n`; + if (problem.riskAssessment) { + result += + ` riskLevel: ${problem.riskAssessment.riskLevel}; ` + + `riskScore: ${problem.riskAssessment.riskScore}; ` + + `exposure: ${problem.riskAssessment.exposure}\n`; + } + if (problem.cveIds && problem.cveIds.length > 0) { + result += + ` cveIds: ${problem.cveIds.slice(0, SecurityApiClient.MAX_CVES_DISPLAY).join(', ')}` + + `${problem.cveIds.length > SecurityApiClient.MAX_CVES_DISPLAY ? ` (+${problem.cveIds.length - SecurityApiClient.MAX_CVES_DISPLAY} more)` : ''}\n`; + } + if (problem.firstSeenTimestamp) { + result += ` firstSeen: ${formatTimestamp(problem.firstSeenTimestamp)}\n`; + } + result += '\n'; + }); + } + result += '\n' + 'Next Steps:\n' + - (numProblems == 0 + (totalNumProblems == 0 ? '* Verify that the filters such as entitySelector, status and time range were correct, and search again with different filters.\n' : '') + - (isLimited ? '* Use more restrictive filters, such as a more specific entitySelector and status.\n' : '') + - (numProblems > 1 ? '* Use sort (e.g. with "-riskAssessment.riskScore" for highest risk score first).\n' : '') + + (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector and status.\n' : '') + + (totalNumProblems > 1 ? '* Use sort (e.g. with "-riskAssessment.riskScore" for highest risk score first).\n' : '') + '* If the user is interested in a specific vulnerability, use the get_security_problem_details tool. Use the securityProblemId for this.\n' + - '* Suggest to the user that they view the security vulnerabilties in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/security/overview for an overview,` + - `or ${this.authClient.dashboardBaseUrl}/ui/security/vulnerabilities for a list of third-party vulnerabilties` + + '* Suggest to the user that they view the security vulnerabilties in the Dynatrace UI.' + + //`${this.authClient.dashboardBaseUrl}/ui/security/overview for an overview,` + + //`or ${this.authClient.dashboardBaseUrl}/ui/security/vulnerabilities for a list of third-party vulnerabilties` + '\n'; return result; } - formatDetails(response: any): string { - let result = - 'Details of security problem in the following json.\n' + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + + formatDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Details of security problem from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + '* If there are affectedEntities, suggest to the user that they could get further information about those entities with get_entity_detais tool, using the entityId.\n' + - '* Suggest to the user that they view the security vulnerability in the Dynatrace UI ' + - `${this.authClient.dashboardBaseUrl}/ui/security/vulnerabilities/, using the securityProblemId in the URL\n`; - + '* Suggest to the user that they view the security vulnerability in the Dynatrace UI using the securityProblemId in the URL\n'; return result; } } diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index 4a300db..b7c4cd8 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClient } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; @@ -69,9 +69,9 @@ export class SloApiClient { static readonly API_PAGE_SIZE = 200; static readonly MAX_MANAGEMENT_ZONES_DISPLAY = 11; - constructor(private authClient: ManagedAuthClient) {} + constructor(private authManager: ManagedAuthClientManager) {} - async listSlos(params: SloQueryParams = {}): Promise { + async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise<[]> { const queryParams: Record = { pageSize: params.pageSize || SloApiClient.API_PAGE_SIZE, ...(params.sloSelector && { sloSelector: params.sloSelector }), @@ -85,12 +85,12 @@ export class SloApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authClient.makeRequest('/api/v2/slo', queryParams); - logger.debug('listSLOs response', { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/slo', queryParams, environment_aliases); + logger.debug('listSLOs response', { data: responses }); + return responses; } - async getSloDetails(params: GetSloQueryParams): Promise { + async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise { const queryParams: Record = { ...(params.from && { from: params.from }), ...(params.to && { to: params.to }), @@ -100,78 +100,81 @@ export class SloApiClient { queryParams.timeFrame = 'GTF'; } - const response = await this.authClient.makeRequest(`/api/v2/slo/${encodeURIComponent(params.id)}`, queryParams); - logger.debug('getSLODetails response', { data: response }); - return response; + const responses = await this.authManager.makeRequests(`/api/v2/slo/${encodeURIComponent(params.id)}`, queryParams, environment_aliases); + logger.debug('getSLODetails response', { data: responses }); + return responses; } - formatList(response: ListSlosResponse): string { - let totalCount = response.totalCount || -1; - let numSLOs = response.slo?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; - - let result = 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs.\n'; + formatList(responses: {'alias': string, 'data': ListSlosResponse}[]): string { + let result = ""; + let totalNumSlo = 0; + let anyLimited = false + for (const response of responses) { + let totalCount = response.data.totalCount || -1; + let numSLOs = response.data.slo?.length || 0; + totalNumSlo += numSLOs; + let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; + + result += 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + response.alias + ':\n'; + + if (isLimited) { + result += + 'Not showing all matching SLOs. Consider using more specific filters (sloSelector) to get complete results.\n'; + anyLimited = true; + } - if (isLimited) { - result += - 'Not showing all matching SLOs. Consider using more specific filters (sloSelector) to get complete results.\n'; + response.data.slo?.forEach((slo: any) => { + result += `id: ${slo.id}\n`; + result += ` name: ${slo.name}\n`; + if (slo.description) { + result += `${slo.description}\n`; + } + result += ` status: ${slo.status}\n`; + result += ` target: ${slo.target}\n`; + result += ` warning: ${slo.warning}\n`; + result += ` enabled: ${slo.enabled}\n`; + if (slo.timeframe) { + result += `. timeframe: ${slo.timeframe}\n`; + } + if (slo.evaluatedPercentage !== undefined && slo.evaluatedPercentage !== -1) { + result += `. evaluatedPercentage: ${slo.evaluatedPercentage}%\n`; + } + if (slo.errorBudget !== undefined && slo.errorBudget !== -1) { + result += ` error budget: ${slo.errorBudget}%\n`; + } + if (slo.managementZones && slo.managementZones.length > 0) { + const zones = slo.managementZones + .slice(0, SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) + .map((zone: any) => zone.name || zone.id || zone) + .join(', '); + result += ` management zones: ${zones}${slo.managementZones.length > SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${slo.managementZones.length - SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; + } + result += '\n'; + }); } - response.slo?.forEach((slo: any) => { - result += `id: ${slo.id}\n`; - result += ` name: ${slo.name}\n`; - if (slo.description) { - result += `${slo.description}\n`; - } - result += ` status: ${slo.status}\n`; - result += ` target: ${slo.target}\n`; - result += ` warning: ${slo.warning}\n`; - result += ` enabled: ${slo.enabled}\n`; - if (slo.timeframe) { - result += `. timeframe: ${slo.timeframe}\n`; - } - if (slo.evaluatedPercentage !== undefined && slo.evaluatedPercentage !== -1) { - result += `. evaluatedPercentage: ${slo.evaluatedPercentage}%\n`; - } - if (slo.errorBudget !== undefined && slo.errorBudget !== -1) { - result += ` error budget: ${slo.errorBudget}%\n`; - } - if (slo.managementZones && slo.managementZones.length > 0) { - const zones = slo.managementZones - .slice(0, SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY) - .map((zone: any) => zone.name || zone.id || zone) - .join(', '); - result += ` management zones: ${zones}${slo.managementZones.length > SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY ? ` (+${slo.managementZones.length - SloApiClient.MAX_MANAGEMENT_ZONES_DISPLAY} more)` : ''}\n`; - } - result += '\n'; - }); - result += '\n' + 'Next Steps:\n' + - (numSLOs == 0 + (totalNumSlo == 0 ? '* Verify that the filters such as sloSelector were correct, and search again with different filters.\n' : '') + - (isLimited ? '* Use more restrictive filters, such as a more specific sloSelector and status.\n' : '') + - (numSLOs > 1 ? '* Use sort (e.g. with "+name" for ascending alphabetical order).\n' : '') + + (anyLimited ? '* Use more restrictive filters, such as a more specific sloSelector and status.\n' : '') + + (totalNumSlo > 1 ? '* Use sort (e.g. with "+name" for ascending alphabetical order).\n' : '') + '* If the user is interested in a specific SLO, use the get_slo_details tool. Use the SLO id for this.\n' + - '* Suggest to the user that they view the SLOs in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/slo` + - '\n'; + '* Suggest to the user that they view the SLOs in the Dynatrace UI.'; return result; } - formatDetails(response: any): string { - let result = - 'Details of SLO in the following json.\n' + - JSON.stringify(response) + - '\n' + - 'Next Steps:\n' + - '* Suggest to the user that they view the SLO in the Dynatrace UI at ' + - `${this.authClient.dashboardBaseUrl}/ui/slo/, using the SLO id in the URL` + - '\n'; - + formatDetails(responses: {'alias': string, 'data': string}[]): string { + let result = ""; + for (const response of responses) { + result += 'Details of SLO from environment ' + response.alias + ' in the following json:\n' + + JSON.stringify(response.data) + '\n'; + } + result += 'Next Steps:\n' + + '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n'; return result; } } diff --git a/src/index.ts b/src/index.ts index c8a6874..1d07004 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,9 @@ import { createServer, IncomingMessage, ServerResponse } from 'node:http'; import { Command } from 'commander'; import { z, ZodRawShape, ZodTypeAny } from 'zod'; import { getPackageJsonVersion } from './utils/version'; -import { ManagedAuthClient } from './authentication/managed-auth-client'; -import { getManagedEnvironmentConfig } from './utils/environment'; -import { createTelemetry, Telemetry } from './utils/telemetry-openkit'; +import { ManagedAuthClientManager } from './authentication/managed-auth-client'; +import { getManagedEnvironmentConfigs } from './utils/environment'; +import { createTelemetry } from './utils/telemetry-openkit'; import { MetricsApiClient } from './capabilities/metrics-api'; import { LogsApiClient } from './capabilities/logs-api'; @@ -43,32 +43,26 @@ const main = async () => { logger.info(`Initializing Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`); // Read Managed environment configuration - let managedConfig; + let managedConfigs; try { - managedConfig = getManagedEnvironmentConfig(); + managedConfigs = getManagedEnvironmentConfigs(); } catch (err) { - console.error('Failed to get managed environment configuration', err); - logger.error('Failed to get managed environment configuration', { error: err }); + console.error('Failed to get managed environments configuration: ', err); + logger.error('Failed to get managed environments configuration: ', { error: err }); process.exit(1); } - const { environmentId, apiUrl, dashboardUrl, apiToken } = managedConfig; - - // Initialize Managed authentication client - const authClient = new ManagedAuthClient({ - apiBaseUrl: apiUrl, - dashboardBaseUrl: dashboardUrl, - apiToken: apiToken, - }); + const authClientManager = new ManagedAuthClientManager(managedConfigs); + await authClientManager.validateClients() // Initialize API clients - const metricsClient = new MetricsApiClient(authClient); - const logsClient = new LogsApiClient(authClient); - const eventsClient = new EventsApiClient(authClient); - const entitiesClient = new EntitiesApiClient(authClient); - const problemsClient = new ProblemsApiClient(authClient); - const securityClient = new SecurityApiClient(authClient); - const sloClient = new SloApiClient(authClient); + const metricsClient = new MetricsApiClient(authClientManager); + const logsClient = new LogsApiClient(authClientManager); + const eventsClient = new EventsApiClient(authClientManager); + const entitiesClient = new EntitiesApiClient(authClientManager); + const problemsClient = new ProblemsApiClient(authClientManager); + const securityClient = new SecurityApiClient(authClientManager); + const sloClient = new SloApiClient(authClientManager); // Initialize usage tracking const telemetry = createTelemetry(); @@ -97,16 +91,16 @@ const main = async () => { elicitation: {}, }, instructions: ` -This MCP server connects to a Dynatrace Managed (self-hosted) environment for Observabilitiy. This can include metrics, logs and traces, +This MCP server connects to Dynatrace Managed (self-hosted) environments for Observabilitiy. This can include metrics, logs and traces, and detection of problems and security vulnerabilities relating to these. -Some users may configure two MCPs at the same time: this MCP to connect to their Dynatrace Managed, and a second MCP to connect to their SaaS environment. +Some users may configure two MCPs at the same time: this MCP to connect to their Dynatrace Managed instances, and a second MCP to connect to their SaaS environment. Be careful of which MCP to use. If it is unclear, ask the user which they want to use. Ask the user to confirm the difference between their two environments. **Key Context:** - This server accesses self-hosted Dynatrace Managed clusters (not the SaaS environment) - Designed for historical data analysis before migration to SaaS -- Minimum supported cluster version: ${authClient.MINIMUM_VERSION} +- Minimum supported cluster version: ... - Two different ways that Dynatrace Managed may be being used: 1. Dynatrace Managed may be the primary Observability system, containing all live data. 2. Or alternatively the customer may have migrated to Dynatrace SaaS, leavng historical observability data in Dynatrace Managed from before the migration, in which case this MCP Server would only be used to access historical data. @@ -124,7 +118,7 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - Use specific time ranges (1-2 hours) rather than large historical queries for better performance - Leverage entity selectors to filter data at the source - they are fundamental to getting good results - Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) -- Start with get_environment_info to understand cluster capabilities and data range +- Start with get_environments_info to understand cluster capabilities and data range - **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText - **Avoid searchText guessing** - only use searchText when user explicitly mentions keywords to search for - **discover_entities ALWAYS requires entitySelector** - never call this tool without providing an entitySelector with exactly ONE entity type like type("SERVICE") unless using an EntityId. Multiple entity types are NOT supported. @@ -184,41 +178,12 @@ For entity-based Analysis: 3. get_entity_details (using use exact entityId) 4. list_problems or list_events, using the entityId in the entitySelector. -Always be cautious to avoid overloading the self-hosted Dynatrace Managed clusters. +Always be cautious to avoid overloading the self-hosted Dynatrace Managed clusters. Never run queries that could return very large amounts of data, or that could be very expensive to compute. `, }, ); - // Test connection to Managed cluster - logger.info(`Testing connection to Dynatrace Managed cluster: ${apiUrl}...`); - - try { - const isConnected = await authClient.validateConnection(); - if (!isConnected) { - throw new Error('Connection validation failed'); - } - logger.info(`Called validateConnection`); - - const clusterVersion = await authClient.getClusterVersion(); - logger.info(`Connected to Managed cluster version ${clusterVersion.version}`); - - const isValidVersion = authClient.validateMinimumVersion(clusterVersion); - if (!isValidVersion) { - logger.info( - `Warning: Cluster version ${clusterVersion.version} may not support all features. Minimum recommended version is${authClient.MINIMUM_VERSION}`, - ); - } - } catch (error: any) { - logger.error(`Failed to connect to Managed cluster ${apiUrl}: ${error.message}`); - logger.error('Please verify:'); - logger.error('1. DT_MANAGED_ENVIRONMENT URL is correct'); - logger.error(`2. DT_MANAGED_API_TOKEN has required scopes: ${MANAGED_API_SCOPES.join(', ')}`); - logger.error('3. Network connectivity to the Managed cluster'); - - process.exit(2); - } - // Ready to start the server logger.info(`Starting Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`); @@ -290,23 +255,47 @@ Never run queries that could return very large amounts of data, or that could be server.tool(name, description, paramsSchema, annotations, (args: z.ZodRawShape) => wrappedCb(args)); }; + const envAliasValidate = (alias: string | undefined) => { + if (alias) { + const env_list = alias.split(';'); + for (const env_alias of env_list) { + if (!authClientManager.validAliases.includes(env_alias)) { + return false; + } + } + } + return true + } + tool( - 'dynatrace_managed_get_environment_info', - 'Get information about the connected Dynatrace Managed cluster and verify the connection and authentication.', + 'dynatrace_managed_get_environments_info', + 'Get information about all connected Dynatrace Managed clusters and verify the connections and authentication services.', {}, { readOnlyHint: true, }, async ({}) => { - const clusterVersion = await authClient.getClusterVersion(); - const isValidVersion = authClient.validateMinimumVersion(clusterVersion); - - let resp = `Dynatrace Managed Cluster Information:\n`; - resp += `- API URL: ${apiUrl}\n`; - resp += `- Dashboard URL: ${dashboardUrl}\n`; - resp += `- Version: ${clusterVersion.version}\n`; - resp += `- Minimum Version Check: ${isValidVersion ? 'PASSED' : 'WARNING - Version may not be fully compatible and may not support all features'}\n`; - resp += `- Available API Scopes: ${MANAGED_API_SCOPES.join(', ')}\n`; + let resp = `Dynatrace Managed Cluster Information - Listing info for all ${authClientManager.rawClients.length} environments:\n\n`; + + for (let authClient of authClientManager.rawClients) { + resp += `- Environment Alias: ${authClient.alias}\n`; + resp += `- API URL: ${authClient.apiBaseUrl}\n`; + resp += `- Dashboard URL: ${authClient.dashboardBaseUrl}\n`; + resp += `- Valid Environment: ${authClient.isValid ? 'Yes' : 'No'}\n`; + let clusterVersion; + let isValidVersion; + if (authClient.isValid) { + clusterVersion = await authClient.getClusterVersion(); + isValidVersion = authClient.validateMinimumVersion(clusterVersion); + + resp += `- Version: ${clusterVersion.version}\n`; + resp += `- Minimum Version Check: ${isValidVersion ? 'PASSED' : 'WARNING - Version may not be fully compatible and may not support all features'}\n`; + resp += `- Available API Scopes: ${MANAGED_API_SCOPES.join(', ')}\n\n\n`; + } else { + resp += `- Error message: ${authClient.validationError}\n`; + } + } + resp += `\n\nAll Dynatrace Managed Cluster Environments listed. Value of "Valid environment" is set to "No" for invalid environments.\n\n`; return resp; }, @@ -321,7 +310,7 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe( - `Entity selector to filter metrics. Must use at most one entity type per query. + `Entity selector to filter metrics. Must use at most one entity type per query. Examples include: * type(SERVICE) * entityId("id1","id2") @@ -332,42 +321,48 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe( - `Text to search for in metric names and descriptions. - **RECOMMENDED SEARCHES**: "response.time" (latency), "cpu.usage" (CPU), "memory" (memory), + `Text to search for in metric names and descriptions. + **RECOMMENDED SEARCHES**: "response.time" (latency), "cpu.usage" (CPU), "memory" (memory), "error.rate" (errors), "throughput" (performance), "availability" (uptime)`, ), limit: z .number() .optional() .describe( - `Maximum number of metrics to return. Use this when user specifies a count - (e.g., "first 16 metrics" → limit: 16, "500 metrics" → limit: 500). + `Maximum number of metrics to return. Use this when user specifies a count + (e.g., "first 16 metrics" → limit: 16, "500 metrics" → limit: 500). If not specified, returns up to API limit: ${MetricsApiClient.API_PAGE_SIZE}`, ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ entitySelector, searchText, limit }) => { - const response = await metricsClient.listAvailableMetrics({ + async ({ entitySelector, searchText, limit, environment_alias}) => { + const responses = await metricsClient.listAvailableMetrics({ entitySelector: entitySelector, text: searchText, pageSize: limit, - }); - return metricsClient.formatMetricList(response); + }, environment_alias); + return metricsClient.formatMetricList(responses); }, ); tool( 'dynatrace_managed_query_metrics_data', `Query metric data for a specific time range and metric selector. - Must limit the amount of data being retreived: - must use a specific entitySelector, such as using specific entityIds; + Must limit the amount of data being retreived: + must use a specific entitySelector, such as using specific entityIds; must use a narrow timerange (with from and to); must use a resolution in line with the timerange, for example if getting data covering several days then the resolution should be hours rather than minutes.`, { metricSelector: z.string().describe( - `Metric selector (e.g., "builtin:service.response.time" for latency, + `Metric selector (e.g., "builtin:service.response.time" for latency, "builtin:tech.generic.cpu.usage" for container CPU, "builtin:host.mem.usage" for memory). Consider first using the tool list_available_metrics to identity the right metric.`, ), @@ -377,32 +372,38 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe( - `Data resolution. Use a bigger resolution when the timerange is larger. + `Data resolution. Use a bigger resolution when the timerange is larger. For example, use "5m" for detailed analysis of data over hour(s), use "1h" for trends of data over a day, use 6h or 1d for data over many days.`, ), entitySelector: z .string() .optional() .describe( - `Entity selector to filter metrics data. CRITICAL: Only ONE entity type per query. - Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or - type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), + `Entity selector to filter metrics data. CRITICAL: Only ONE entity type per query. + Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or + type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`, ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ metricSelector, from, to, resolution, entitySelector }) => { - const response = await metricsClient.queryMetrics({ + async ({ metricSelector, from, to, resolution, entitySelector, environment_alias }) => { + const responses = await metricsClient.queryMetrics({ metricSelector: metricSelector, from: from, to: to, resolution: resolution, entitySelector: entitySelector, - }); + }, environment_alias); - return metricsClient.formatMetricData(response); + return metricsClient.formatMetricData(responses); }, ); @@ -411,49 +412,61 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific metric.', { metricId: z.string().describe('The metric ID to get details for'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ metricId }) => { - const response = await metricsClient.getMetricDetails(metricId); - return metricsClient.formatMetricDetails(response); + async ({ metricId, environment_alias }) => { + const responses = await metricsClient.getMetricDetails(metricId, environment_alias ); + return metricsClient.formatMetricDetails(responses); }, ); tool( 'dynatrace_managed_query_logs', - `Search logs using simple text queries. Results include event types, expanded metadata fields - (up to 8 fields), and enhanced error detection. Managed clusters support basic text search but + `Search logs using simple text queries. Results include event types, expanded metadata fields + (up to 8 fields), and enhanced error detection. Managed clusters support basic text search but not structured syntax like "content:" or "loglevel:".`, { query: z.string().describe( - `Simple text to search for in log content (e.g., "error", "exception", "timeout"). + `Simple text to search for in log content (e.g., "error", "exception", "timeout"). Do NOT use structured syntax like "content:error" - just use "error".`, ), from: z.string().describe('Start time (ISO format or relative like "now-1h")'), to: z.string().describe('End time (ISO format or relative like "now")'), limit: z.number().optional().describe('Maximum number of logs to return (default: 100)'), sort: z.string().optional().describe('Sort order for logs. Use "-timestamp" for most recent first.'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ query, from, to, limit, sort }) => { - const response = await logsClient.queryLogs({ + async ({ query, from, to, limit, sort, environment_alias }) => { + const responses = await logsClient.queryLogs({ query: query, from: from, to: to, limit: limit, - sort: sort, - }); - return logsClient.formatList(response); + sort: sort + }, environment_alias); + return logsClient.formatList(responses); }, ); tool( 'dynatrace_managed_list_events', - `List events from the Managed cluster within a specified timeframe. Results include event properties, + `List events from the Managed cluster within a specified timeframe. Results include event properties, management zones, severity/impact levels, and detailed metadata for comprehensive analysis.`, { from: z.string().describe('Start time (ISO format or relative like "now-1h")'), @@ -462,16 +475,16 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe( - `Filter by event type (e.g., "CONTAINER_RESTART" for certain container issues, + `Filter by event type (e.g., "CONTAINER_RESTART" for certain container issues, "CUSTOM_DEPLOYMENT" for deployments, "RESOURCE_CONTENTION_EVENT" for resource issues)`, ), entitySelector: z .string() .optional() .describe( - `Entity selector to filter events. CRITICAL: Only ONE entity type per query. - Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or - type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), + `Entity selector to filter events. CRITICAL: Only ONE entity type per query. + Use discover_entities() first to get exact names/IDs, then use entityId("exact-id") or + type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`, ), limit: z @@ -480,20 +493,26 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of events to return. Use this when user specifies a count (e.g., "first 20 events" → limit: 20). If not specified, returns up to API limit: ${EventsApiClient.API_PAGE_SIZE}`, ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ from, to, eventType, entitySelector, limit }) => { - const response = await eventsClient.queryEvents({ + async ({ from, to, eventType, entitySelector, limit, environment_alias}) => { + const responses = await eventsClient.queryEvents({ from: from, to: to, eventType: eventType, entitySelector: entitySelector, pageSize: limit, - }); + }, environment_alias); - return eventsClient.formatList(response); + return eventsClient.formatList(responses); }, ); @@ -502,12 +521,18 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific event.', { eventId: z.string().describe('The event ID to get details for'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ eventId }) => { - const response = await eventsClient.getEventDetails(eventId); + async ({ eventId, environment_alias }) => { + const response = await eventsClient.getEventDetails(eventId, environment_alias); return eventsClient.formatDetails(response); }, ); @@ -515,13 +540,20 @@ Never run queries that could return very large amounts of data, or that could be tool( 'dynatrace_managed_list_entity_types', 'List all available entity types in the Managed cluster to understand what types of entities can be monitored.', - {}, + { + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) + }, { readOnlyHint: true, }, - async ({}) => { - const response = await entitiesClient.listEntityTypes(); - return entitiesClient.formatEntityTypeList(response); + async ({environment_alias}) => { + const responses = await entitiesClient.listEntityTypes(environment_alias); + return entitiesClient.formatEntityTypeList(responses); }, ); @@ -530,34 +562,40 @@ Never run queries that could return very large amounts of data, or that could be 'Get details of an entity type.', { type: z.string().describe('Name of the entity type, such as SERVICE, APPLICATION, HOST, etc'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ type }) => { - const response = await entitiesClient.getEntityTypeDetails(type); + async ({ type, environment_alias }) => { + const response = await entitiesClient.getEntityTypeDetails(type, environment_alias); return entitiesClient.formatEntityTypeDetails(response); }, ); tool( 'dynatrace_managed_discover_entities', - `Discover entities in the Managed cluster using EntitySelector syntax. REQUIRED: Must specify - entitySelector with exactly ONE entity type only. Results include entity properties, tags, + `Discover entities in the Managed cluster using EntitySelector syntax. REQUIRED: Must specify + entitySelector with exactly ONE entity type only. Results include entity properties, tags, management zones, and relationship counts for comprehensive topology analysis.`, { entitySelector: z.string().describe( - `Entity selector to filter the entities. CRITICAL: Must include exactly ONE entity type - like type("SERVICE") - multiple types NOT supported. Examples: type("SERVICE"), - entityId("ID1"), entityName.contains("name"), entityName.equals("exact"), tag("key:value"), mzName("zone"), + `Entity selector to filter the entities. CRITICAL: Must include exactly ONE entity type + like type("SERVICE") - multiple types NOT supported. Examples: type("SERVICE"), + entityId("ID1"), entityName.contains("name"), entityName.equals("exact"), tag("key:value"), mzName("zone"), healthState("HEALTHY").`, ), mzSelector: z .string() .optional() .describe( - `Optional management zone selector to further scope the query. Use mzId(123,456) for zone IDs - or mzName("Bookstore-FS","Stocks") for zone names. Can combine: mzId(123),mzName("Production"). + `Optional management zone selector to further scope the query. Use mzId(123,456) for zone IDs + or mzName("Bookstore-FS","Stocks") for zone names. Can combine: mzId(123),mzName("Production"). Works alongside entitySelector.`, ), from: z @@ -578,20 +616,26 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe('Sort order for entities. Use "name" for ascending, "-name" for descending by display name.'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ entitySelector, mzSelector, from, to, limit, sort }) => { - const response = await entitiesClient.queryEntities({ + async ({ entitySelector, mzSelector, from, to, limit, sort, environment_alias }) => { + const responses = await entitiesClient.queryEntities({ entitySelector: entitySelector, pageSize: limit, mzSelector: mzSelector, from: from, to: to, sort: sort, - }); - return entitiesClient.formatEntityList(response); + }, environment_alias); + return entitiesClient.formatEntityList(responses); }, ); @@ -600,12 +644,18 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific entity.', { entityId: z.string().describe('The entity ID to get details for'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ entityId }) => { - const response = await entitiesClient.getEntityDetails(entityId); + async ({ entityId, environment_alias}) => { + const response = await entitiesClient.getEntityDetails(entityId, environment_alias); return entitiesClient.formatEntityDetails(response); }, ); @@ -615,13 +665,19 @@ Never run queries that could return very large amounts of data, or that could be 'Get relationships that a specific entity has "to" and "from" other entities.', { entityId: z.string().describe('The entity ID to get relationships for'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ entityId }) => { - const response = await entitiesClient.getEntityRelationships(entityId); - return entitiesClient.formatEntityRelationships(response); + async ({ entityId, environment_alias }) => { + const responses = await entitiesClient.getEntityRelationships(entityId, environment_alias); + return entitiesClient.formatEntityRelationships(responses); }, ); @@ -659,12 +715,18 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Use "+status" (open first), "-status" (closed first), "+startTime" (old first), "-startTime" (new first), or "+relevance"/"-relevance" (with text search).', ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ from, to, status, impactLevel, entitySelector, limit, sort }) => { - const response = await problemsClient.listProblems({ + async ({ from, to, status, impactLevel, entitySelector, limit, sort, environment_alias }) => { + const responses = await problemsClient.listProblems({ from: from || 'now-24h', to: to || 'now', status: status, @@ -672,9 +734,9 @@ Never run queries that could return very large amounts of data, or that could be entitySelector: entitySelector, pageSize: limit, sort: sort, - }); + }, environment_alias); - return problemsClient.formatList(response); + return problemsClient.formatList(responses); }, ); @@ -685,12 +747,18 @@ Never run queries that could return very large amounts of data, or that could be problemId: z .string() .describe('The internal problem ID (UUID format) from list_problems - NOT the displayId (P-XXXXX)'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ problemId }) => { - const response = await problemsClient.getProblemDetails(problemId); + async ({ problemId, environment_alias }) => { + const response = await problemsClient.getProblemDetails(problemId, environment_alias); return problemsClient.formatDetails(response); }, ); @@ -721,12 +789,18 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Examples: "+status" (open first), "-riskAssessment.riskScore" (highest risk first), "+firstSeenTimestamp" (newest first), "-lastUpdatedTimestamp" (recently updated first).', ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ riskLevel, status, entitySelector, from, to, limit, sort }) => { - const response = await securityClient.listSecurityProblems({ + async ({ riskLevel, status, entitySelector, from, to, limit, sort, environment_alias }) => { + const responses = await securityClient.listSecurityProblems({ riskLevel: riskLevel, status: status, entitySelector: entitySelector, @@ -734,9 +808,9 @@ Never run queries that could return very large amounts of data, or that could be to: to, pageSize: limit, sort: sort, - }); + }, environment_alias); - return securityClient.formatList(response); + return securityClient.formatList(responses); }, ); @@ -747,12 +821,18 @@ Never run queries that could return very large amounts of data, or that could be securityProblemId: z .string() .describe('The security problem ID (UUID format) from list_security_problems - NOT the displayId (S-XXXXX)'), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ securityProblemId }) => { - const response = await securityClient.getSecurityProblemDetails(securityProblemId); + async ({ securityProblemId, environment_alias }) => { + const response = await securityClient.getSecurityProblemDetails(securityProblemId, environment_alias); return securityClient.formatDetails(response); }, ); @@ -803,12 +883,18 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of SLOs to return. Use this when user specifies a count (e.g., "first 15 SLOs" → limit: 15). If not specified, returns up to API limit: ${SloApiClient.API_PAGE_SIZE}`, ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ sloSelector, timeFrame, from, to, evaluate, sort, enabledSlos, showGlobalSlos, demo, limit }) => { - const response = await sloClient.listSlos({ + async ({ sloSelector, timeFrame, from, to, evaluate, sort, enabledSlos, showGlobalSlos, demo, limit, environment_alias }) => { + const responses = await sloClient.listSlos({ sloSelector: sloSelector, timeFrame: timeFrame, from: from, @@ -819,8 +905,8 @@ Never run queries that could return very large amounts of data, or that could be showGlobalSlos: showGlobalSlos, demo: demo, pageSize: limit, - }); - return sloClient.formatList(response); + }, environment_alias); + return sloClient.formatList(responses); }, ); @@ -840,17 +926,23 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Time frame for SLO evaluation: "CURRENT" for SLO\'s own timeframe, "GTF" for custom timeframe specified by from and to parameters', ), + environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), + { + message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") + }, + ) }, { readOnlyHint: true, }, - async ({ sloId, from, to, timeFrame }) => { + async ({ sloId, from, to, timeFrame, environment_alias }) => { const response = await sloClient.getSloDetails({ id: sloId, from: from, to: to, timeFrame: timeFrame, - }); + }, environment_alias); return sloClient.formatDetails(response); }, ); diff --git a/src/utils/environment.ts b/src/utils/environment.ts index e9926ac..32abdef 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,26 +1,38 @@ +import { JSONObject } from '@dynatrace/openkit-js'; + export interface ManagedEnvironmentConfig { environmentId: string; apiUrl: string; dashboardUrl: string; apiToken: string; + alias: string; + httpProxy?: string; + httpsProxy?: string; } -export function getManagedEnvironmentConfig(): ManagedEnvironmentConfig { - const environmentIdRaw = process.env.DT_MANAGED_ENVIRONMENT; - const apiUrlRaw = process.env.DT_API_ENDPOINT_URL; - const dashboardUrlRaw = process.env.DT_DYNATRACE_URL; - const apiToken = process.env.DT_MANAGED_API_TOKEN; +export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): ManagedEnvironmentConfig { + const environmentIdRaw = environmentInfo.environmentId.toString(); + const apiUrlRaw = environmentInfo.apiEndpointUrl.toString(); + const dashboardUrlRaw = environmentInfo.dynatraceUrl.toString(); + const apiToken = environmentInfo.apiToken.toString(); + const alias = environmentInfo.alias.toString(); + const httpProxy = environmentInfo.httpProxyUrl?.toString(); + const httpsProxy = environmentInfo.httspProxyUrl?.toString(); if (!environmentIdRaw) { - throw new Error('DT_MANAGED_ENVIRONMENT is required'); + throw new Error('environmentId is required'); } if (!apiUrlRaw) { - throw new Error('DT_API_ENDPOINT_URL is required'); + throw new Error('apiEndpointUrl is required'); } if (!apiToken) { - throw new Error('DT_MANAGED_API_TOKEN is required'); + throw new Error('apiToken is required'); + } + + if (!alias) { + throw new Error('`alias` is required'); } let environmentId = environmentIdRaw.replace(/\/$/, ''); // Remove trailing slash @@ -33,5 +45,36 @@ export function getManagedEnvironmentConfig(): ManagedEnvironmentConfig { apiUrl: apiUrl, dashboardUrl: dashboardUrl, apiToken: apiToken, + alias: alias, + httpProxy: httpProxy, + httpsProxy: httpsProxy }; } + + +export function getManagedEnvironmentConfigs(): ManagedEnvironmentConfig[] { + const environmentConfigs = process.env.DT_ENVIRONMENT_CONFIGS; + if (!environmentConfigs) { + throw new Error('DT_ENVIRONMENT_CONFIGS is required'); + } + let parsedConfig; + console.log('we in'); + try { + parsedConfig = JSON.parse(environmentConfigs); + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error(`JSON syntax error: ${e}`); + } else { + throw e; + } + } + + let validConfigurations = [] + for (let env of parsedConfig) { + validConfigurations.push( + parseManagedEnvironmentConfig(env) + ) + } + + return validConfigurations; +} From 1636e246a998f35180732086bc4161357b841bb6 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Fri, 16 Jan 2026 11:57:14 +0000 Subject: [PATCH 02/17] - Removed old TODO --- src/capabilities/entities-api.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index e30da4e..443a0b3 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -98,9 +98,7 @@ export class EntitiesApiClient { } async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { - const responses = await this.getEntityDetails(entityId, environment_aliases); -// TODO: THIS IS NOT GONNA WORK, GOTTA REFACTOR SINCE WE GETTING SEVERAL RESPONSES NOW FROM makeRequests - return responses + return await this.getEntityDetails(entityId, environment_aliases); } async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise<[]> { From 7b50550375d665fe0898f8b27a162213e0267280 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Tue, 20 Jan 2026 16:57:43 +0000 Subject: [PATCH 03/17] - New interface EnvironmentResponse: used for responses coming from Dynatrace envs. - Refactored all related functions that would make use of it - Added checks in environment.ts to validate environments configuration before checking connections. - Added tool for LLM so it can query environment setting diagnostics. - `environment_alias` is now a required field. LLM will have to pass always one value to this tool param. --- src/authentication/managed-auth-client.ts | 11 +- src/capabilities/entities-api.ts | 49 ++------- src/capabilities/events-api.ts | 17 +--- src/capabilities/logs-api.ts | 12 +-- src/capabilities/metrics-api.ts | 49 +++------ src/capabilities/problems-api.ts | 22 ++-- src/capabilities/security-api.ts | 33 +----- src/capabilities/slo-api.ts | 17 +--- src/index.ts | 116 +++++++++++++++------- src/utils/environment.ts | 80 ++++++++------- 10 files changed, 187 insertions(+), 219 deletions(-) diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index 41ee340..c21f2ea 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -27,6 +27,11 @@ export interface ManagedAuthClientParams { isValid?: boolean; } +export interface EnvironmentResponse { + alias: string; + data: any; +} + export class ManagedAuthClientManager { public readonly rawClients: ManagedAuthClient[]; public clients: ManagedAuthClient[]; @@ -35,7 +40,7 @@ export class ManagedAuthClientManager { constructor(managedEnvironments: ManagedEnvironmentConfig[]) { this.rawClients = [] this.clients = [] - this.validAliases = [] + this.validAliases = ["ALL_ENVIRONMENTS",] logger.warn('Validating Environments'); for (let managedEnvironment of managedEnvironments) { @@ -49,7 +54,7 @@ export class ManagedAuthClientManager { } } - async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { + async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { let responses = [] const selectedAliases = environments ? environments.split(';') : this.validAliases; @@ -64,7 +69,7 @@ export class ManagedAuthClientManager { return responses; } - async validateClients(): Promise { + async isConfigured(): Promise { for (let client of this.rawClients) { let validClient = await client.isConfigured() if (validClient) { diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 443a0b3..09f782d 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; @@ -11,20 +11,6 @@ export interface EntityQueryParams { sort?: string; } -export interface ListEntityTypesResponse { - types?: EntityType[]; - totalCount?: number; - pageSize?: number; - nextPageKey?: string; -} - -export interface ListEntitiesResponse { - entities?: Entity[]; - totalCount?: number; - pageSize?: number; - nextPageKey?: string; -} - // Could be a list or a map; hence using 'any' // e.g. see example response body at https://docs.dynatrace.com/docs/discover-dynatrace/references/dynatrace-api/environment-api/entity-v2/get-entity export interface GetEntityRelationshipsResponse { @@ -53,19 +39,6 @@ export interface Tag { value?: string; } -export interface Relationship { - id?: string; - type?: string; - fromEntityId?: string; - toEntityId?: string; -} - -export interface EntityType { - type?: string; - displayName?: string; - properties?: string[]; -} - export class EntitiesApiClient { static readonly API_PAGE_SIZE = 100; static readonly MAX_TAGS_DISPLAY = 11; @@ -74,7 +47,7 @@ export class EntitiesApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listEntityTypes(environment_aliases?: string): Promise<[]> { + async listEntityTypes(environment_aliases?: string): Promise { // Deliberately large page size; will format this concisely rather than returning all json in tool response. // Want to get all of them (with reason), otherwise trying to pull out common types won't work. const params: Record = { @@ -85,23 +58,23 @@ export class EntitiesApiClient { return responses; } - async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise { + async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise { const responses = await this.authManager.makeRequests(`/api/v2/entityTypes/${encodeURIComponent(entityType)}`, undefined, environment_aliases); logger.debug(`getEntityTypeDetails response, entityType=${entityType}`, { data: responses }); return responses; } - async getEntityDetails(entityId: string, environment_aliases?: string): Promise { + async getEntityDetails(entityId: string, environment_aliases?: string): Promise { const responses = await this.authManager.makeRequests(`/api/v2/entities/${encodeURIComponent(entityId)}`, undefined, environment_aliases); logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: responses }); return responses; } - async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { + async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { return await this.getEntityDetails(entityId, environment_aliases); } - async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise<[]> { + async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise { const queryParams = { pageSize: params.pageSize || EntitiesApiClient.API_PAGE_SIZE, entitySelector: params.entitySelector, @@ -116,7 +89,7 @@ export class EntitiesApiClient { return responses; } - formatEntityList(responses: {'alias': string, 'data': ListEntitiesResponse}[]): string { + formatEntityList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumEntities = 0; let anyLimited = false @@ -188,7 +161,7 @@ export class EntitiesApiClient { return result; } - formatEntityTypeList(responses: {'alias': string, 'data': ListEntityTypesResponse}[]): string { + formatEntityTypeList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumTypes = 0; const commonTypes = [ @@ -246,7 +219,7 @@ export class EntitiesApiClient { return result; } - formatEntityTypeDetails(responses: {'alias': string, 'data': string}[]): string { + formatEntityTypeDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Entity type details from environment ' + response.alias + ' in the following json:\n' + @@ -257,7 +230,7 @@ export class EntitiesApiClient { return result; } - formatEntityDetails(responses: {'alias': string, 'data': string}[]): string { + formatEntityDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Entity details from environment ' + response.alias + ' in the following json:\n' + @@ -270,7 +243,7 @@ export class EntitiesApiClient { return result; } - formatEntityRelationships(responses: {'alias': string, 'data': GetEntityRelationshipsResponse}[]): string { + formatEntityRelationships(responses: EnvironmentResponse[]): string { let result = ''; for (const response of responses) { const from = response.data.fromRelationships; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index c044453..9479e78 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -11,13 +11,6 @@ export interface EventQueryParams { pageSize?: number; } -export interface ListEventsResponse { - events?: Event[]; - totalCount?: number; - pageSize?: number; - nextPageKey?: string; -} - export interface Event { eventId: string; eventType: string; @@ -38,7 +31,7 @@ export class EventsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise<[]> { + async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise { const queryParams = { from: params.from, to: params.to, @@ -52,13 +45,13 @@ export class EventsApiClient { return responses; } - async getEventDetails(eventId: string, environment_aliases?: string): Promise { + async getEventDetails(eventId: string, environment_aliases?: string): Promise { const responses = await this.authManager.makeRequests(`/api/v2/events/${encodeURIComponent(eventId)}`, undefined, environment_aliases); logger.debug('getEventDetails response: ', { data: responses }); return responses; } - formatList(responses: {'alias': string, 'data': ListEventsResponse}[]): string { + formatList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumEvents = 0; let anyLimited = false @@ -134,7 +127,7 @@ export class EventsApiClient { return result; } - formatDetails(responses: {'alias': string, 'data': string}[]): string { + formatDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Event details from environment ' + response.alias + ' in the following json:\n' + diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 8f159ae..bd03521 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -11,12 +11,6 @@ export interface LogQueryParams { environment_aliases?: ''; } -export interface ListLogsResponse { - results?: LogEntry[]; - sliceSize?: number; - nextSliceKey?: string; -} - export interface LogEntry { timestamp: number; // API returns integer UTC milliseconds content: string; @@ -34,7 +28,7 @@ export class LogsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise<[]> { + async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise { const queryParams = { query: params.query || '', from: params.from, @@ -48,7 +42,7 @@ export class LogsApiClient { return responses; } - formatList(responses: {'alias': string, 'data': ListLogsResponse}[]): string { + formatList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumLogs = 0; let anyLimited = false diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index 68a0aa2..b5344af 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; export interface MetricListParams { @@ -19,29 +19,6 @@ export interface MetricQueryParams { entitySelector?: string; } -export interface ListMetricsResponse { - metrics?: Metric[]; - totalCount?: number; - nextPageKey?: string; -} - -export interface MetricDataResponse { - result?: Array<{ - data?: Array<{ - timestamps?: number[]; - vaules?: any[]; - dimensionMap?: Record; - dimensions?: string[]; - }>; - dataPointCountRatio?: number; - dimensionCountRatio?: number; - metricId?: string; - }>; - resolution?: string; - totalCount?: number; - nextPageKey?: string; -} - export interface Metric { metricId?: string; displayName?: string; @@ -64,7 +41,7 @@ export class MetricsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listAvailableMetrics(params: MetricListParams = {}, environment_aliases?: string): Promise<[]> { + async listAvailableMetrics(params: MetricListParams = {}, environment_aliases?: string): Promise { const queryParams = { pageSize: params.pageSize || MetricsApiClient.API_PAGE_SIZE, ...(params.entitySelector && { entitySelector: params.entitySelector }), @@ -80,13 +57,13 @@ export class MetricsApiClient { return responses; } - async getMetricDetails(metricId: string, environment_aliases?: string): Promise<[]> { - const response = await this.authManager.makeRequests(`/api/v2/metrics/${encodeURIComponent(metricId)}`, undefined, environment_aliases); - logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: response }); - return response; + async getMetricDetails(metricId: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/metrics/${encodeURIComponent(metricId)}`, undefined, environment_aliases); + logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: responses }); + return responses; } - async queryMetrics(params: MetricQueryParams, environment_aliases?: string): Promise<[]> { + async queryMetrics(params: MetricQueryParams, environment_aliases?: string): Promise { const queryParams = { metricSelector: params.metricSelector, resolution: params.resolution || 'Inf', @@ -95,12 +72,12 @@ export class MetricsApiClient { ...(params.entitySelector && { entitySelector: params.entitySelector }), }; - const response = await this.authManager.makeRequests('/api/v2/metrics/query', queryParams, environment_aliases); - logger.debug(`queryMetrics response, params=${JSON.stringify(params)}`, { data: response }); - return response; + const responses = await this.authManager.makeRequests('/api/v2/metrics/query', queryParams, environment_aliases); + logger.debug(`queryMetrics response, params=${JSON.stringify(params)}`, { data: responses }); + return responses; } - formatMetricList(responses: {'alias': string, 'data': ListMetricsResponse}[]): string { + formatMetricList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumMetrics = 0; let anyLimited = false @@ -156,7 +133,7 @@ export class MetricsApiClient { return result; } - formatMetricDetails(responses: {'alias': string, 'data': string}[]): string { + formatMetricDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Details of metric from environment ' + response.alias + ' in the following json:\n' + @@ -168,7 +145,7 @@ export class MetricsApiClient { return result; } - formatMetricData(responses: {'alias': string, 'data': MetricDataResponse}[]): string { + formatMetricData(responses: EnvironmentResponse[]): string { let result = ""; let allEmpty = true; for (const response of responses) { diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index 25f5300..40d2318 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -73,7 +73,7 @@ export class ProblemsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listProblems(params: ProblemQueryParams = {}, environment_aliases? : string): Promise<[]> { + async listProblems(params: ProblemQueryParams = {}, environment_aliases? : string): Promise { const queryParams = { pageSize: params.pageSize || ProblemsApiClient.API_PAGE_SIZE, ...(params.from && { from: params.from }), @@ -84,20 +84,20 @@ export class ProblemsApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authManager.makeRequests('/api/v2/problems', queryParams, environment_aliases); + const responses = await this.authManager.makeRequests('/api/v2/problems', queryParams, environment_aliases); - logger.debug('listProblems response', { data: response }); - return response; + logger.debug('listProblems response', { data: responses }); + return responses; } - async getProblemDetails(problemId: string, environment_aliases? : string): Promise { - const response = await this.authManager.makeRequests(`/api/v2/problems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); + async getProblemDetails(problemId: string, environment_aliases? : string): Promise { + const responses = await this.authManager.makeRequests(`/api/v2/problems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); - logger.debug('getProblemDetails response', { data: response }); - return response; + logger.debug('getProblemDetails response', { data: responses }); + return responses; } - formatList(responses: {'alias': string, 'data': ListProblemResponse}[]): string { + formatList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumProblems = 0; let anyLimited = false @@ -148,7 +148,7 @@ export class ProblemsApiClient { return result; } - formatDetails(responses: {'alias': string, 'data': string}[]): string { + formatDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Details of problem from environment ' + response.alias + ' in the following json:\n' + diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 5581988..8a7729a 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -13,13 +13,6 @@ export interface SecurityProblemQueryParams { sort?: string; } -export interface ListSecurityProblemsResponse { - securityProblems?: SecurityProblem[]; - totalCount?: number; - pageSize?: number; - nextPageKey?: string; -} - export interface SecurityProblem { securityProblemId?: string; displayId?: string; @@ -59,29 +52,13 @@ export interface SecurityProblem { lastUpdatedTimestamp?: number; } -export interface SecurityProblemDetail extends SecurityProblem { - description?: string; - remediationItems?: Array<{ - id: string; - name: string; - type: string; - state: string; - }>; - events?: Array<{ - eventType: string; - timestamp: number; - entityId?: string; - }>; - codeLocations?: any[]; -} - export class SecurityApiClient { static readonly API_PAGE_SIZE = 200; static readonly MAX_CVES_DISPLAY = 11; constructor(private authManager: ManagedAuthClientManager) {} - async listSecurityProblems(params: SecurityProblemQueryParams = {}, environment_aliases?: string): Promise<[]> { + async listSecurityProblems(params: SecurityProblemQueryParams = {}, environment_aliases?: string): Promise { const queryParams = { pageSize: params.pageSize || SecurityApiClient.API_PAGE_SIZE, ...(params.riskLevel && { riskLevel: params.riskLevel }), @@ -97,13 +74,13 @@ export class SecurityApiClient { return responses; } - async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise { + async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise { const responses = await this.authManager.makeRequests(`/api/v2/securityProblems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); logger.debug('getSecurityProblemDetails response', { data: responses }); return responses; } - formatList(responses: {'alias': string, 'data': ListSecurityProblemsResponse}[]): string { + formatList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumProblems = 0; let anyLimited = false @@ -168,7 +145,7 @@ export class SecurityApiClient { return result; } - formatDetails(responses: {'alias': string, 'data': string}[]): string { + formatDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Details of security problem from environment ' + response.alias + ' in the following json:\n' + diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index b7c4cd8..6702481 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -1,4 +1,4 @@ -import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; @@ -22,13 +22,6 @@ export interface GetSloQueryParams { timeFrame?: string; } -export interface ListSlosResponse { - slo?: SLO[]; - totalCount?: number; - pageSize?: number; - nextPageKey?: string; -} - export interface SLO { id?: string; name?: string; @@ -71,7 +64,7 @@ export class SloApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise<[]> { + async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise { const queryParams: Record = { pageSize: params.pageSize || SloApiClient.API_PAGE_SIZE, ...(params.sloSelector && { sloSelector: params.sloSelector }), @@ -90,7 +83,7 @@ export class SloApiClient { return responses; } - async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise { + async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise { const queryParams: Record = { ...(params.from && { from: params.from }), ...(params.to && { to: params.to }), @@ -105,7 +98,7 @@ export class SloApiClient { return responses; } - formatList(responses: {'alias': string, 'data': ListSlosResponse}[]): string { + formatList(responses: EnvironmentResponse[]): string { let result = ""; let totalNumSlo = 0; let anyLimited = false @@ -167,7 +160,7 @@ export class SloApiClient { return result; } - formatDetails(responses: {'alias': string, 'data': string}[]): string { + formatDetails(responses: EnvironmentResponse[]): string { let result = ""; for (const response of responses) { result += 'Details of SLO from environment ' + response.alias + ' in the following json:\n' + diff --git a/src/index.ts b/src/index.ts index 1d07004..93538e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { Command } from 'commander'; import { z, ZodRawShape, ZodTypeAny } from 'zod'; import { getPackageJsonVersion } from './utils/version'; import { ManagedAuthClientManager } from './authentication/managed-auth-client'; -import { getManagedEnvironmentConfigs } from './utils/environment'; +import { getManagedEnvironmentConfigs, validateEnvironments } from './utils/environment'; import { createTelemetry } from './utils/telemetry-openkit'; import { MetricsApiClient } from './capabilities/metrics-api'; import { LogsApiClient } from './capabilities/logs-api'; @@ -21,6 +21,7 @@ import { SloApiClient } from './capabilities/slo-api'; // Import logger after environment is loaded import { logger } from './utils/logger'; +import { transport } from 'winston'; logger.info('Starting Dynatrace Managed MCP'); @@ -43,17 +44,26 @@ const main = async () => { logger.info(`Initializing Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`); // Read Managed environment configuration - let managedConfigs; - try { - managedConfigs = getManagedEnvironmentConfigs(); - } catch (err) { - console.error('Failed to get managed environments configuration: ', err); - logger.error('Failed to get managed environments configuration: ', { error: err }); + + const managedConfigs = getManagedEnvironmentConfigs(); + const validatedConfigs = validateEnvironments(managedConfigs); + + const initErrors = validatedConfigs['errors'] + const initConfigs = validatedConfigs['valid_configs'] + + if (initErrors.length > 0) { + logger.error('Failed to get managed environments configurations: ', { error: initErrors }); + console.error('Failed to get managed environments configurations: ', { error: initErrors }); + } + + if (initConfigs.length === 0) { + logger.error('No valid environments found, stopping.') + console.error('No valid environments found, stopping.'); process.exit(1); } - const authClientManager = new ManagedAuthClientManager(managedConfigs); - await authClientManager.validateClients() + const authClientManager = new ManagedAuthClientManager(initConfigs); + await authClientManager.isConfigured() // Initialize API clients const metricsClient = new MetricsApiClient(authClientManager); @@ -118,7 +128,9 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - Use specific time ranges (1-2 hours) rather than large historical queries for better performance - Leverage entity selectors to filter data at the source - they are fundamental to getting good results - Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) -- Start with get_environments_info to understand cluster capabilities and data range +- Start with get_environments_info to understand connection errors, cluster capabilities and data range. **CRITICAL: Breakdown connection issues to the user before any other request**. +- On every request, an "environment_alias" must be passed. +- If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" - **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText - **Avoid searchText guessing** - only use searchText when user explicitly mentions keywords to search for - **discover_entities ALWAYS requires entitySelector** - never call this tool without providing an entitySelector with exactly ONE entity type like type("SERVICE") unless using an EntityId. Multiple entity types are NOT supported. @@ -255,18 +267,41 @@ Never run queries that could return very large amounts of data, or that could be server.tool(name, description, paramsSchema, annotations, (args: z.ZodRawShape) => wrappedCb(args)); }; - const envAliasValidate = (alias: string | undefined) => { - if (alias) { - const env_list = alias.split(';'); - for (const env_alias of env_list) { - if (!authClientManager.validAliases.includes(env_alias)) { - return false; - } + const envAliasValidate = (alias: string) => { + if (alias == "ALL_ENVIRONMENTS") { + return true; + } + const env_list = alias.split(';'); + for (const env_alias of env_list) { + if (!authClientManager.validAliases.includes(env_alias)) { + return false; } } return true } + tool( + 'dynatrace_managed_check_for_configuration_errors', + 'Returns information about environment configurations and any potential error found during initialization', + {}, + { + readOnlyHint: true, + }, + async({}) => { + let resp = `Dynatrace Managed Environments Information - Listing configuration errors found during initialization:\n\n`; + if (initErrors.length > 0) { + resp += `Issues where found in environment configurations during start up: \n`; + for (const errorMessage of initErrors) { + resp += `- ${errorMessage}\n`; + } + resp += `\nPlease review all environment information and try again. \n`; + } + + return resp; + } + ) + + tool( 'dynatrace_managed_get_environments_info', 'Get information about all connected Dynatrace Managed clusters and verify the connections and authentication services.', @@ -275,7 +310,7 @@ Never run queries that could return very large amounts of data, or that could be readOnlyHint: true, }, async ({}) => { - let resp = `Dynatrace Managed Cluster Information - Listing info for all ${authClientManager.rawClients.length} environments:\n\n`; + let resp = `Dynatrace Managed Cluster Information - Listing info for ${authClientManager.rawClients.length} environments:\n\n`; for (let authClient of authClientManager.rawClients) { resp += `- Environment Alias: ${authClient.alias}\n`; @@ -295,7 +330,16 @@ Never run queries that could return very large amounts of data, or that could be resp += `- Error message: ${authClient.validationError}\n`; } } - resp += `\n\nAll Dynatrace Managed Cluster Environments listed. Value of "Valid environment" is set to "No" for invalid environments.\n\n`; + + if (initErrors.length > 0) { + resp += `Issues were found in environment configurations during start up: \n`; + for (const errorMessage of initErrors) { + resp += `- ${errorMessage}\n`; + } + resp += `\nPlease review all environments connection information. \n`; + } + + resp += `\n\n\nAll Dynatrace Managed Cluster Environments listed. Environment showing connection errors and environments with "Valid environment" set to "No" are invalid environments.\n\n`; return resp; }, @@ -333,7 +377,7 @@ Never run queries that could return very large amounts of data, or that could be (e.g., "first 16 metrics" → limit: 16, "500 metrics" → limit: 500). If not specified, returns up to API limit: ${MetricsApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -384,7 +428,7 @@ Never run queries that could return very large amounts of data, or that could be type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`, ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -412,7 +456,7 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific metric.', { metricId: z.string().describe('The metric ID to get details for'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -442,7 +486,7 @@ Never run queries that could return very large amounts of data, or that could be to: z.string().describe('End time (ISO format or relative like "now")'), limit: z.number().optional().describe('Maximum number of logs to return (default: 100)'), sort: z.string().optional().describe('Sort order for logs. Use "-timestamp" for most recent first.'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -493,7 +537,7 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of events to return. Use this when user specifies a count (e.g., "first 20 events" → limit: 20). If not specified, returns up to API limit: ${EventsApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -521,7 +565,7 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific event.', { eventId: z.string().describe('The event ID to get details for'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -541,7 +585,7 @@ Never run queries that could return very large amounts of data, or that could be 'dynatrace_managed_list_entity_types', 'List all available entity types in the Managed cluster to understand what types of entities can be monitored.', { - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -562,7 +606,7 @@ Never run queries that could return very large amounts of data, or that could be 'Get details of an entity type.', { type: z.string().describe('Name of the entity type, such as SERVICE, APPLICATION, HOST, etc'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -616,7 +660,7 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe('Sort order for entities. Use "name" for ascending, "-name" for descending by display name.'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -644,7 +688,7 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific entity.', { entityId: z.string().describe('The entity ID to get details for'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -665,7 +709,7 @@ Never run queries that could return very large amounts of data, or that could be 'Get relationships that a specific entity has "to" and "from" other entities.', { entityId: z.string().describe('The entity ID to get relationships for'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -715,7 +759,7 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Use "+status" (open first), "-status" (closed first), "+startTime" (old first), "-startTime" (new first), or "+relevance"/"-relevance" (with text search).', ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -747,7 +791,7 @@ Never run queries that could return very large amounts of data, or that could be problemId: z .string() .describe('The internal problem ID (UUID format) from list_problems - NOT the displayId (P-XXXXX)'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -789,7 +833,7 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Examples: "+status" (open first), "-riskAssessment.riskScore" (highest risk first), "+firstSeenTimestamp" (newest first), "-lastUpdatedTimestamp" (recently updated first).', ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -821,7 +865,7 @@ Never run queries that could return very large amounts of data, or that could be securityProblemId: z .string() .describe('The security problem ID (UUID format) from list_security_problems - NOT the displayId (S-XXXXX)'), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -883,7 +927,7 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of SLOs to return. Use this when user specifies a count (e.g., "first 15 SLOs" → limit: 15). If not specified, returns up to API limit: ${SloApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") @@ -926,7 +970,7 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Time frame for SLO evaluation: "CURRENT" for SLO\'s own timeframe, "GTF" for custom timeframe specified by from and to parameters', ), - environment_alias: z.string().optional().describe('Specifically hits one environment. Blank for all.') + environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') .refine((alias) => envAliasValidate(alias), { message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 32abdef..e64d7b6 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -11,29 +11,13 @@ export interface ManagedEnvironmentConfig { } export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): ManagedEnvironmentConfig { - const environmentIdRaw = environmentInfo.environmentId.toString(); - const apiUrlRaw = environmentInfo.apiEndpointUrl.toString(); - const dashboardUrlRaw = environmentInfo.dynatraceUrl.toString(); - const apiToken = environmentInfo.apiToken.toString(); - const alias = environmentInfo.alias.toString(); - const httpProxy = environmentInfo.httpProxyUrl?.toString(); - const httpsProxy = environmentInfo.httspProxyUrl?.toString(); - - if (!environmentIdRaw) { - throw new Error('environmentId is required'); - } - - if (!apiUrlRaw) { - throw new Error('apiEndpointUrl is required'); - } - - if (!apiToken) { - throw new Error('apiToken is required'); - } - - if (!alias) { - throw new Error('`alias` is required'); - } + const environmentIdRaw = environmentInfo.environmentId ? environmentInfo.environmentId.toString(): ""; + const apiUrlRaw = environmentInfo.apiEndpointUrl ? environmentInfo.apiEndpointUrl.toString(): ""; + const dashboardUrlRaw = environmentInfo.dynatraceUrl ? environmentInfo.dynatraceUrl.toString(): ""; + const apiToken = environmentInfo.apiToken ? environmentInfo.apiToken.toString() : ""; + const alias = environmentInfo.alias ? environmentInfo.alias.toString() : ""; + const httpProxy = environmentInfo.httpProxyUrl? environmentInfo.httpProxyUrl.toString(): ""; + const httpsProxy = environmentInfo.httpsProxyUrl? environmentInfo.httpsProxyUrl.toString(): ""; let environmentId = environmentIdRaw.replace(/\/$/, ''); // Remove trailing slash let apiUrl = apiUrlRaw + (apiUrlRaw.endsWith('/') ? '' : '/') + 'e/' + environmentId; @@ -51,30 +35,58 @@ export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): Mana }; } - export function getManagedEnvironmentConfigs(): ManagedEnvironmentConfig[] { const environmentConfigs = process.env.DT_ENVIRONMENT_CONFIGS; if (!environmentConfigs) { throw new Error('DT_ENVIRONMENT_CONFIGS is required'); } - let parsedConfig; - console.log('we in'); + let environmentConfigurations; try { - parsedConfig = JSON.parse(environmentConfigs); + environmentConfigurations = JSON.parse(environmentConfigs); } catch (e) { if (e instanceof SyntaxError) { - throw new Error(`JSON syntax error: ${e}`); + throw new Error(`JSON syntax error in environment file: ${e}`); } else { throw e; } } - - let validConfigurations = [] - for (let env of parsedConfig) { - validConfigurations.push( - parseManagedEnvironmentConfig(env) + let parsedManagedEnvironmentConfigs: ManagedEnvironmentConfig[] = []; + for (const environmentConfig of environmentConfigurations) { + parsedManagedEnvironmentConfigs.push( + parseManagedEnvironmentConfig(environmentConfig) ) } + return parsedManagedEnvironmentConfigs +} + +export function validateEnvironments(environmentConfigurations: ManagedEnvironmentConfig[]): {"valid_configs": ManagedEnvironmentConfig[], "errors": string[]} { + const requiredKeys = ['dashboardUrl', 'apiUrl', 'environmentId', 'alias', 'apiToken']; + const originalKeys = {'dashboardUrl': 'dynatraceUrl', 'apiUrl': 'apiEndpointUrl', 'environmentId': 'environmentId', 'alias': 'alias', 'apiToken': 'apiToken'}; + let validConfigurations: ManagedEnvironmentConfig[] = []; + let errors: string[] = []; + + environmentConfigurations.forEach((configuration, index) => { + const hasAllValues = requiredKeys.every(key => { + const value = configuration[key as keyof typeof configuration]; + + const isValid = value && value.length > 0; + if (!isValid) { + errors.push( + `Key "${originalKeys[key as keyof typeof originalKeys]}" is empty or missing (environment #${index}, alias: ${configuration.alias ? configuration.alias : 'N/A'}). Please make sure all values are present and populated in the configuration array.` + ) + } + return isValid; + }); + const validAlias = configuration.alias.indexOf(';') == -1; + if (!validAlias) { + errors.push( + 'Invalid alias found: "' + configuration.alias +'". Aliases are mandatory and cannot contain semicolons.' + ) + } + if (validAlias && hasAllValues) { + validConfigurations.push(configuration) + } + }) - return validConfigurations; + return {"valid_configs": validConfigurations, "errors": errors} } From 3eef85b47cc7a17a665f31600eff8aca5dc336ff Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Wed, 21 Jan 2026 16:12:44 +0000 Subject: [PATCH 04/17] - Fixed unit tests to pass with the new logic - Reworked environment.test.ts to touch more cases with the new multienvironment approach. - Fixed 2 out of 4 integrations test files (working on those other 2) - Fixed getEntityRelationships to return expected response structure - Fixed ManagedAuthClient to instance proxy data on creation - Removed 'dynatraceUrl' from required configuration fields. - Updated .env.template --- .env.template | 33 +- .../__tests__/managed-auth-client.test.ts | 2 + src/authentication/__tests__/proxy.test.ts | 23 +- src/authentication/managed-auth-client.ts | 44 +- .../__tests__/entities-api.test.ts | 407 +++++++++++------- src/capabilities/__tests__/events-api.test.ts | 213 +++++---- src/capabilities/__tests__/logs-api.test.ts | 227 ++++++---- .../__tests__/metrics-api.test.ts | 335 +++++++++----- .../__tests__/problems-api.test.ts | 184 +++++--- .../__tests__/security-api.test.ts | 233 ++++++---- src/capabilities/__tests__/slo-api.test.ts | 316 +++++++++----- src/capabilities/entities-api.ts | 79 +++- src/capabilities/metrics-api.ts | 55 ++- src/utils/__tests__/environment.test.ts | 195 ++++++--- src/utils/environment.ts | 52 ++- .../capabilities.integration.test.ts | 272 +++++++----- tests/integration/proxy.integration.test.ts | 5 +- 17 files changed, 1731 insertions(+), 944 deletions(-) diff --git a/.env.template b/.env.template index eb002cc..9720976 100644 --- a/.env.template +++ b/.env.template @@ -1,23 +1,24 @@ ## Dynatrace Managed Configuration # Proxy configuration optional (for corporate environments) +# Mandatory keys are: "apiEndpointUrl", "environmentId", "alias" and "apiToken" + DT_ENVIRONMENT_CONFIGS='[ -{ - "dynatraceUrl": "https://dmz456.dynatrace-managed.com", - "apiEndpointUrl": "https://dmz456-ag.dynatrace-managed.com", - "environmentId": "abcdef-0124-4567-890a-bcdef0124", - "alias": "ENVIRONMENT 1", - "apiToken": "dt0s16.SAMPLE.efgh4567", - "httpProxyUrl": "", - "httpsProxyUrl": "" + { + "dynatraceUrl": "https://my-dashboard-endpoint.com/", + "apiEndpointUrl": "https://my-api-endpoint.com/", + "environmentId": "my-env-id-1", + "alias": "alias-env", + "apiToken": "my-api-token", + "httpProxyUrl": ""' + "httpsProxyUrl": ""' }, { - "dynatraceUrl": "https://dmz456.dynatrace-managed.com", - "apiEndpointUrl": "https://dmz456-ag.dynatrace-managed.com", - "environmentId": "abcdef-0124-4567-890a-bcdef0124", - "alias": "ENVIRONMENT 2", - "apiToken": "dt0s16.SAMPLE.efgh4567", - "httpProxyUrl": "", - "httpsProxyUrl": "" + "dynatraceUrl": "https://my-dashboard2-endpoint.com/", + "apiEndpointUrl": "https://my-api2-endpoint.com/", + "environmentId": "my-env-id-2", + "alias": "alias-env-2", + "apiToken": "my-api-token-2", + "httpProxyUrl": ""' + "httpsProxyUrl": ""' } ]' - diff --git a/src/authentication/__tests__/managed-auth-client.test.ts b/src/authentication/__tests__/managed-auth-client.test.ts index 51c9b17..95c13a4 100644 --- a/src/authentication/__tests__/managed-auth-client.test.ts +++ b/src/authentication/__tests__/managed-auth-client.test.ts @@ -17,6 +17,7 @@ describe('ManagedAuthClient', () => { apiBaseUrl: 'https://managed.test.com', dashboardBaseUrl: 'https://managed-dashboard.test.com', apiToken: 'test-token', + alias: 'testAlias', }); }); @@ -50,6 +51,7 @@ describe('ManagedAuthClient', () => { apiBaseUrl: 'https://managed.test.com', dashboardBaseUrl: 'https://managed-dashboard.test.com', apiToken: 'test-token', + alias: 'testAlias', }); const result = await client.validateConnection(); diff --git a/src/authentication/__tests__/proxy.test.ts b/src/authentication/__tests__/proxy.test.ts index 61ed3d8..d070ce8 100644 --- a/src/authentication/__tests__/proxy.test.ts +++ b/src/authentication/__tests__/proxy.test.ts @@ -1,4 +1,4 @@ -import { getAxiosProxyFromEnv } from '../managed-auth-client'; +import { setAxiosProxy } from '../managed-auth-client'; // Mock undici describe('proxy-config', () => { @@ -21,7 +21,7 @@ describe('proxy-config', () => { describe('configureProxyFromEnvironment', () => { it('should parse HTTP_PROXY', () => { process.env.HTTP_PROXY = 'http://myhost.com:1234'; - const response = getAxiosProxyFromEnv(); + const response = setAxiosProxy(process.env.HTTP_PROXY); expect(response).toEqual({ host: 'myhost.com', @@ -33,7 +33,7 @@ describe('proxy-config', () => { it('should parse HTTPS_PROXY', () => { process.env.HTTPS_PROXY = 'https://myhost.com:1234'; - const response = getAxiosProxyFromEnv(); + const response = setAxiosProxy(process.env.HTTPS_PROXY); expect(response).toEqual({ host: 'myhost.com', @@ -45,7 +45,7 @@ describe('proxy-config', () => { it('should parse auth', () => { process.env.HTTP_PROXY = 'http://myuser:mypass@myhost.com:1234'; - const response = getAxiosProxyFromEnv(); + const response = setAxiosProxy(process.env.HTTP_PROXY); expect(response).toEqual({ host: 'myhost.com', @@ -56,25 +56,22 @@ describe('proxy-config', () => { }); it('should return undefined if no proxy', () => { - const response = getAxiosProxyFromEnv(); + const response = setAxiosProxy(); expect(response).toBeUndefined(); }); - it('should fail if set HTTP_PROXY and HTTPS_PROXY', () => { + it('should return undefined if set HTTP_PROXY and HTTPS_PROXY', () => { process.env.HTTP_PROXY = 'http://myuser:mypass@myhost.com:1234'; process.env.HTTPS_PROXY = 'https://myuser:mypass@myhost.com:4321'; - try { - const response = getAxiosProxyFromEnv(); - fail(`Should have failed, but returned response=${response}`); - } catch (err: any) { - expect(err.message).toContain('Cannot specify both HTTPS_PROXY and HTTP_PROXY, use only one'); - } + + const response = setAxiosProxy(); + expect(response).toBeUndefined(); }); it('should fail if invalid URL', () => { process.env.HTTP_PROXY = 'this is not a url'; try { - const response = getAxiosProxyFromEnv(); + const response = setAxiosProxy(process.env.HTTP_PROXY); fail(`Should have failed, but returned response=${response}`); } catch (err: any) { expect(err.message).toContain('Failed to parse and configure http(s) proxy'); diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index c21f2ea..9292551 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -38,9 +38,9 @@ export class ManagedAuthClientManager { public validAliases: string[] = []; constructor(managedEnvironments: ManagedEnvironmentConfig[]) { - this.rawClients = [] - this.clients = [] - this.validAliases = ["ALL_ENVIRONMENTS",] + this.rawClients = []; + this.clients = []; + this.validAliases = ['ALL_ENVIRONMENTS']; logger.warn('Validating Environments'); for (let managedEnvironment of managedEnvironments) { @@ -48,21 +48,27 @@ export class ManagedAuthClientManager { apiBaseUrl: managedEnvironment.apiUrl, dashboardBaseUrl: managedEnvironment.dashboardUrl, apiToken: managedEnvironment.apiToken, - alias: managedEnvironment.alias + alias: managedEnvironment.alias, + httpProxy: managedEnvironment.httpProxy, + httpsProxy: managedEnvironment.httpsProxy, }); this.rawClients.push(newClient); } } - async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { - let responses = [] + async makeRequests( + endpoint: string, + params?: Record, + environments?: string, + ): Promise { + let responses = []; const selectedAliases = environments ? environments.split(';') : this.validAliases; for (const client of this.clients) { if (selectedAliases.indexOf(client.alias) > -1) { responses.push({ - "alias": client.alias, - "data": await client.makeRequest(endpoint, params) + alias: client.alias, + data: await client.makeRequest(endpoint, params), }); } } @@ -71,7 +77,7 @@ export class ManagedAuthClientManager { async isConfigured(): Promise { for (let client of this.rawClients) { - let validClient = await client.isConfigured() + let validClient = await client.isConfigured(); if (validClient) { client.isValid = true; this.clients.push(client); @@ -117,7 +123,10 @@ export class ManagedAuthClient { const response = await this.httpClient.get('/api/v1/config/clusterversion'); return response.status === 200; } catch (error) { - logger.error(`[Alias: ${this.alias}] Failed calling /api/v1/config/clusterversion; falling back to /api/v2/metrics`, { error: error }); + logger.error( + `[Alias: ${this.alias}] Failed calling /api/v1/config/clusterversion; falling back to /api/v2/metrics`, + { error: error }, + ); // Fallback: try a basic API endpoint that exists in both SaaS and Managed try { const response = await this.httpClient.get('/api/v2/metrics', { params: { pageSize: 1 } }); @@ -194,27 +203,30 @@ export class ManagedAuthClient { const isValidVersion = this.validateMinimumVersion(clusterVersion); if (!isValidVersion) { - const invalidVersionMessage = `Cluster "${this.alias}" version ${clusterVersion.version} may not support all features. Minimum recommended version is ${this.MINIMUM_VERSION}` + const invalidVersionMessage = `Cluster "${this.alias}" version ${clusterVersion.version} may not support all features. Minimum recommended version is ${this.MINIMUM_VERSION}`; logger.info(invalidVersionMessage); - this.validationError = invalidVersionMessage + this.validationError = invalidVersionMessage; return false; } return true; } catch (error: any) { - logger.error(`[CONNECTION ERROR] Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`); + logger.error( + `[CONNECTION ERROR] Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`, + ); logger.error('Please verify:'); logger.error('1. DT_MANAGED_ENVIRONMENTS is correct'); logger.error(`2. API Token has required scopes: ${MANAGED_API_SCOPES.join(', ')}`); logger.error('3. Network connectivity to the Managed cluster'); - this.validationError = `Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}. Please verify connection details are correct.` + this.validationError = `Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}. Please verify connection details are correct.`; return false; } } } -export function setAxiosProxy(httpProxy = "", httpsProxy = ""): AxiosProxyConfig | undefined { +export function setAxiosProxy(httpProxy = '', httpsProxy = ''): AxiosProxyConfig | undefined { if (httpsProxy && httpProxy) { - throw Error('Cannot specify both HTTPS_PROXY and HTTP_PROXY, use only one.'); + logger.error('Cannot specify both HTTPS_PROXY and HTTP_PROXY, use only one.'); + return undefined; } else if (!httpsProxy && !httpProxy) { // No proxy configured, nothing to do return undefined; diff --git a/src/capabilities/__tests__/entities-api.test.ts b/src/capabilities/__tests__/entities-api.test.ts index 9804c18..c22e9ae 100644 --- a/src/capabilities/__tests__/entities-api.test.ts +++ b/src/capabilities/__tests__/entities-api.test.ts @@ -1,18 +1,18 @@ import { EntitiesApiClient, Entity, GetEntityRelationshipsResponse } from '../entities-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); describe('EntitiesApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: EntitiesApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new EntitiesApiClient(mockAuthClient); + client = new EntitiesApiClient(mockAuthManager); }); afterEach(() => { @@ -21,39 +21,48 @@ describe('EntitiesApiClient', () => { describe('getEntityDetails', () => { it('should get entity details by ID', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.getEntityDetails('SERVICE-123'); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + const result = await client.getEntityDetails('SERVICE-123', 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123', undefined, 'testAlias'); expect(result).toEqual(mockResponse); }); }); describe('getEntityRelationships', () => { it('should get entity relationships', async () => { - const mockEntity: Entity = { - entityId: 'SERVICE-123', - displayName: 'payment-service', - entityType: 'SERVICE', - fromRelationships: [ - { - id: 'rel-1', - type: 'CALLS', - fromEntityId: 'SERVICE-123', - toEntityId: 'SERVICE-456', + const mockEntity: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + entityId: 'SERVICE-123', + displayName: 'payment-service', + entityType: 'SERVICE', + fromRelationships: [ + { + id: 'rel-1', + type: 'CALLS', + fromEntityId: 'SERVICE-123', + toEntityId: 'SERVICE-456', + }, + ], + toRelationships: [ + { + id: 'rel-2', + type: 'RUNS_ON', + fromEntityId: 'SERVICE-123', + toEntityId: 'HOST-789', + }, + ], }, - ], - toRelationships: [ - { - id: 'rel-2', - type: 'RUNS_ON', - fromEntityId: 'SERVICE-123', - toEntityId: 'HOST-789', - }, - ], - }; + }, + ]; const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: [ @@ -74,48 +83,58 @@ describe('EntitiesApiClient', () => { ], }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); - + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); const result = await client.getEntityRelationships('SERVICE-123'); - - expect(result).toEqual(expectedResponse); + expect(result[0].data).toEqual(expectedResponse); }); }); describe('formatEntityDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getEntityDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getEntityDetails('my-id'); + const mockResponse = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityDetails.json', 'utf8')), + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getEntityDetails('my-id', 'testAlias'); const result = client.formatEntityDetails(response); - expect(result).toContain('Entity details in the following json'); + expect(result).toContain('Entity details from environment testAlias in the following json'); expect(result).toContain('"type":"SERVICE"'); expect(result).toContain('"displayName":"Service"'); }); it('should format details when sparse problem', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getEntityDetails('my-id'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getEntityDetails('my-id', 'testAlias'); const result = client.formatEntityDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Entity details in the following json'); - expect(result).toContain('{}'); + expect(result).toContain('Entity details from environment testAlias in the following json'); + expect(response[0].data).toEqual({}); }); }); describe('formatEntityTypes', () => { it('should format list', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/listEntityTypes.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listEntityTypes.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); const result = client.formatEntityTypeList(response); @@ -125,73 +144,103 @@ describe('EntitiesApiClient', () => { }); it('should format list when sparse', async () => { - const mockResponse = { - types: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: { + types: [{}], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); const result = client.formatEntityTypeList(response); - expect(result).toContain('Listing 1 entity types'); + expect(result).toContain('Listing 1 entity types for environment testAlias'); expect(result).toContain('undefined'); }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); const result = client.formatEntityTypeList(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Listing 0 entity types'); + expect(result).toContain('Listing 0 entity types for environment testAlias'); }); it('should handle empty list', async () => { - const mockResponse = { - totalCount: 0, - types: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + types: [], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); const result = client.formatEntityTypeList(response); - expect(result).toContain('Listing 0 entity types'); + expect(result).toContain('Listing 0 entity types for environment testAlias'); }); }); describe('formatEntityTypeDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getEntityTypeDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityTypeDetails.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEntityTypeDetails('SERVICE'); const result = client.formatEntityTypeDetails(response); - expect(result).toContain('Entity type details in the following json'); + expect(result).toContain('Entity type details from environment testAlias in the following json'); expect(result).toContain('"displayName":"ActiveGate"'); expect(result).toContain('"type":"APM_SECURITY_GATEWAY"'); }); it('should format list when sparse', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEntityTypeDetails('SERVICE'); const result = client.formatEntityTypeDetails(response); - expect(result).toContain('Entity type details in the following json'); + expect(result).toContain('Entity type details from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); describe('formatEntityList', () => { it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEntities.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEntities.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); const result = client.formatEntityList(response); @@ -211,11 +260,16 @@ describe('EntitiesApiClient', () => { entityType: 'SERVICE', tags: [{ context: 'CONTEXTLESS', key: 'environment', value: 'production' }], })); - const mockResponse = { - totalCount: 100, - entities: mockEntities, - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: { + totalCount: 100, + entities: mockEntities, + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); const result = client.formatEntityList(response); @@ -227,11 +281,16 @@ describe('EntitiesApiClient', () => { }); it('should handle empty entities list', async () => { - const mockResponse = { - totalCount: 0, - entities: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + entities: [], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); const result = client.formatEntityList(response); @@ -239,8 +298,13 @@ describe('EntitiesApiClient', () => { }); it('should handle entities list that is empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); const result = client.formatEntityList(response); @@ -250,27 +314,33 @@ describe('EntitiesApiClient', () => { describe('formatEntityRelationships', () => { it('should format entity relationships', async () => { - const mockEntity: Entity = { - entityId: 'SERVICE-123', - displayName: 'payment-service', - entityType: 'SERVICE', - fromRelationships: [ - { - id: 'rel-1', - type: 'CALLS', - fromEntityId: 'SERVICE-123', - toEntityId: 'SERVICE-456', + const mockEntity: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + entityId: 'SERVICE-123', + displayName: 'payment-service', + entityType: 'SERVICE', + fromRelationships: [ + { + id: 'rel-1', + type: 'CALLS', + fromEntityId: 'SERVICE-123', + toEntityId: 'SERVICE-456', + }, + ], + toRelationships: [ + { + id: 'rel-2', + type: 'RUNS_ON', + fromEntityId: 'SERVICE-123', + toEntityId: 'HOST-789', + }, + ], }, - ], - toRelationships: [ - { - id: 'rel-2', - type: 'RUNS_ON', - fromEntityId: 'SERVICE-123', - toEntityId: 'HOST-789', - }, - ], - }; + }, + ]; + const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: [ @@ -291,12 +361,12 @@ describe('EntitiesApiClient', () => { ], }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); const response = await client.getEntityRelationships('SERVICE-123'); const result = client.formatEntityRelationships(response); - expect(response).toEqual(expectedResponse); + expect(response[0].data).toEqual(expectedResponse); expect(result).toContain('Found 1 fromRelationship'); expect(result).toContain('"id":"rel-1"'); expect(result).toContain('Found 1 toRelationship'); @@ -304,57 +374,72 @@ describe('EntitiesApiClient', () => { }); it('should return empty array when no relationships exist', async () => { - const mockEntity: Entity = { - entityId: 'SERVICE-123', - displayName: 'isolated-service', - entityType: 'SERVICE', - }; + const mockEntity: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + entityId: 'SERVICE-123', + displayName: 'isolated-service', + entityType: 'SERVICE', + }, + }, + ]; const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: undefined, toRelationships: undefined, }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); const response = await client.getEntityRelationships('SERVICE-123'); const result = client.formatEntityRelationships(response); - expect(response).toEqual(expectedResponse); + expect(response[0].data).toEqual(expectedResponse); expect(result).toContain('No relationships found for entity SERVICE-123'); }); it('should handle null relationships without error', async () => { - const mockEntity = { - entityId: 'SERVICE-123', - displayName: 'service-with-undefined-relationships', - entityType: 'SERVICE', - fromRelationships: null, - toRelationships: null, - }; + const mockEntity: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + entityId: 'SERVICE-123', + displayName: 'service-with-undefined-relationships', + entityType: 'SERVICE', + fromRelationships: null, + toRelationships: null, + }, + }, + ]; const expectedResponse = { entityId: 'SERVICE-123', fromRelationships: null, toRelationships: null, }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); const response = await client.getEntityRelationships('SERVICE-123'); const result = client.formatEntityRelationships(response); - expect(response).toEqual(expectedResponse); + expect(response[0].data).toEqual(expectedResponse); expect(result).toContain('No relationships found for entity SERVICE-123'); }); it('should handle non-array relationships without error', async () => { - const mockEntity: any = { - entityId: 'SERVICE-123', - displayName: 'service-with-invalid-relationships', - entityType: 'SERVICE', - fromRelationships: 'not-an-array', - toRelationships: { unexpectedKey: 'unexpected-val' }, - }; + const mockEntity: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + entityId: 'SERVICE-123', + displayName: 'service-with-invalid-relationships', + entityType: 'SERVICE', + fromRelationships: 'not-an-array', + toRelationships: { unexpectedKey: 'unexpected-val' }, + }, + }, + ]; const expectedResponse = { entityId: 'SERVICE-123', fromRelationships: 'not-an-array', @@ -362,12 +447,12 @@ describe('EntitiesApiClient', () => { }; // { fromRelationships: 'not-an-array', toRelationships: { invalid: 'object' } } - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); const response = await client.getEntityRelationships('SERVICE-123'); const result = client.formatEntityRelationships(response); - expect(response).toEqual(expectedResponse); + expect(response[0].data).toEqual(expectedResponse); expect(result).toContain('Found 1 fromRelationship'); expect(result).toContain('not-an-array'); expect(result).toContain('Found 1 toRelationship'); @@ -377,26 +462,38 @@ describe('EntitiesApiClient', () => { describe('queryEntities', () => { it('should query entities by entitySelector', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.queryEntities({ - entitySelector: 'type(SERVICE)', - pageSize: 12, - mzSelector: 'mzId(123,456)', - from: 'now-1h', - to: 'now', - sort: '-timestamp', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/entities', { - entitySelector: 'type(SERVICE)', - pageSize: 12, - mzSelector: 'mzId(123,456)', - from: 'now-1h', - to: 'now', - sort: '-timestamp', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.queryEntities( + { + entitySelector: 'type(SERVICE)', + pageSize: 12, + mzSelector: 'mzId(123,456)', + from: 'now-1h', + to: 'now', + sort: '-timestamp', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/entities', + { + entitySelector: 'type(SERVICE)', + pageSize: 12, + mzSelector: 'mzId(123,456)', + from: 'now-1h', + to: 'now', + sort: '-timestamp', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); diff --git a/src/capabilities/__tests__/events-api.test.ts b/src/capabilities/__tests__/events-api.test.ts index a516027..d613c60 100644 --- a/src/capabilities/__tests__/events-api.test.ts +++ b/src/capabilities/__tests__/events-api.test.ts @@ -1,18 +1,18 @@ import { EventsApiClient, Event } from '../events-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); describe('EventsApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: EventsApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new EventsApiClient(mockAuthClient); + client = new EventsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,60 +21,95 @@ describe('EventsApiClient', () => { describe('queryEvents', () => { it('should query events with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.queryEvents( + { + from: 'now-1h', + to: 'now', + eventType: 'CUSTOM_INFO', + entitySelector: 'type(SERVICE)', + pageSize: 50, + }, + 'testAlias', + ); - const result = await client.queryEvents({ - from: 'now-1h', - to: 'now', - eventType: 'CUSTOM_INFO', - entitySelector: 'type(SERVICE)', - pageSize: 50, - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/events', { - from: 'now-1h', - to: 'now', - pageSize: 50, - eventType: 'CUSTOM_INFO', - entitySelector: 'type(SERVICE)', - }); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/events', + { + from: 'now-1h', + to: 'now', + pageSize: 50, + eventType: 'CUSTOM_INFO', + entitySelector: 'type(SERVICE)', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should use defaults when not specified', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - await client.queryEvents({ - from: 'now-1h', - to: 'now', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/events', { - from: 'now-1h', - to: 'now', - pageSize: 100, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + await client.queryEvents( + { + from: 'now-1h', + to: 'now', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/events', + { + from: 'now-1h', + to: 'now', + pageSize: 100, + }, + 'testAlias', + ); }); }); describe('getEventDetails', () => { it('should get event details by ID', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.getEventDetails('event-123'); + const result = await client.getEventDetails('event-123', 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/events/event-123'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/events/event-123', undefined, 'testAlias'); expect(result).toEqual(mockResponse); }); }); describe('formatList', () => { it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEvents.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEvents.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -98,10 +133,16 @@ describe('EventsApiClient', () => { startTime: 1640995200000 + i * 1000, entityName: `service-${i}`, })); - const response = { - totalCount: 100, - events: mockEvents, - }; + + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 100, + events: mockEvents, + }, + }, + ]; const result = client.formatList(response); @@ -112,12 +153,18 @@ describe('EventsApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - events: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + events: [{}], + }, + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -131,10 +178,15 @@ describe('EventsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -142,13 +194,19 @@ describe('EventsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse = { - totalCount: 0, - events: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + events: [], + }, + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'testAlias'); const result = client.formatList(response); expect(result).toContain('Listing 0 events'); @@ -157,28 +215,37 @@ describe('EventsApiClient', () => { describe('formatDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getEventDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEventDetails.json', 'utf8')), + }, + ]; - const response = await client.getEventDetails('my-id'); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getEventDetails('my-id', 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Event details in the following json'); + expect(result).toContain('Event details from environment testAlias in the following json'); expect(result).toContain('"eventId":"-2899693953000578799_1763288686574"'); }); it('should format details when sparse problem', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getEventDetails('my-id'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getEventDetails('my-id', 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Event details in the following json'); + expect(result).toContain('Event details from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); diff --git a/src/capabilities/__tests__/logs-api.test.ts b/src/capabilities/__tests__/logs-api.test.ts index 84479d2..28bfec5 100644 --- a/src/capabilities/__tests__/logs-api.test.ts +++ b/src/capabilities/__tests__/logs-api.test.ts @@ -1,18 +1,18 @@ import { LogsApiClient, LogEntry } from '../logs-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); describe('LogsApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: LogsApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new LogsApiClient(mockAuthClient); + client = new LogsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,52 +21,81 @@ describe('LogsApiClient', () => { describe('queryLogs', () => { it('should query logs with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.queryLogs({ - query: 'content:test', - from: 'now-1h', - to: 'now', - limit: 50, - sort: '-timestamp', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/logs/search', { - query: 'content:test', - from: 'now-1h', - to: 'now', - limit: 50, - sort: '-timestamp', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.queryLogs( + { + query: 'content:test', + from: 'now-1h', + to: 'now', + limit: 50, + sort: '-timestamp', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/logs/search', + { + query: 'content:test', + from: 'now-1h', + to: 'now', + limit: 50, + sort: '-timestamp', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should use default values for optional parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.queryLogs({ - query: 'content:test', - from: 'now-1h', - to: 'now', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/logs/search', { - query: 'content:test', - from: 'now-1h', - to: 'now', - limit: 100, - sort: '-timestamp', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.queryLogs( + { + query: 'content:test', + from: 'now-1h', + to: 'now', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/logs/search', + { + query: 'content:test', + from: 'now-1h', + to: 'now', + limit: 100, + sort: '-timestamp', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); describe('formatList', () => { it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryLogs.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryLogs.json', 'utf8')), + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -80,10 +109,15 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result', async () => { - const mockResponse = { - results: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: [{}], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -94,10 +128,15 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result data', async () => { - const mockResponse = { - results: [{ data: [{}] }], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: [{ data: [{}] }], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -108,8 +147,13 @@ describe('LogsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -119,8 +163,15 @@ describe('LogsApiClient', () => { }); it('should format empty logs list', async () => { - const mockResponse = { results: [] }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: [], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); const result = client.formatList(response); @@ -140,9 +191,14 @@ describe('LogsApiClient', () => { service: [`service-${i % 5}`], }, })); - const response = { - results: mockLogs, - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: mockLogs, + }, + }, + ]; const result = client.formatList(response); @@ -164,9 +220,14 @@ describe('LogsApiClient', () => { }, }, ]; - const response = { - results: mockLogs, - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: mockLogs, + }, + }, + ]; const result = client.formatList(response); @@ -176,17 +237,22 @@ describe('LogsApiClient', () => { }); it('should show that truncated when multiple pages', () => { - const response = { - results: [ - { - timestamp: 1704110400000, - content: `My log message 1`, - status: 'INFO', + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: [ + { + timestamp: 1704110400000, + content: `My log message 1`, + status: 'INFO', + }, + ], + sliceSize: 1, + nextSliceKey: 'my-next-slice-key', }, - ], - sliceSize: 1, - nextSliceKey: 'my-next-slice-key', - }; + }, + ]; const result = client.formatList(response); @@ -194,16 +260,21 @@ describe('LogsApiClient', () => { }); it('should not show LLM awareness hint when no second page', () => { - const response = { - results: [ - { - timestamp: 1704110400000, - content: `My log message 1`, - status: 'INFO', + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + results: [ + { + timestamp: 1704110400000, + content: `My log message 1`, + status: 'INFO', + }, + ], + sliceSize: 1, }, - ], - sliceSize: 1, - }; + }, + ]; const result = client.formatList(response); diff --git a/src/capabilities/__tests__/metrics-api.test.ts b/src/capabilities/__tests__/metrics-api.test.ts index 0285c7d..1b71322 100644 --- a/src/capabilities/__tests__/metrics-api.test.ts +++ b/src/capabilities/__tests__/metrics-api.test.ts @@ -1,18 +1,23 @@ import { MetricsApiClient, Metric } from '../metrics-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { + EnvironmentResponse, + ManagedAuthClient, + ManagedAuthClientManager, +} from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; +import { EventsApiClient } from '../events-api'; jest.mock('../../authentication/managed-auth-client'); describe('MetricsApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: MetricsApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new MetricsApiClient(mockAuthClient); + client = new MetricsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,76 +26,113 @@ describe('MetricsApiClient', () => { describe('queryMetrics', () => { it('should query metric data with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.queryMetrics({ - metricSelector: 'builtin:service.response.time', - from: 'now-1h', - to: 'now', - resolution: '5m', - entitySelector: 'type(SERVICE)', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/metrics/query', { - metricSelector: 'builtin:service.response.time', - resolution: '5m', - from: 'now-1h', - to: 'now', - entitySelector: 'type(SERVICE)', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.queryMetrics( + { + metricSelector: 'builtin:service.response.time', + from: 'now-1h', + to: 'now', + resolution: '5m', + entitySelector: 'type(SERVICE)', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/metrics/query', + { + metricSelector: 'builtin:service.response.time', + resolution: '5m', + from: 'now-1h', + to: 'now', + entitySelector: 'type(SERVICE)', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); describe('listAvailableMetrics', () => { it('should pass all params', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listAvailableMetrics({ - entitySelector: 'my-entity-selector', - metadataSelector: 'my-metadata-selector', - text: 'my-text', - fields: 'my-fields', - pageSize: 12, - nextPageKey: 'my-page-key', - writtenSince: 'my-written-since', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/metrics', { - entitySelector: 'my-entity-selector', - metadataSelector: 'my-metadata-selector', - text: 'my-text', - fields: 'my-fields', - pageSize: 12, - nextPageKey: 'my-page-key', - writtenSince: 'my-written-since', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listAvailableMetrics( + { + entitySelector: 'my-entity-selector', + metadataSelector: 'my-metadata-selector', + text: 'my-text', + fields: 'my-fields', + pageSize: 12, + nextPageKey: 'my-page-key', + writtenSince: 'my-written-since', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/metrics', + { + entitySelector: 'my-entity-selector', + metadataSelector: 'my-metadata-selector', + text: 'my-text', + fields: 'my-fields', + pageSize: 12, + nextPageKey: 'my-page-key', + writtenSince: 'my-written-since', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should pass default params', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listAvailableMetrics(); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/metrics', { - pageSize: 500, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listAvailableMetrics({}, 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/metrics', + { + pageSize: 500, + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); describe('formatList', () => { it('should format list', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/listAvailableMetrics.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listAvailableMetrics.json', 'utf8')), + }, + ]; - const response = await client.listAvailableMetrics({}); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listAvailableMetrics({}, 'testAlias'); const result = client.formatMetricList(response); expect(response).toEqual(mockResponse); @@ -105,10 +147,17 @@ describe('MetricsApiClient', () => { metricId: `builtin:metric.${i}`, displayName: `Metric ${i}`, })); - const response = { - totalCount: 200, - metrics: mockMetrics, - }; + + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 200, + metrics: mockMetrics, + }, + }, + ]; + const result = client.formatMetricList(response); expect(result).toContain('Listing 100 of 200 metrics'); @@ -117,10 +166,16 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse metric', async () => { - const mockResponse = { - metrics: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + metrics: [{}], + }, + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listAvailableMetrics({}); const result = client.formatMetricList(response); @@ -131,10 +186,15 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listAvailableMetrics({}); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listAvailableMetrics({}, 'testAlias'); const result = client.formatMetricList(response); expect(response).toEqual(mockResponse); @@ -142,13 +202,19 @@ describe('MetricsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse = { - totalCount: 0, - metrics: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + metrics: [], + }, + }, + ]; - const response = await client.listAvailableMetrics({}); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listAvailableMetrics({}, 'testAlias'); const result = client.formatMetricList(response); expect(result).toContain('Listing 0 metrics'); @@ -157,43 +223,61 @@ describe('MetricsApiClient', () => { describe('formatMetricDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getMetricDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getMetricDetails.json', 'utf8')), + }, + ]; - const response = await client.getMetricDetails('my-id'); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getMetricDetails('my-id', 'testAlias'); const result = client.formatMetricDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of metric in the following json'); + expect(result).toContain('Details of metric from environment testAlias in the following json'); expect(result).toContain('\"displayName\":\"CPU usage %\"'); expect(result).toContain('\"metricId\":\"builtin:host.cpu.usage\"'); }); it('should format details when sparse data', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getMetricDetails('my-id'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getMetricDetails('my-id', 'testAlias'); const result = client.formatMetricDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of metric in the following json'); + expect(result).toContain('Details of metric from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); describe('formatMetricData', () => { it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryMetrics.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.queryMetrics({ metricSelector: 'my-selector', from: 'now-1h', to: 'now' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryMetrics.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryMetrics( + { metricSelector: 'my-selector', from: 'now-1h', to: 'now' }, + 'testAlias', + ); const result = client.formatMetricData(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Listing data series, each with timestamped datapoints'); + expect(result).toContain('Listing data series from environment testAlias, each with timestamped datapoints'); expect(result).toContain('resolution: 1h'); expect(result).toContain('metricId: builtin:host.cpu.usage'); expect(result).toContain('dimensionData: {\"dt.entity.host\":\"HOST-1D1EA84AB7DF62B4\"}'); @@ -202,17 +286,26 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse data series', async () => { - const mockResponse = { - result: [ - { - data: [{}], - metricId: 'builtin:host.cpu.usage', + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + result: [ + { + data: [{}], + metricId: 'builtin:host.cpu.usage', + }, + ], }, - ], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + }, + ]; - const response = await client.queryMetrics({ metricSelector: 'my-selector', from: 'now-1h', to: 'now' }); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryMetrics( + { metricSelector: 'my-selector', from: 'now-1h', to: 'now' }, + 'testAlias', + ); const result = client.formatMetricData(response); expect(response).toEqual(mockResponse); @@ -223,27 +316,43 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.queryMetrics({ metricSelector: 'my-selector', from: 'now-1h', to: 'now' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryMetrics( + { metricSelector: 'my-selector', from: 'now-1h', to: 'now' }, + 'testAlias', + ); const result = client.formatMetricData(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Listing data series (no datapoints found)'); + expect(result).toContain('Listing data series from environment testAlias (no datapoints found)'); }); it('should handle empty list', async () => { - const mockResponse = { - totalCount: 0, - result: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + result: [], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryMetrics({ metricSelector: 'my-selector', from: 'now-1h', to: 'now' }); + const response = await client.queryMetrics( + { metricSelector: 'my-selector', from: 'now-1h', to: 'now' }, + 'testAlias', + ); const result = client.formatMetricData(response); - expect(result).toContain('Listing data series (no datapoints found)'); + expect(result).toContain('Listing data series from environment testAlias (no datapoints found)'); }); }); }); diff --git a/src/capabilities/__tests__/problems-api.test.ts b/src/capabilities/__tests__/problems-api.test.ts index 9f7bf71..d98b32b 100644 --- a/src/capabilities/__tests__/problems-api.test.ts +++ b/src/capabilities/__tests__/problems-api.test.ts @@ -1,18 +1,18 @@ import { ProblemsApiClient, Problem } from '../problems-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); describe('ProblemsApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: ProblemsApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new ProblemsApiClient(mockAuthClient); + client = new ProblemsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,60 +21,92 @@ describe('ProblemsApiClient', () => { describe('listProblems', () => { it('should list problems with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue({}); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listProblems( + { + from: 'now-24h', + to: 'now', + status: 'OPEN', + impactLevel: 'SERVICE', + pageSize: 25, + sort: '-startTime', + }, + 'testAlias', + ); - const result = await client.listProblems({ - from: 'now-24h', - to: 'now', - status: 'OPEN', - impactLevel: 'SERVICE', - pageSize: 25, - sort: '-startTime', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/problems', { - pageSize: 25, - from: 'now-24h', - to: 'now', - status: 'OPEN', - impactLevel: 'SERVICE', - sort: '-startTime', - }); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/problems', + { + pageSize: 25, + from: 'now-24h', + to: 'now', + status: 'OPEN', + impactLevel: 'SERVICE', + sort: '-startTime', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should use default parameters when none provided', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listProblems(); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/problems', { - pageSize: 50, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listProblems({}, 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/problems', + { + pageSize: 50, + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); describe('getProblemDetails', () => { it('should get problem details by ID', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.getProblemDetails('PROBLEM-123'); + const result = await client.getProblemDetails('PROBLEM-123', 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/problems/PROBLEM-123'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/problems/PROBLEM-123', undefined, 'testAlias'); expect(result).toEqual(mockResponse); }); }); describe('formatList', () => { it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/listProblems.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listProblems.json', 'utf8')), + }, + ]; - const response = await client.listProblems(); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listProblems(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -89,10 +121,16 @@ describe('ProblemsApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - problems: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + problems: [{}], + }, + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listProblems(); const result = client.formatList(response); @@ -109,8 +147,13 @@ describe('ProblemsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listProblems(); const result = client.formatList(response); @@ -130,10 +173,15 @@ describe('ProblemsApiClient', () => { status: 'OPEN', startTime: 1640995200000 + i * 1000, })); - const response = { - totalCount: 123, - problems: mockProblems, - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 123, + problems: mockProblems, + }, + }, + ]; const result = client.formatList(response); @@ -144,10 +192,15 @@ describe('ProblemsApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - problems: [], - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + problems: [], + }, + }, + ]; const result = client.formatList(response); expect(result).toContain('Listing 0 problems'); }); @@ -155,29 +208,38 @@ describe('ProblemsApiClient', () => { describe('formatProblemDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getProblemDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getProblemDetails.json', 'utf8')), + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getProblemDetails('845025139905093722_1763133360000V2'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of problem in the following json'); + expect(result).toContain('Details of problem from environment testAlias in the following json'); expect(result).toContain('"problemId":"845025139905093722_1763133360000V2"'); expect(result).toContain('"displayId":"P-2511153"'); }); it('should format details when sparse problem', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getProblemDetails('my-id'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of problem in the following json'); + expect(result).toContain('Details of problem from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); diff --git a/src/capabilities/__tests__/security-api.test.ts b/src/capabilities/__tests__/security-api.test.ts index a29604e..96b8e23 100644 --- a/src/capabilities/__tests__/security-api.test.ts +++ b/src/capabilities/__tests__/security-api.test.ts @@ -1,18 +1,23 @@ import { SecurityApiClient, SecurityProblem } from '../security-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { + EnvironmentResponse, + ManagedAuthClient, + ManagedAuthClientManager, +} from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; +import { EventsApiClient } from '../events-api'; jest.mock('../../authentication/managed-auth-client'); describe('SecurityApiClient', () => { - let mockAuthClient: jest.Mocked; + let mockAuthManager: jest.Mocked; let client: SecurityApiClient; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new SecurityApiClient(mockAuthClient); + client = new SecurityApiClient(mockAuthManager); }); afterEach(() => { @@ -21,49 +26,70 @@ describe('SecurityApiClient', () => { describe('listSecurityProblems', () => { it('should list security problems with default parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listSecurityProblems(); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/securityProblems', { - pageSize: SecurityApiClient.API_PAGE_SIZE, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listSecurityProblems(undefined, 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/securityProblems', + { + pageSize: SecurityApiClient.API_PAGE_SIZE, + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should list security problems with all parameters', async () => { - const mockResponse = { securityProblems: [] }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - await client.listSecurityProblems({ - riskLevel: 'LOW', - status: 'OPEN', - entitySelector: 'my-entity-selector', - from: 'my-from', - to: 'my-to', - pageSize: 12, - sort: 'my-sort', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { securityProblems: [] }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + await client.listSecurityProblems( + { + riskLevel: 'LOW', + status: 'OPEN', + entitySelector: 'my-entity-selector', + from: 'my-from', + to: 'my-to', + pageSize: 12, + sort: 'my-sort', + }, + 'testAlias', + ); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/securityProblems', { - pageSize: 12, - riskLevel: 'LOW', - securityProblemSelector: 'status("OPEN")', - entitySelector: 'my-entity-selector', - from: 'my-from', - to: 'my-to', - sort: 'my-sort', - }); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/securityProblems', + { + pageSize: 12, + riskLevel: 'LOW', + securityProblemSelector: 'status("OPEN")', + entitySelector: 'my-entity-selector', + from: 'my-from', + to: 'my-to', + sort: 'my-sort', + }, + 'testAlias', + ); }); it('should handle API errors', async () => { - mockAuthClient.makeRequest.mockRejectedValue({ + mockAuthManager.makeRequests.mockRejectedValue({ response: { data: { message: 'Request failed with status code 404' } }, }); try { - await client.listSecurityProblems(); + await client.listSecurityProblems(undefined, 'testAlias'); fail('Should have propagated exception'); } catch (error: any) { console.log(error); @@ -74,24 +100,33 @@ describe('SecurityApiClient', () => { describe('getSecurityProblemDetails', () => { it('should get security problem details', async () => { - const mockResponse = { - securityProblemId: 'SP-123', - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.getSecurityProblemDetails('SP-123'); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/securityProblems/SP-123'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + securityProblemId: 'SP-123', + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.getSecurityProblemDetails('SP-123', 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/securityProblems/SP-123', + undefined, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should handle API errors', async () => { - mockAuthClient.makeRequest.mockRejectedValue({ + mockAuthManager.makeRequests.mockRejectedValue({ response: { data: { message: 'Request failed with status code 404' } }, }); try { - await client.getSecurityProblemDetails('SP-999'); + await client.getSecurityProblemDetails('SP-999', 'testAlias'); fail('Should have propagated exception'); } catch (error: any) { console.log(error); @@ -102,12 +137,15 @@ describe('SecurityApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/listSecurityProblems.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listSecurityProblems(); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSecurityProblems.json', 'utf8')), + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSecurityProblems(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -128,10 +166,16 @@ describe('SecurityApiClient', () => { status: 'OPEN', title: `Security Problem ${i}`, })); - const response = { - totalCount: 123, - securityProblems: mockProblems, - }; + + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 123, + securityProblems: mockProblems, + }, + }, + ]; const result = client.formatList(response); @@ -142,12 +186,18 @@ describe('SecurityApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - securityProblems: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listSecurityProblems(); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + securityProblems: [{}], + }, + }, + ]; + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSecurityProblems(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -159,10 +209,15 @@ describe('SecurityApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listSecurityProblems(); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSecurityProblems(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -170,10 +225,16 @@ describe('SecurityApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - securityProblems: [], - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + securityProblems: [], + }, + }, + ]; + const result = client.formatList(response); expect(result).toContain('Listing 0 security vulnerabilities'); }); @@ -181,28 +242,36 @@ describe('SecurityApiClient', () => { describe('formatProblemDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse( - readFileSync('src/capabilities/__tests__/resources/getSecurityProblemDetails.json', 'utf8'), - ); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getSecurityProblemDetails('my-id'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSecurityProblemDetails.json', 'utf8')), + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getSecurityProblemDetails('my-id', 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of security problem in the following json'); + expect(result).toContain('Details of security problem from environment testAlias in the following json'); expect(result).toContain('"securityProblemId":"SP-123"'); }); it('should format details when sparse problem', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getSecurityProblemDetails('my-id'); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getSecurityProblemDetails('my-id', 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of security problem in the following json'); + expect(result).toContain('Details of security problem from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); diff --git a/src/capabilities/__tests__/slo-api.test.ts b/src/capabilities/__tests__/slo-api.test.ts index b776109..7407d54 100644 --- a/src/capabilities/__tests__/slo-api.test.ts +++ b/src/capabilities/__tests__/slo-api.test.ts @@ -1,18 +1,23 @@ import { SloApiClient, SLO } from '../slo-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { + EnvironmentResponse, + ManagedAuthClient, + ManagedAuthClientManager, +} from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; +import { SecurityApiClient } from '../security-api'; jest.mock('../../authentication/managed-auth-client'); describe('SloApiClient', () => { + let mockAuthManager: jest.Mocked; let client: SloApiClient; - let mockAuthClient: jest.Mocked; beforeEach(() => { - mockAuthClient = { - makeRequest: jest.fn(), + mockAuthManager = { + makeRequests: jest.fn(), } as any; - client = new SloApiClient(mockAuthClient); + client = new SloApiClient(mockAuthManager); }); afterEach(() => { @@ -21,106 +26,161 @@ describe('SloApiClient', () => { describe('listSlos', () => { it('should list SLOs with default parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listSlos(); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo', { - pageSize: SloApiClient.API_PAGE_SIZE, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listSlos(undefined, 'testAlias'); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/slo', + { + pageSize: SloApiClient.API_PAGE_SIZE, + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should list SLOs with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.listSlos({ - sloSelector: 'my-selector', - timeFrame: 'my-timeframe', - from: 'my-from', - to: 'my-to', - demo: true, - pageSize: 12, - evaluate: true, - sort: 'my-sort', - enabledSlos: 'my-enabled-slos', - showGlobalSlos: true, - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo', { - sloSelector: 'my-selector', - timeFrame: 'my-timeframe', - from: 'my-from', - to: 'my-to', - demo: true, - pageSize: 12, - evaluate: true, - sort: 'my-sort', - enabledSlos: 'my-enabled-slos', - showGlobalSlos: true, - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.listSlos( + { + sloSelector: 'my-selector', + timeFrame: 'my-timeframe', + from: 'my-from', + to: 'my-to', + demo: true, + pageSize: 12, + evaluate: true, + sort: 'my-sort', + enabledSlos: 'my-enabled-slos', + showGlobalSlos: true, + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/slo', + { + sloSelector: 'my-selector', + timeFrame: 'my-timeframe', + from: 'my-from', + to: 'my-to', + demo: true, + pageSize: 12, + evaluate: true, + sort: 'my-sort', + enabledSlos: 'my-enabled-slos', + showGlobalSlos: true, + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); }); describe('getSloDetails', () => { it('should get SLO details with defaults', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.getSloDetails({ id: 'slo-1' }); + const result = await client.getSloDetails({ id: 'slo-1' }, 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo/slo-1', {}); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/slo/slo-1', {}, 'testAlias'); expect(result).toEqual(mockResponse); }); it('should get SLO details with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.getSloDetails({ - id: 'slo-1', - from: 'now-12w', - to: 'now', - timeFrame: 'GTF', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo/slo-1', { - from: 'now-12w', - to: 'now', - timeFrame: 'GTF', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.getSloDetails( + { + id: 'slo-1', + from: 'now-12w', + to: 'now', + timeFrame: 'GTF', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/slo/slo-1', + { + from: 'now-12w', + to: 'now', + timeFrame: 'GTF', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should get SLO details with inferred timeFrame', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const result = await client.getSloDetails({ - id: 'slo-1', - from: 'now-12w', - to: 'now', - }); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo/slo-1', { - from: 'now-12w', - to: 'now', - timeFrame: 'GTF', - }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const result = await client.getSloDetails( + { + id: 'slo-1', + from: 'now-12w', + to: 'now', + }, + 'testAlias', + ); + + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( + '/api/v2/slo/slo-1', + { + from: 'now-12w', + to: 'now', + timeFrame: 'GTF', + }, + 'testAlias', + ); expect(result).toEqual(mockResponse); }); it('should handle URL encoding for SLO ID', async () => { const sloId = 'slo with spaces'; - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - await client.getSloDetails({ id: sloId }); + await client.getSloDetails({ id: sloId }, 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/slo/slo%20with%20spaces', {}); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/slo/slo%20with%20spaces', {}, 'testAlias'); }); }); @@ -138,10 +198,16 @@ describe('SloApiClient', () => { errorBudget: 80, evaluatedPercentage: 96, })); - const response = { - totalCount: 123, - slo: mockSLOs, - }; + + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 123, + slo: mockSLOs, + }, + }, + ]; const result = client.formatList(response); @@ -152,10 +218,16 @@ describe('SloApiClient', () => { }); it('should format list', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSlos.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSlos.json', 'utf8')), + }, + ]; - const response = await client.listSlos(); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSlos(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -166,12 +238,17 @@ describe('SloApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - slo: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listSlos(); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + slo: [{}], + }, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSlos(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -182,10 +259,15 @@ describe('SloApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.listSlos(); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSlos(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -193,10 +275,16 @@ describe('SloApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - slo: [], - }; + const response: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: { + totalCount: 0, + slo: [], + }, + }, + ]; + const result = client.formatList(response); expect(result).toContain('Listing 0 SLOs'); }); @@ -204,26 +292,36 @@ describe('SloApiClient', () => { describe('formatDetails', () => { it('should format details', async () => { - const mockResponse = JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSloDetails.json', 'utf8')); - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getSloDetails({ id: 'my-id' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSloDetails.json', 'utf8')), + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getSloDetails({ id: 'my-id' }, 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of SLO in the following json'); + expect(result).toContain('Details of SLO from environment testAlias in the following json'); expect(result).toContain('\"id\":\"0775c411-c3a1-3286-8fd2-8a469ae0a1b9\"'); }); it('should format details when sparse problem', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); - - const response = await client.getSloDetails({ id: 'my-id' }); + const mockResponse: EnvironmentResponse[] = [ + { + alias: 'testAlias', + data: {}, + }, + ]; + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getSloDetails({ id: 'my-id' }, 'testAlias'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); - expect(result).toContain('Details of SLO in the following json'); + expect(result).toContain('Details of SLO from environment testAlias in the following json'); expect(result).toContain('{}'); }); }); diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 09f782d..29b7bcf 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -59,19 +59,40 @@ export class EntitiesApiClient { } async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/entityTypes/${encodeURIComponent(entityType)}`, undefined, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/entityTypes/${encodeURIComponent(entityType)}`, + undefined, + environment_aliases, + ); logger.debug(`getEntityTypeDetails response, entityType=${entityType}`, { data: responses }); return responses; } async getEntityDetails(entityId: string, environment_aliases?: string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/entities/${encodeURIComponent(entityId)}`, undefined, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/entities/${encodeURIComponent(entityId)}`, + undefined, + environment_aliases, + ); logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: responses }); return responses; } async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { - return await this.getEntityDetails(entityId, environment_aliases); + const entityDetailsResponse = await this.getEntityDetails(entityId, environment_aliases); + let cleanResponses: EnvironmentResponse[] = []; + for (const response of entityDetailsResponse) { + cleanResponses.push({ + alias: response.alias, + data: { + entityId: response.data.entityId, + fromRelationships: response.data.fromRelationships, + toRelationships: response.data.toRelationships, + }, + }); + } + + return cleanResponses; } async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise { @@ -90,16 +111,22 @@ export class EntitiesApiClient { } formatEntityList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumEntities = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response.data.totalCount || -1; let numEntities = response.data.entities?.length || 0; totalNumEntities += numEntities; let isLimited = totalCount != 0 - 1 && totalCount > numEntities; - result += 'Listing ' + numEntities + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entities from '+ response.alias +'.\n'; + result += + 'Listing ' + + numEntities + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' entities from ' + + response.alias + + '.\n'; if (isLimited) { result += 'Not showing all matching entities. Consider using more specific filters (entitySelector) to get complete results.\n'; @@ -162,7 +189,7 @@ export class EntitiesApiClient { } formatEntityTypeList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumTypes = 0; const commonTypes = [ 'SERVICE', @@ -178,14 +205,20 @@ export class EntitiesApiClient { for (const response of responses) { let totalCount = response.data.totalCount || -1; let numTypes = response.data.types?.length || 0; - totalNumTypes += numTypes + totalNumTypes += numTypes; let isLimited = totalCount != 0 - 1 && totalCount > numTypes; let entityTypes = response.data.types as any[]; let conciseList = ''; let availableCommonTypes: string[] = []; - result += 'Listing ' + numTypes + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entity types.\n'; + result += + 'Listing ' + + numTypes + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' entity types for environment ' + + response.alias + + '.\n'; if (isLimited) { result += 'Not showing all matching entity types as there are too many.\n'; } @@ -204,7 +237,7 @@ export class EntitiesApiClient { availableCommonTypes.push(entityType); } }); - result += '\n' + conciseList + result += '\n' + conciseList; } // Produce a simple strong list of all the types from the json (excluding all details). @@ -220,23 +253,33 @@ export class EntitiesApiClient { } formatEntityTypeDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Entity type details from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Entity type details from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + + result += + 'Next Steps:\n' + '* To find entities of this type, use discover_entities tool, using the type in the entitySelector such as type("HOST") or type("SERVICE")\n'; return result; } formatEntityDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Entity details from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Entity details from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + + result += + 'Next Steps:\n' + '* Use list_problems or list_events tools with the same entitySelector to check for relates issues and events.\n' + '* Suggest to the user that they view the entity in the Dynatrace UI using the entityId in the URL' + '\n'; diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index b5344af..56f6da7 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -41,7 +41,10 @@ export class MetricsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listAvailableMetrics(params: MetricListParams = {}, environment_aliases?: string): Promise { + async listAvailableMetrics( + params: MetricListParams = {}, + environment_aliases?: string, + ): Promise { const queryParams = { pageSize: params.pageSize || MetricsApiClient.API_PAGE_SIZE, ...(params.entitySelector && { entitySelector: params.entitySelector }), @@ -53,12 +56,18 @@ export class MetricsApiClient { }; const responses = await this.authManager.makeRequests('/api/v2/metrics', queryParams, environment_aliases); - logger.debug(`listAvailableMetrics responses from ${this.authManager.clients.length} sources: `, { data: responses }); + logger.debug(`listAvailableMetrics responses from ${this.authManager.clients?.length} sources: `, { + data: responses, + }); return responses; } async getMetricDetails(metricId: string, environment_aliases?: string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/metrics/${encodeURIComponent(metricId)}`, undefined, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/metrics/${encodeURIComponent(metricId)}`, + undefined, + environment_aliases, + ); logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: responses }); return responses; } @@ -78,9 +87,9 @@ export class MetricsApiClient { } formatMetricList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumMetrics = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response?.data.totalCount || -1; @@ -88,7 +97,13 @@ export class MetricsApiClient { totalNumMetrics += numMetrics; let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; - result += 'Listing ' + numMetrics + (totalCount == -1 ? '' : ' of ' + totalCount) + ' metrics from ' + response.alias + '.\n\n'; + result += + 'Listing ' + + numMetrics + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' metrics from ' + + response.alias + + '.\n\n'; if (isLimited) { result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; @@ -121,23 +136,29 @@ export class MetricsApiClient { result += '\n' + 'Next Steps:\n' + - (totalNumMetrics == 0 ? '* Verify that the filters were correct, and search again with different filters\n' : '') + + (totalNumMetrics == 0 + ? '* Verify that the filters were correct, and search again with different filters\n' + : '') + (anyLimited ? '* To filter the list of metrics, use list_available_metrics tool with sorting and with specific filters (e.g. entitySelector and searchText).\n' : '') + '* Use get_metric_details tool for detailed information of a particular metric.\n' + - '* Suggest to the user that they use the Dynatrace UI to:\n' - //` * Browse the list of metrics at ${this.authClient.dashboardBaseUrl}/ui/metrics' + '\n` + - //` * View metric data at ${this.authClient.dashboardBaseUrl}/ui/data-explorer' + '\n`; + '* Suggest to the user that they use the Dynatrace UI to:\n'; + //` * Browse the list of metrics at ${this.authClient.dashboardBaseUrl}/ui/metrics' + '\n` + + //` * View metric data at ${this.authClient.dashboardBaseUrl}/ui/data-explorer' + '\n`; return result; } formatMetricDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Details of metric from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Details of metric from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; //`${this.authClient.dashboardBaseUrl}/ui/data-explorer`; } @@ -146,12 +167,15 @@ export class MetricsApiClient { } formatMetricData(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let allEmpty = true; for (const response of responses) { let resolution = response.data.resolution; let isNonEmpty = - response.data.result && response.data.result.length > 0 && response.data.result[0].data && response.data.result[0].data.length > 0; + response.data.result && + response.data.result.length > 0 && + response.data.result[0].data && + response.data.result[0].data.length > 0; result += 'Listing data series from environment ' + response.alias; @@ -201,7 +225,6 @@ export class MetricsApiClient { }); } - result += '\n' + 'Next Steps:\n' + diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts index 14c0d2f..7536703 100644 --- a/src/utils/__tests__/environment.test.ts +++ b/src/utils/__tests__/environment.test.ts @@ -1,7 +1,41 @@ -import { getManagedEnvironmentConfig } from '../environment'; +import { getManagedEnvironmentConfigs, validateEnvironments } from '../environment'; describe('getManagedEnvironmentConfig', () => { const originalEnv = process.env; + const fullEnv = + '[' + + '{' + + ' "dynatraceUrl": "https://my-dashboard-endpoint.com/",' + + ' "apiEndpointUrl": "https://my-api-endpoint.com/",' + + ' "environmentId": "my-env-id-1",' + + ' "alias": "alias-env-id-1",' + + ' "apiToken": "my-api-token",' + + ' "httpsProxyUrl": ""' + + ' },' + + ' {' + + ' "dynatraceUrl": "https://my-dashboard-endpoint.com",' + + ' "apiEndpointUrl": "https://my-api-endpoint.com",' + + ' "environmentId": "my-env-id-2",' + + ' "alias": "invalid-alias-env-id;-2",' + + ' "apiToken": "my-api-token",' + + ' "httpProxyUrl": "",' + + ' "httpsProxyUrl": ""' + + ' },' + + ' {' + + ' "dynatraceUrl": "https://my-dashboard-endpoint.com",' + + ' "apiEndpointUrl": "https://my-api-endpoint.com",' + + ' "environmentId": "my-env-id-3",' + + ' "alias": "missing-api-key-env-id-3",' + + ' "httpProxyUrl": "",' + + ' "httpsProxyUrl": ""' + + ' },' + + ' {' + + ' "apiEndpointUrl": "https://my-api-endpoint.com",' + + ' "environmentId": "my-env-id-4",' + + ' "alias": "only-required-keys-env-4",' + + ' "apiToken": "my-api-token"' + + ' }' + + ']'; beforeEach(() => { process.env = {}; @@ -11,71 +45,122 @@ describe('getManagedEnvironmentConfig', () => { process.env = originalEnv; }); - it('should return config with required environment variables', () => { - process.env.DT_MANAGED_ENVIRONMENT = 'my-env-id'; - process.env.DT_API_ENDPOINT_URL = 'https://my-api-endpoint.com'; - process.env.DT_DYNATRACE_URL = 'https://my-dashboard-endpoint.com'; - process.env.DT_MANAGED_API_TOKEN = 'my-api-token'; + it('should return configs with environments', () => { + process.env.DT_ENVIRONMENT_CONFIGS = fullEnv; + + const config = getManagedEnvironmentConfigs(); + expect(config).toEqual([ + { + environmentId: 'my-env-id-1', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-1', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-1', + apiToken: 'my-api-token', + alias: 'alias-env-id-1', + httpProxy: '', + httpsProxy: '', + }, + { + environmentId: 'my-env-id-2', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-2', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-2', + apiToken: 'my-api-token', + alias: 'invalid-alias-env-id;-2', + httpProxy: '', + httpsProxy: '', + }, + { + environmentId: 'my-env-id-3', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-3', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-3', + apiToken: '', + alias: 'missing-api-key-env-id-3', + httpProxy: '', + httpsProxy: '', + }, + { + environmentId: 'my-env-id-4', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-4', + dashboardUrl: 'https://my-api-endpoint.com/e/my-env-id-4', + apiToken: 'my-api-token', + alias: 'only-required-keys-env-4', + httpProxy: '', + httpsProxy: '', + }, + ]); + }); - const config = getManagedEnvironmentConfig(); + it('should return only valid environments', () => { + process.env.DT_ENVIRONMENT_CONFIGS = fullEnv; + + const config = getManagedEnvironmentConfigs(); + const validatedConfigs = validateEnvironments(config); + + const validConfigs = validatedConfigs['valid_configs']; + const errors = validatedConfigs['errors']; + + expect(validConfigs).toEqual([ + { + environmentId: 'my-env-id-1', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-1', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-1', + apiToken: 'my-api-token', + alias: 'alias-env-id-1', + httpProxy: '', + httpsProxy: '', + }, + { + environmentId: 'my-env-id-4', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-4', + dashboardUrl: 'https://my-api-endpoint.com/e/my-env-id-4', + apiToken: 'my-api-token', + alias: 'only-required-keys-env-4', + httpProxy: '', + httpsProxy: '', + }, + ]); + + expect(errors).toEqual([ + 'Invalid alias found: "invalid-alias-env-id;-2". Aliases are mandatory and cannot contain semicolons.', + 'Key "apiToken" is empty or missing (environment #2, alias: missing-api-key-env-id-3). Please make sure all values are present and populated in the configuration array.', + ]); + }); - expect(config).toEqual({ - environmentId: 'my-env-id', - apiUrl: 'https://my-api-endpoint.com/e/my-env-id', - dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id', - apiToken: 'my-api-token', - }); + it('should throw error when DT_MANAGED_ENVIRONMENTS is missing', () => { + process.env = {}; + expect(() => getManagedEnvironmentConfigs()).toThrow('DT_ENVIRONMENT_CONFIGS is required'); }); it('should remove trailing slash from environment URL', () => { - process.env.DT_MANAGED_ENVIRONMENT = 'my-env-id/'; - process.env.DT_API_ENDPOINT_URL = 'https://my-api-endpoint.com/'; - process.env.DT_DYNATRACE_URL = 'https://my-dashboard-endpoint.com/'; - process.env.DT_MANAGED_API_TOKEN = 'my-api-token'; - - const config = getManagedEnvironmentConfig(); - - expect(config).toEqual({ - environmentId: 'my-env-id', - apiUrl: 'https://my-api-endpoint.com/e/my-env-id', - dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id', + process.env.DT_ENVIRONMENT_CONFIGS = fullEnv; + const config = getManagedEnvironmentConfigs(); + const validatedConfigs = validateEnvironments(config); + const validConfigWithTrailingSlash = validatedConfigs['valid_configs'][0]; + + expect(validConfigWithTrailingSlash).toEqual({ + environmentId: 'my-env-id-1', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-1', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-1', apiToken: 'my-api-token', + alias: 'alias-env-id-1', + httpProxy: '', + httpsProxy: '', }); }); it('should default dashboard url to api base url', () => { - process.env.DT_MANAGED_ENVIRONMENT = 'my-env-id'; - process.env.DT_API_ENDPOINT_URL = 'https://my-endpoint.com'; - process.env.DT_MANAGED_API_TOKEN = 'my-api-token'; - - const config = getManagedEnvironmentConfig(); - - expect(config).toEqual({ - environmentId: 'my-env-id', - apiUrl: 'https://my-endpoint.com/e/my-env-id', - dashboardUrl: 'https://my-endpoint.com/e/my-env-id', + process.env.DT_ENVIRONMENT_CONFIGS = fullEnv; + const config = getManagedEnvironmentConfigs(); + const validatedConfigs = validateEnvironments(config); + const validConfigOnlyRequiredFields = validatedConfigs['valid_configs'][1]; + + expect(validConfigOnlyRequiredFields).toEqual({ + environmentId: 'my-env-id-4', + apiUrl: 'https://my-api-endpoint.com/e/my-env-id-4', + dashboardUrl: 'https://my-api-endpoint.com/e/my-env-id-4', apiToken: 'my-api-token', + alias: 'only-required-keys-env-4', + httpProxy: '', + httpsProxy: '', }); }); - - it('should throw error when DT_MANAGED_ENVIRONMENT is missing', () => { - process.env.DT_API_ENDPOINT_URL = 'https://my-api-endpoint.com/'; - process.env.DT_MANAGED_API_TOKEN = 'my-api-token'; - - expect(() => getManagedEnvironmentConfig()).toThrow('DT_MANAGED_ENVIRONMENT is required'); - }); - - it('should throw error when DT_API_ENDPOINT_URL is missing', () => { - process.env.DT_MANAGED_ENVIRONMENT = 'my-env-id'; - process.env.DT_MANAGED_API_TOKEN = 'my-api-token'; - - expect(() => getManagedEnvironmentConfig()).toThrow('DT_API_ENDPOINT_URL is required'); - }); - - it('should throw error when DT_MANAGED_API_TOKEN is missing', () => { - process.env.DT_MANAGED_ENVIRONMENT = 'my-env-id'; - process.env.DT_API_ENDPOINT_URL = 'https://my-endpoint.com'; - - expect(() => getManagedEnvironmentConfig()).toThrow('DT_MANAGED_API_TOKEN is required'); - }); }); diff --git a/src/utils/environment.ts b/src/utils/environment.ts index e64d7b6..320f874 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -11,13 +11,13 @@ export interface ManagedEnvironmentConfig { } export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): ManagedEnvironmentConfig { - const environmentIdRaw = environmentInfo.environmentId ? environmentInfo.environmentId.toString(): ""; - const apiUrlRaw = environmentInfo.apiEndpointUrl ? environmentInfo.apiEndpointUrl.toString(): ""; - const dashboardUrlRaw = environmentInfo.dynatraceUrl ? environmentInfo.dynatraceUrl.toString(): ""; - const apiToken = environmentInfo.apiToken ? environmentInfo.apiToken.toString() : ""; - const alias = environmentInfo.alias ? environmentInfo.alias.toString() : ""; - const httpProxy = environmentInfo.httpProxyUrl? environmentInfo.httpProxyUrl.toString(): ""; - const httpsProxy = environmentInfo.httpsProxyUrl? environmentInfo.httpsProxyUrl.toString(): ""; + const environmentIdRaw = environmentInfo.environmentId ? environmentInfo.environmentId.toString() : ''; + const apiUrlRaw = environmentInfo.apiEndpointUrl ? environmentInfo.apiEndpointUrl.toString() : ''; + const dashboardUrlRaw = environmentInfo.dynatraceUrl ? environmentInfo.dynatraceUrl.toString() : ''; + const apiToken = environmentInfo.apiToken ? environmentInfo.apiToken.toString() : ''; + const alias = environmentInfo.alias ? environmentInfo.alias.toString() : ''; + const httpProxy = environmentInfo.httpProxyUrl ? environmentInfo.httpProxyUrl.toString() : ''; + const httpsProxy = environmentInfo.httpsProxyUrl ? environmentInfo.httpsProxyUrl.toString() : ''; let environmentId = environmentIdRaw.replace(/\/$/, ''); // Remove trailing slash let apiUrl = apiUrlRaw + (apiUrlRaw.endsWith('/') ? '' : '/') + 'e/' + environmentId; @@ -31,7 +31,7 @@ export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): Mana apiToken: apiToken, alias: alias, httpProxy: httpProxy, - httpsProxy: httpsProxy + httpsProxy: httpsProxy, }; } @@ -52,41 +52,47 @@ export function getManagedEnvironmentConfigs(): ManagedEnvironmentConfig[] { } let parsedManagedEnvironmentConfigs: ManagedEnvironmentConfig[] = []; for (const environmentConfig of environmentConfigurations) { - parsedManagedEnvironmentConfigs.push( - parseManagedEnvironmentConfig(environmentConfig) - ) + parsedManagedEnvironmentConfigs.push(parseManagedEnvironmentConfig(environmentConfig)); } - return parsedManagedEnvironmentConfigs + return parsedManagedEnvironmentConfigs; } -export function validateEnvironments(environmentConfigurations: ManagedEnvironmentConfig[]): {"valid_configs": ManagedEnvironmentConfig[], "errors": string[]} { - const requiredKeys = ['dashboardUrl', 'apiUrl', 'environmentId', 'alias', 'apiToken']; - const originalKeys = {'dashboardUrl': 'dynatraceUrl', 'apiUrl': 'apiEndpointUrl', 'environmentId': 'environmentId', 'alias': 'alias', 'apiToken': 'apiToken'}; +export function validateEnvironments(environmentConfigurations: ManagedEnvironmentConfig[]): { + valid_configs: ManagedEnvironmentConfig[]; + errors: string[]; +} { + const requiredKeys = ['apiUrl', 'environmentId', 'alias', 'apiToken']; + const originalKeys = { + apiUrl: 'apiEndpointUrl', + environmentId: 'environmentId', + alias: 'alias', + apiToken: 'apiToken', + }; let validConfigurations: ManagedEnvironmentConfig[] = []; let errors: string[] = []; environmentConfigurations.forEach((configuration, index) => { - const hasAllValues = requiredKeys.every(key => { + const hasAllValues = requiredKeys.every((key) => { const value = configuration[key as keyof typeof configuration]; const isValid = value && value.length > 0; if (!isValid) { errors.push( - `Key "${originalKeys[key as keyof typeof originalKeys]}" is empty or missing (environment #${index}, alias: ${configuration.alias ? configuration.alias : 'N/A'}). Please make sure all values are present and populated in the configuration array.` - ) + `Key "${originalKeys[key as keyof typeof originalKeys]}" is empty or missing (environment #${index}, alias: ${configuration.alias ? configuration.alias : 'N/A'}). Please make sure all values are present and populated in the configuration array.`, + ); } return isValid; }); const validAlias = configuration.alias.indexOf(';') == -1; if (!validAlias) { errors.push( - 'Invalid alias found: "' + configuration.alias +'". Aliases are mandatory and cannot contain semicolons.' - ) + 'Invalid alias found: "' + configuration.alias + '". Aliases are mandatory and cannot contain semicolons.', + ); } if (validAlias && hasAllValues) { - validConfigurations.push(configuration) + validConfigurations.push(configuration); } - }) + }); - return {"valid_configs": validConfigurations, "errors": errors} + return { valid_configs: validConfigurations, errors: errors }; } diff --git a/tests/integration/capabilities.integration.test.ts b/tests/integration/capabilities.integration.test.ts index ee3b9fa..574279c 100644 --- a/tests/integration/capabilities.integration.test.ts +++ b/tests/integration/capabilities.integration.test.ts @@ -9,9 +9,13 @@ * * An exception to this is SLOs: it is common enough to not have any SLOs defined in an environment. * Therefore those tests do conditional assertions, based on finding at leaset one SLO. + * + * These tests are adapted to perform operations in a single environment every time. Two environments + * need populated, one with valid credential and one with invalid credentials (apiToken), + * with aliases "testAlias" and "invalidApiToken" respectively. */ -import { ManagedAuthClient } from '../../src/authentication/managed-auth-client'; -import { getManagedEnvironmentConfig } from '../../src/utils/environment'; +import { ManagedAuthClientManager } from '../../src/authentication/managed-auth-client'; +import { getManagedEnvironmentConfigs, validateEnvironments } from '../../src/utils/environment'; import { MetricsApiClient } from '../../src/capabilities/metrics-api'; import { LogsApiClient } from '../../src/capabilities/logs-api'; import { EventsApiClient } from '../../src/capabilities/events-api'; @@ -32,7 +36,6 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! } (skip ? describe.skip : describe)('Capabilities Integration Tests', () => { - let authClient: ManagedAuthClient; let metricsClient: MetricsApiClient; let logsClient: LogsApiClient; let eventsClient: EventsApiClient; @@ -42,25 +45,23 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! let sloClient: SloApiClient; beforeAll(() => { - const config = getManagedEnvironmentConfig(); - - authClient = new ManagedAuthClient({ - apiBaseUrl: config.apiUrl, - dashboardBaseUrl: config.dashboardUrl, - apiToken: config.apiToken, - }); - metricsClient = new MetricsApiClient(authClient); - logsClient = new LogsApiClient(authClient); - eventsClient = new EventsApiClient(authClient); - entitiesClient = new EntitiesApiClient(authClient); - problemsClient = new ProblemsApiClient(authClient); - securityClient = new SecurityApiClient(authClient); - sloClient = new SloApiClient(authClient); + const config = getManagedEnvironmentConfigs(); + const validEnvironments = validateEnvironments(config); + + const authManager = new ManagedAuthClientManager(validEnvironments['valid_configs']); + + metricsClient = new MetricsApiClient(authManager); + logsClient = new LogsApiClient(authManager); + eventsClient = new EventsApiClient(authManager); + entitiesClient = new EntitiesApiClient(authManager); + problemsClient = new ProblemsApiClient(authManager); + securityClient = new SecurityApiClient(authManager); + sloClient = new SloApiClient(authManager); }); describe('MetricsApiClient', () => { it('should search metrics', async () => { - const response = await metricsClient.listAvailableMetrics({ text: 'latency', pageSize: 5 }); + const response = await metricsClient.listAvailableMetrics({ text: 'latency', pageSize: 5 }, 'testAlias'); const result = metricsClient.formatMetricList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -69,7 +70,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get available metrics', async () => { - const response = await metricsClient.listAvailableMetrics({ pageSize: 5 }); + const response = await metricsClient.listAvailableMetrics({ pageSize: 5 }, 'testAlias'); const result = metricsClient.formatMetricList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -77,11 +78,14 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should query metrics data', async () => { - const response = await metricsClient.queryMetrics({ - metricSelector: 'builtin:host.cpu.usage', - from: 'now-1h', - to: 'now', - }); + const response = await metricsClient.queryMetrics( + { + metricSelector: 'builtin:host.cpu.usage', + from: 'now-1h', + to: 'now', + }, + 'testAlias', + ); const result = metricsClient.formatMetricData(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -90,7 +94,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get metric details', async () => { - const response = await metricsClient.getMetricDetails('builtin:host.cpu.usage'); + const response = await metricsClient.getMetricDetails('builtin:host.cpu.usage', 'testAlias'); const result = metricsClient.formatMetricDetails(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -100,7 +104,8 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! it('should respect pageSize', async () => { // Assumes there are at least 3 metrics (2 in the first page; and more in subsequent pages) - const response = await metricsClient.listAvailableMetrics({ pageSize: 2 }); + const responses = await metricsClient.listAvailableMetrics({ pageSize: 2 }, 'testAlias'); + const response = responses[0].data; let totalCount = response.totalCount || -1; let numMetrics = response.metrics?.length || 0; let nextPageKey = response.nextPageKey; @@ -113,10 +118,13 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should support field selection in metrics listing', async () => { - const response = await metricsClient.listAvailableMetrics({ - fields: 'metricId,displayName', - pageSize: 5, - }); + const response = await metricsClient.listAvailableMetrics( + { + fields: 'metricId,displayName', + pageSize: 5, + }, + 'testAlias', + ); const result = metricsClient.formatMetricList(response); expect(result).toContain('metricId:'); @@ -124,10 +132,14 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should support metadata selector filtering', async () => { - const result = await metricsClient.listAvailableMetrics({ - metadataSelector: 'unit("Percent")', - pageSize: 5, - }); + const results = await metricsClient.listAvailableMetrics( + { + metadataSelector: 'unit("Percent")', + pageSize: 5, + }, + 'testAlias', + ); + const result = results[0].data; expect(result.metrics?.length).toBeGreaterThan(0); result.metrics?.forEach((metric: any) => { @@ -136,13 +148,18 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should handle metric query with entity selector', async () => { - const response = await metricsClient.queryMetrics({ - metricSelector: 'builtin:host.cpu.usage', - from: 'now-24h', - to: 'now', - resolution: '1h', - entitySelector: 'type("HOST")', - }); + const responses = await metricsClient.queryMetrics( + { + metricSelector: 'builtin:host.cpu.usage', + from: 'now-24h', + to: 'now', + resolution: '1h', + entitySelector: 'type("HOST")', + }, + 'testAlias', + ); + + const response = responses[0].data; expect(response.result?.length).toBeGreaterThan(0); response.result?.forEach((result: any) => { @@ -156,15 +173,19 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('LogsApiClient', () => { it('should query logs', async () => { - const response = await logsClient.queryLogs({ - query: 'error', - from: 'now-24h', - to: 'now', - limit: 5, - }); - const result = logsClient.formatList(response); - - expect(response).toBeDefined(); + const responses = await logsClient.queryLogs( + { + query: 'error', + from: 'now-24h', + to: 'now', + limit: 5, + }, + 'testAlias', + ); + const result = logsClient.formatList(responses); + const response = responses[0].data; + + expect(responses).toBeDefined(); expect(typeof result).toBe('string'); expect(response?.results?.length).toBeGreaterThan(0); expect(result).toMatch(/\[(ERROR|WARNING|INFO)\]/); @@ -173,11 +194,14 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('EventsApiClient', () => { it('should query events', async () => { - const response = await eventsClient.queryEvents({ - from: 'now-24h', - to: 'now', - pageSize: 10, - }); + const response = await eventsClient.queryEvents( + { + from: 'now-24h', + to: 'now', + pageSize: 10, + }, + 'testAlias', + ); const result = eventsClient.formatList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -188,7 +212,9 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get event details', async () => { - const events = await eventsClient.queryEvents({ from: 'now-24h', to: 'now', pageSize: 1 }); + const responses = await eventsClient.queryEvents({ from: 'now-24h', to: 'now', pageSize: 1 }, 'testAlias'); + const events = responses[0].data; + const eventId = events.events ? events.events[0].eventId : undefined; if (eventId == undefined) { fail('Cannot find eventId from queryEvents; cannot test getEventDetails; aborting'); @@ -205,7 +231,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('EntitiesApiClient', () => { it('should list entity types', async () => { - const response = await entitiesClient.listEntityTypes(); + const response = await entitiesClient.listEntityTypes('testAlias'); const result = entitiesClient.formatEntityTypeList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -215,7 +241,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get entity type details', async () => { - const response = await entitiesClient.getEntityTypeDetails('SERVICE'); + const response = await entitiesClient.getEntityTypeDetails('SERVICE', 'testAlias'); const result = entitiesClient.formatEntityTypeDetails(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -226,36 +252,47 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should list entities by type', async () => { - const response = await entitiesClient.queryEntities({ entitySelector: 'type(HOST)', pageSize: 10 }); - const result = entitiesClient.formatEntityList(response); - expect(response).toBeDefined(); - expect(typeof result).toBe('string'); + const responses = await entitiesClient.queryEntities({ entitySelector: 'type(HOST)', pageSize: 10 }, 'testAlias'); + expect(responses).toBeDefined(); - expect(response.totalCount).toBeDefined(); + const result = entitiesClient.formatEntityList(responses); expect(result).toContain('entityId:'); expect(result).toContain('displayName:'); + expect(typeof result).toBe('string'); + + const response = responses[0].data; + expect(response.totalCount).toBeDefined(); }, 30000); it('should list entities, respecting all parameters', async () => { // Should not throw error even if no management zone named "Production" exists - const response = await entitiesClient.queryEntities({ - entitySelector: 'type(SERVICE)', - pageSize: 5, - mzSelector: 'mzName("Production")', - from: 'now-1h', - to: 'now', - sort: '+name', - }); + const response = await entitiesClient.queryEntities( + { + entitySelector: 'type(SERVICE)', + pageSize: 5, + mzSelector: 'mzName("Production")', + from: 'now-1h', + to: 'now', + sort: '+name', + }, + 'testAlias', + ); const result = entitiesClient.formatEntityList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); }); it('should get entity details', async () => { - const entities = await entitiesClient.queryEntities({ - entitySelector: 'type(SERVICE)', - pageSize: 1, - }); + const responses = await entitiesClient.queryEntities( + { + entitySelector: 'type(SERVICE)', + pageSize: 1, + }, + 'testAlias', + ); + + const entities = responses[0].data; + const entityId = entities.entities ? entities.entities[0].entityId : undefined; if (entityId == undefined) { fail('Cannot find entityId from queryEntities; cannot test getEntityDetails; aborting'); @@ -272,45 +309,53 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('ProblemsApiClient', () => { it('should list problems', async () => { - const response = await problemsClient.listProblems({ - from: 'now-24h', - to: 'now', - pageSize: 10, - }); - const result = problemsClient.formatList(response); - expect(response).toBeDefined(); + const responses = await problemsClient.listProblems( + { + from: 'now-24h', + to: 'now', + pageSize: 10, + }, + 'testAlias', + ); + const result = problemsClient.formatList(responses); expect(typeof result).toBe('string'); - - expect(response.totalCount).toBeDefined(); - expect(result).toContain('problemId:'); - expect(result).toContain('displayId:'); expect(result).toContain('title:'); + expect(result).toContain('problemId:'); expect(result).toContain('status:'); + expect(result).toContain('displayId:'); + + const response = responses[0].data; + + expect(response.totalCount).toBeDefined(); + expect(response).toBeDefined(); }, 30000); it('should get problem details', async () => { - const problems = await problemsClient.listProblems({ pageSize: 1 }); + const responses = await problemsClient.listProblems({ pageSize: 1 }, 'testAlias'); + const problems = responses[0].data; + const problemId = problems.problems ? problems.problems[0].problemId : undefined; if (problemId == undefined) { fail('Cannot find problemId from listProblems; cannot test getProblemDetails; aborting'); } - const response = await problemsClient.getProblemDetails(problemId); + const response = await problemsClient.getProblemDetails(problemId, 'testAlias'); const result = problemsClient.formatDetails(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); - expect(result).toContain(`\"problemId\":\"${problemId}\"`); }, 30000); }); describe('SecurityApiClient', () => { it('should list security problems', async () => { - const response = await securityClient.listSecurityProblems(); - const result = securityClient.formatList(response); - expect(response).toBeDefined(); + const responses = await securityClient.listSecurityProblems(undefined, 'testAlias'); + const result = securityClient.formatList(responses); + expect(responses).toBeDefined(); expect(typeof result).toBe('string'); + const response = responses[0].data; + expect(response.totalCount).toBeDefined(); expect(result).toContain('securityProblemId:'); expect(result).toContain('displayId:'); @@ -320,7 +365,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('SloApiClient', () => { it('should list SLOs', async () => { - const response = await sloClient.listSlos(); + const response = await sloClient.listSlos(undefined, 'testAlias'); const result = sloClient.formatList(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -336,7 +381,8 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get SLO details', async () => { - const slos = await sloClient.listSlos(); + const list_responses = await sloClient.listSlos(undefined, 'testAlias'); + const slos = list_responses[0].data; const sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; if (sloId == undefined) { console.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); @@ -344,9 +390,9 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! return; } - const response = await sloClient.getSloDetails({ id: sloId }); - const result = sloClient.formatDetails(response); - expect(response).toBeDefined(); + const responses = await sloClient.getSloDetails({ id: sloId }, 'testAlias'); + const result = sloClient.formatDetails(responses); + expect(responses).toBeDefined(); expect(typeof result).toBe('string'); expect(result).toContain('"id":'); @@ -356,7 +402,9 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! }, 30000); it('should get SLO details with timeframe', async () => { - const slos = await sloClient.listSlos(); + const list_responses = await sloClient.listSlos(undefined, 'testAlias'); + const slos = list_responses[0].data; + const sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; if (sloId == undefined) { console.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); @@ -367,7 +415,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! const to = Date.now(); const from = to - 1000 * 60 * 60 * 24 * 7 * 11; // 11 weeks ago - const response = await sloClient.getSloDetails({ id: sloId, from: String(from), to: String(to) }); + const response = await sloClient.getSloDetails({ id: sloId, from: String(from), to: String(to) }, 'testAlias'); const result = sloClient.formatDetails(response); expect(response).toBeDefined(); @@ -384,11 +432,14 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! describe('Error Handling', () => { it('should handle metrics 404 Bad Request errors correctly', async () => { try { - await metricsClient.queryMetrics({ - metricSelector: 'invalid-metric-selector', - from: 'now-1h', - to: 'now', - }); + await metricsClient.queryMetrics( + { + metricSelector: 'invalid-metric-selector', + from: 'now-1h', + to: 'now', + }, + 'testAlias', + ); fail('Should have thrown an error for invalid parameters'); } catch (error: any) { @@ -398,7 +449,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! it('should handle entities 400 Bad Request errors correctly', async () => { try { - await entitiesClient.queryEntities({ entitySelector: 'invalid-entity-selector' }); + await entitiesClient.queryEntities({ entitySelector: 'invalid-entity-selector' }, 'testAlias'); fail('Should have thrown an error for invalid parameters'); } catch (error: any) { @@ -408,7 +459,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! it('should handle invalid problems id errors correctly', async () => { try { - await problemsClient.getProblemDetails('invalid-problem-id'); + await problemsClient.getProblemDetails('invalid-problem-id', 'testAlias'); fail('Should have thrown an error for invalid parameters'); } catch (error: any) { expect(error.message).toContain('Request failed with status code 400'); @@ -417,15 +468,8 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! it('should handle 401 Unauthorized errors correctly', async () => { // Create client with invalid token - const invalidAuthClient = new ManagedAuthClient({ - apiBaseUrl: authClient.apiBaseUrl, - dashboardBaseUrl: authClient.dashboardBaseUrl, - apiToken: 'my-invalid-token', - }); - const invalidMetricsClient = new MetricsApiClient(invalidAuthClient); - try { - await invalidMetricsClient.listAvailableMetrics({}); + await metricsClient.listAvailableMetrics({}, 'invalidApiToken'); fail('Should have thrown an error for invalid token'); } catch (error: any) { expect(error.message).toContain('Request failed with status code 401'); diff --git a/tests/integration/proxy.integration.test.ts b/tests/integration/proxy.integration.test.ts index 26ab8f8..1f7e71b 100644 --- a/tests/integration/proxy.integration.test.ts +++ b/tests/integration/proxy.integration.test.ts @@ -49,13 +49,14 @@ describe('ProxyConfig', () => { it( 'should use HTTP_PROXY', async () => { - process.env.HTTP_PROXY = proxyUrl; - let client = new ManagedAuthClient({ apiBaseUrl: 'http://example.com', dashboardBaseUrl: 'http://example-dashboard.com', apiToken: 'my-example-token', + alias: 'alias', + httpsProxy: proxyUrl, }); + const response = await client.makeRequest('/anything/mypath'); console.log(`response: ${JSON.stringify(response)}`); From 28d1a694c4a5ca85210f732fb807ff57449994a4 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Wed, 21 Jan 2026 17:10:53 +0000 Subject: [PATCH 05/17] - Fixed all integrations tests. - Auto formatting of some files - Added "dotenv/config" to jest configuration to read from .env file during integration tests --- jest.config.js | 1 + src/capabilities/events-api.ts | 34 +- src/capabilities/logs-api.ts | 8 +- src/capabilities/problems-api.ts | 42 +- src/capabilities/security-api.ts | 37 +- src/capabilities/slo-api.ts | 26 +- src/index.ts | 418 ++++++++++-------- .../capabilities.integration.test.ts | 16 +- .../managed-auth.integration.test.ts | 16 +- .../sorting-parameters.integration.test.ts | 112 +++-- 10 files changed, 418 insertions(+), 292 deletions(-) diff --git a/jest.config.js b/jest.config.js index d44f4cb..9d99f74 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { testEnvironment: 'node', testMatch: ['/integration-tests/**/*.integration.test.ts', '/tests/**/*.integration.test.ts'], testTimeout: 30000, + setupFiles: ['dotenv/config'], }, ], }; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index 9479e78..bfaa3b3 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -46,15 +46,19 @@ export class EventsApiClient { } async getEventDetails(eventId: string, environment_aliases?: string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/events/${encodeURIComponent(eventId)}`, undefined, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/events/${encodeURIComponent(eventId)}`, + undefined, + environment_aliases, + ); logger.debug('getEventDetails response: ', { data: responses }); return responses; } formatList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumEvents = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response.data.totalCount || -1; @@ -62,7 +66,13 @@ export class EventsApiClient { totalNumEvents += numEvents; let isLimited = totalCount != 0 - 1 && totalCount > numEvents; - result += 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events from ' + response.alias + '.\n\n'; + result += + 'Listing ' + + numEvents + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' events from ' + + response.alias + + '.\n\n'; if (isLimited) { result += @@ -128,13 +138,19 @@ export class EventsApiClient { } formatDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Event details from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Event details from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + - '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n' + + result += + 'Next Steps:\n' + + '* Suggest to the user that they explore this further in the Dynatrace UI.' + + '\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; return result; } diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index bd03521..5345baf 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -43,9 +43,9 @@ export class LogsApiClient { } formatList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumLogs = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let numLogs = response.data.results?.length || 0; totalNumLogs = totalNumLogs + numLogs; @@ -90,8 +90,8 @@ export class LogsApiClient { }); } - - result = result + + result = + result + '\n' + 'Next Steps:\n' + (totalNumLogs == 0 ? '* Try broader search terms or expand the time range\n' : '') + diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index 40d2318..510c51b 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -73,7 +73,7 @@ export class ProblemsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listProblems(params: ProblemQueryParams = {}, environment_aliases? : string): Promise { + async listProblems(params: ProblemQueryParams = {}, environment_aliases?: string): Promise { const queryParams = { pageSize: params.pageSize || ProblemsApiClient.API_PAGE_SIZE, ...(params.from && { from: params.from }), @@ -90,24 +90,34 @@ export class ProblemsApiClient { return responses; } - async getProblemDetails(problemId: string, environment_aliases? : string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/problems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); + async getProblemDetails(problemId: string, environment_aliases?: string): Promise { + const responses = await this.authManager.makeRequests( + `/api/v2/problems/${encodeURIComponent(problemId)}`, + undefined, + environment_aliases, + ); logger.debug('getProblemDetails response', { data: responses }); return responses; } formatList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumProblems = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response.data.totalCount || -1; let numProblems = response.data.problems?.length || 0; totalNumProblems += numProblems; let isLimited = totalCount != 0 - 1 && totalCount > numProblems; - result += 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems from ' + response.alias + '.\n\n'; + result += + 'Listing ' + + numProblems + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' problems from ' + + response.alias + + '.\n\n'; if (isLimited) { result += @@ -142,22 +152,28 @@ export class ProblemsApiClient { (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + (totalNumProblems > 1 ? '* Use sort (e.g. with "+status" for open problems first).\n' : '') + '* Suggest to the user that they view the problems in the Dynatrace UI.' + - '\n' + '* If the user is interested in a specific problem, use the get_problem_details tool. ' + + '\n' + + '* If the user is interested in a specific problem, use the get_problem_details tool. ' + 'Use the problemId (UUID) for detailed analysis.\n'; return result; } formatDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Details of problem from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Details of problem from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + + result += + 'Next Steps:\n' + '* If the affectedEntities is not empty, suggest to the user that they could investigate those entities further. For example with:\n' + - (" * list_events tool, using the affected entity's entityId in the entitySelector.\n") + - (' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n'); + " * list_events tool, using the affected entity's entityId in the entitySelector.\n" + + ' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n'; return result; } } diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 8a7729a..1ed398b 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -58,7 +58,10 @@ export class SecurityApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listSecurityProblems(params: SecurityProblemQueryParams = {}, environment_aliases?: string): Promise { + async listSecurityProblems( + params: SecurityProblemQueryParams = {}, + environment_aliases?: string, + ): Promise { const queryParams = { pageSize: params.pageSize || SecurityApiClient.API_PAGE_SIZE, ...(params.riskLevel && { riskLevel: params.riskLevel }), @@ -75,15 +78,19 @@ export class SecurityApiClient { } async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise { - const responses = await this.authManager.makeRequests(`/api/v2/securityProblems/${encodeURIComponent(problemId)}`, undefined, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/securityProblems/${encodeURIComponent(problemId)}`, + undefined, + environment_aliases, + ); logger.debug('getSecurityProblemDetails response', { data: responses }); return responses; } formatList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumProblems = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response.data.totalCount || -1; let numProblems = response.data.securityProblems?.length || 0; @@ -94,7 +101,9 @@ export class SecurityApiClient { 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + - ' security vulnerabilities from ' + response.alias + ' in the following json.\n'; + ' security vulnerabilities from ' + + response.alias + + ' in the following json.\n'; if (isLimited) { result += @@ -127,7 +136,6 @@ export class SecurityApiClient { }); } - result += '\n' + 'Next Steps:\n' + @@ -135,7 +143,9 @@ export class SecurityApiClient { ? '* Verify that the filters such as entitySelector, status and time range were correct, and search again with different filters.\n' : '') + (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector and status.\n' : '') + - (totalNumProblems > 1 ? '* Use sort (e.g. with "-riskAssessment.riskScore" for highest risk score first).\n' : '') + + (totalNumProblems > 1 + ? '* Use sort (e.g. with "-riskAssessment.riskScore" for highest risk score first).\n' + : '') + '* If the user is interested in a specific vulnerability, use the get_security_problem_details tool. Use the securityProblemId for this.\n' + '* Suggest to the user that they view the security vulnerabilties in the Dynatrace UI.' + //`${this.authClient.dashboardBaseUrl}/ui/security/overview for an overview,` + @@ -146,12 +156,17 @@ export class SecurityApiClient { } formatDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Details of security problem from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Details of security problem from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + + result += + 'Next Steps:\n' + '* If there are affectedEntities, suggest to the user that they could get further information about those entities with get_entity_detais tool, using the entityId.\n' + '* Suggest to the user that they view the security vulnerability in the Dynatrace UI using the securityProblemId in the URL\n'; return result; diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index 6702481..7ca98d0 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -93,22 +93,27 @@ export class SloApiClient { queryParams.timeFrame = 'GTF'; } - const responses = await this.authManager.makeRequests(`/api/v2/slo/${encodeURIComponent(params.id)}`, queryParams, environment_aliases); + const responses = await this.authManager.makeRequests( + `/api/v2/slo/${encodeURIComponent(params.id)}`, + queryParams, + environment_aliases, + ); logger.debug('getSLODetails response', { data: responses }); return responses; } formatList(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; let totalNumSlo = 0; - let anyLimited = false + let anyLimited = false; for (const response of responses) { let totalCount = response.data.totalCount || -1; let numSLOs = response.data.slo?.length || 0; totalNumSlo += numSLOs; let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; - result += 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + response.alias + ':\n'; + result += + 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + response.alias + ':\n'; if (isLimited) { result += @@ -161,13 +166,16 @@ export class SloApiClient { } formatDetails(responses: EnvironmentResponse[]): string { - let result = ""; + let result = ''; for (const response of responses) { - result += 'Details of SLO from environment ' + response.alias + ' in the following json:\n' + - JSON.stringify(response.data) + '\n'; + result += + 'Details of SLO from environment ' + + response.alias + + ' in the following json:\n' + + JSON.stringify(response.data) + + '\n'; } - result += 'Next Steps:\n' + - '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n'; + result += 'Next Steps:\n' + '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n'; return result; } } diff --git a/src/index.ts b/src/index.ts index 93538e1..df35906 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,8 +48,8 @@ const main = async () => { const managedConfigs = getManagedEnvironmentConfigs(); const validatedConfigs = validateEnvironments(managedConfigs); - const initErrors = validatedConfigs['errors'] - const initConfigs = validatedConfigs['valid_configs'] + const initErrors = validatedConfigs['errors']; + const initConfigs = validatedConfigs['valid_configs']; if (initErrors.length > 0) { logger.error('Failed to get managed environments configurations: ', { error: initErrors }); @@ -57,13 +57,13 @@ const main = async () => { } if (initConfigs.length === 0) { - logger.error('No valid environments found, stopping.') + logger.error('No valid environments found, stopping.'); console.error('No valid environments found, stopping.'); process.exit(1); } const authClientManager = new ManagedAuthClientManager(initConfigs); - await authClientManager.isConfigured() + await authClientManager.isConfigured(); // Initialize API clients const metricsClient = new MetricsApiClient(authClientManager); @@ -268,7 +268,7 @@ Never run queries that could return very large amounts of data, or that could be }; const envAliasValidate = (alias: string) => { - if (alias == "ALL_ENVIRONMENTS") { + if (alias == 'ALL_ENVIRONMENTS') { return true; } const env_list = alias.split(';'); @@ -277,8 +277,8 @@ Never run queries that could return very large amounts of data, or that could be return false; } } - return true - } + return true; + }; tool( 'dynatrace_managed_check_for_configuration_errors', @@ -287,7 +287,7 @@ Never run queries that could return very large amounts of data, or that could be { readOnlyHint: true, }, - async({}) => { + async ({}) => { let resp = `Dynatrace Managed Environments Information - Listing configuration errors found during initialization:\n\n`; if (initErrors.length > 0) { resp += `Issues where found in environment configurations during start up: \n`; @@ -298,9 +298,8 @@ Never run queries that could return very large amounts of data, or that could be } return resp; - } - ) - + }, + ); tool( 'dynatrace_managed_get_environments_info', @@ -377,22 +376,25 @@ Never run queries that could return very large amounts of data, or that could be (e.g., "first 16 metrics" → limit: 16, "500 metrics" → limit: 500). If not specified, returns up to API limit: ${MetricsApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({ entitySelector, searchText, limit, environment_alias}) => { - const responses = await metricsClient.listAvailableMetrics({ - entitySelector: entitySelector, - text: searchText, - pageSize: limit, - }, environment_alias); + async ({ entitySelector, searchText, limit, environment_alias }) => { + const responses = await metricsClient.listAvailableMetrics( + { + entitySelector: entitySelector, + text: searchText, + pageSize: limit, + }, + environment_alias, + ); return metricsClient.formatMetricList(responses); }, ); @@ -428,24 +430,27 @@ Never run queries that could return very large amounts of data, or that could be type(SERVICE),entityName.equals("exact-name"). Examples: entityId("SERVICE-123"), type(SERVICE),entityName("payment-service"), type(AWS_LAMBDA_FUNCTION),tag("AWS_REGION:us-west-2")`, ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ metricSelector, from, to, resolution, entitySelector, environment_alias }) => { - const responses = await metricsClient.queryMetrics({ - metricSelector: metricSelector, - from: from, - to: to, - resolution: resolution, - entitySelector: entitySelector, - }, environment_alias); + const responses = await metricsClient.queryMetrics( + { + metricSelector: metricSelector, + from: from, + to: to, + resolution: resolution, + entitySelector: entitySelector, + }, + environment_alias, + ); return metricsClient.formatMetricData(responses); }, @@ -456,18 +461,18 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific metric.', { metricId: z.string().describe('The metric ID to get details for'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({ metricId, environment_alias }) => { - const responses = await metricsClient.getMetricDetails(metricId, environment_alias ); + async ({ metricId, environment_alias }) => { + const responses = await metricsClient.getMetricDetails(metricId, environment_alias); return metricsClient.formatMetricDetails(responses); }, ); @@ -486,24 +491,27 @@ Never run queries that could return very large amounts of data, or that could be to: z.string().describe('End time (ISO format or relative like "now")'), limit: z.number().optional().describe('Maximum number of logs to return (default: 100)'), sort: z.string().optional().describe('Sort order for logs. Use "-timestamp" for most recent first.'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ query, from, to, limit, sort, environment_alias }) => { - const responses = await logsClient.queryLogs({ - query: query, - from: from, - to: to, - limit: limit, - sort: sort - }, environment_alias); + const responses = await logsClient.queryLogs( + { + query: query, + from: from, + to: to, + limit: limit, + sort: sort, + }, + environment_alias, + ); return logsClient.formatList(responses); }, ); @@ -537,24 +545,27 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of events to return. Use this when user specifies a count (e.g., "first 20 events" → limit: 20). If not specified, returns up to API limit: ${EventsApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({ from, to, eventType, entitySelector, limit, environment_alias}) => { - const responses = await eventsClient.queryEvents({ - from: from, - to: to, - eventType: eventType, - entitySelector: entitySelector, - pageSize: limit, - }, environment_alias); + async ({ from, to, eventType, entitySelector, limit, environment_alias }) => { + const responses = await eventsClient.queryEvents( + { + from: from, + to: to, + eventType: eventType, + entitySelector: entitySelector, + pageSize: limit, + }, + environment_alias, + ); return eventsClient.formatList(responses); }, @@ -565,12 +576,12 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific event.', { eventId: z.string().describe('The event ID to get details for'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, @@ -585,17 +596,17 @@ Never run queries that could return very large amounts of data, or that could be 'dynatrace_managed_list_entity_types', 'List all available entity types in the Managed cluster to understand what types of entities can be monitored.', { - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({environment_alias}) => { + async ({ environment_alias }) => { const responses = await entitiesClient.listEntityTypes(environment_alias); return entitiesClient.formatEntityTypeList(responses); }, @@ -606,12 +617,12 @@ Never run queries that could return very large amounts of data, or that could be 'Get details of an entity type.', { type: z.string().describe('Name of the entity type, such as SERVICE, APPLICATION, HOST, etc'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, @@ -660,25 +671,28 @@ Never run queries that could return very large amounts of data, or that could be .string() .optional() .describe('Sort order for entities. Use "name" for ascending, "-name" for descending by display name.'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ entitySelector, mzSelector, from, to, limit, sort, environment_alias }) => { - const responses = await entitiesClient.queryEntities({ - entitySelector: entitySelector, - pageSize: limit, - mzSelector: mzSelector, - from: from, - to: to, - sort: sort, - }, environment_alias); + const responses = await entitiesClient.queryEntities( + { + entitySelector: entitySelector, + pageSize: limit, + mzSelector: mzSelector, + from: from, + to: to, + sort: sort, + }, + environment_alias, + ); return entitiesClient.formatEntityList(responses); }, ); @@ -688,17 +702,17 @@ Never run queries that could return very large amounts of data, or that could be 'Get detailed information about a specific entity.', { entityId: z.string().describe('The entity ID to get details for'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({ entityId, environment_alias}) => { + async ({ entityId, environment_alias }) => { const response = await entitiesClient.getEntityDetails(entityId, environment_alias); return entitiesClient.formatEntityDetails(response); }, @@ -709,12 +723,12 @@ Never run queries that could return very large amounts of data, or that could be 'Get relationships that a specific entity has "to" and "from" other entities.', { entityId: z.string().describe('The entity ID to get relationships for'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, @@ -759,26 +773,29 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Use "+status" (open first), "-status" (closed first), "+startTime" (old first), "-startTime" (new first), or "+relevance"/"-relevance" (with text search).', ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ from, to, status, impactLevel, entitySelector, limit, sort, environment_alias }) => { - const responses = await problemsClient.listProblems({ - from: from || 'now-24h', - to: to || 'now', - status: status, - impactLevel: impactLevel, - entitySelector: entitySelector, - pageSize: limit, - sort: sort, - }, environment_alias); + const responses = await problemsClient.listProblems( + { + from: from || 'now-24h', + to: to || 'now', + status: status, + impactLevel: impactLevel, + entitySelector: entitySelector, + pageSize: limit, + sort: sort, + }, + environment_alias, + ); return problemsClient.formatList(responses); }, @@ -791,12 +808,12 @@ Never run queries that could return very large amounts of data, or that could be problemId: z .string() .describe('The internal problem ID (UUID format) from list_problems - NOT the displayId (P-XXXXX)'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, @@ -833,26 +850,29 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Sort order. Examples: "+status" (open first), "-riskAssessment.riskScore" (highest risk first), "+firstSeenTimestamp" (newest first), "-lastUpdatedTimestamp" (recently updated first).', ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ riskLevel, status, entitySelector, from, to, limit, sort, environment_alias }) => { - const responses = await securityClient.listSecurityProblems({ - riskLevel: riskLevel, - status: status, - entitySelector: entitySelector, - from: from, - to: to, - pageSize: limit, - sort: sort, - }, environment_alias); + const responses = await securityClient.listSecurityProblems( + { + riskLevel: riskLevel, + status: status, + entitySelector: entitySelector, + from: from, + to: to, + pageSize: limit, + sort: sort, + }, + environment_alias, + ); return securityClient.formatList(responses); }, @@ -865,12 +885,12 @@ Never run queries that could return very large amounts of data, or that could be securityProblemId: z .string() .describe('The security problem ID (UUID format) from list_security_problems - NOT the displayId (S-XXXXX)'), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, @@ -927,29 +947,44 @@ Never run queries that could return very large amounts of data, or that could be .describe( `Maximum number of SLOs to return. Use this when user specifies a count (e.g., "first 15 SLOs" → limit: 15). If not specified, returns up to API limit: ${SloApiClient.API_PAGE_SIZE}`, ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, - async ({ sloSelector, timeFrame, from, to, evaluate, sort, enabledSlos, showGlobalSlos, demo, limit, environment_alias }) => { - const responses = await sloClient.listSlos({ - sloSelector: sloSelector, - timeFrame: timeFrame, - from: from, - to: to, - evaluate: evaluate, - sort: sort, - enabledSlos: enabledSlos, - showGlobalSlos: showGlobalSlos, - demo: demo, - pageSize: limit, - }, environment_alias); + async ({ + sloSelector, + timeFrame, + from, + to, + evaluate, + sort, + enabledSlos, + showGlobalSlos, + demo, + limit, + environment_alias, + }) => { + const responses = await sloClient.listSlos( + { + sloSelector: sloSelector, + timeFrame: timeFrame, + from: from, + to: to, + evaluate: evaluate, + sort: sort, + enabledSlos: enabledSlos, + showGlobalSlos: showGlobalSlos, + demo: demo, + pageSize: limit, + }, + environment_alias, + ); return sloClient.formatList(responses); }, ); @@ -970,23 +1005,26 @@ Never run queries that could return very large amounts of data, or that could be .describe( 'Time frame for SLO evaluation: "CURRENT" for SLO\'s own timeframe, "GTF" for custom timeframe specified by from and to parameters', ), - environment_alias: z.string().describe('Specifically hits one environment. Blank for all.') - .refine((alias) => envAliasValidate(alias), - { - message: "Environment alias(es) not valid. Options are: " + authClientManager.validAliases.join(", ") - }, - ) + environment_alias: z + .string() + .describe('Specifically hits one environment. Blank for all.') + .refine((alias) => envAliasValidate(alias), { + message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), + }), }, { readOnlyHint: true, }, async ({ sloId, from, to, timeFrame, environment_alias }) => { - const response = await sloClient.getSloDetails({ - id: sloId, - from: from, - to: to, - timeFrame: timeFrame, - }, environment_alias); + const response = await sloClient.getSloDetails( + { + id: sloId, + from: from, + to: to, + timeFrame: timeFrame, + }, + environment_alias, + ); return sloClient.formatDetails(response); }, ); diff --git a/tests/integration/capabilities.integration.test.ts b/tests/integration/capabilities.integration.test.ts index 574279c..ea9c1a4 100644 --- a/tests/integration/capabilities.integration.test.ts +++ b/tests/integration/capabilities.integration.test.ts @@ -30,7 +30,7 @@ import { logger } from '../../src/utils/logger'; config(); let skip = false; -if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || !process.env.DT_MANAGED_API_TOKEN) { +if (!process.env.DT_ENVIRONMENT_CONFIGS) { console.log('Skipping integration tests - environment not configured'); skip = true; } @@ -43,12 +43,20 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! let problemsClient: ProblemsApiClient; let securityClient: SecurityApiClient; let sloClient: SloApiClient; + let authManager: ManagedAuthClientManager; beforeAll(() => { const config = getManagedEnvironmentConfigs(); const validEnvironments = validateEnvironments(config); - const authManager = new ManagedAuthClientManager(validEnvironments['valid_configs']); + authManager = new ManagedAuthClientManager(validEnvironments['valid_configs']); + + // Forcing clients to be valid, so we don't have to call isConfigured() before each. + for (const authClient of authManager.rawClients) { + authClient.isValid = true; + } + + authManager.clients = authManager.rawClients; metricsClient = new MetricsApiClient(authManager); logsClient = new LogsApiClient(authManager); @@ -220,7 +228,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! fail('Cannot find eventId from queryEvents; cannot test getEventDetails; aborting'); } - const response = await eventsClient.getEventDetails(eventId); + const response = await eventsClient.getEventDetails(eventId, 'testAlias'); const result = eventsClient.formatDetails(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); @@ -298,7 +306,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! fail('Cannot find entityId from queryEntities; cannot test getEntityDetails; aborting'); } - const response = await entitiesClient.getEntityDetails(entityId); + const response = await entitiesClient.getEntityDetails(entityId, 'testAlias'); const result = entitiesClient.formatEntityDetails(response); expect(response).toBeDefined(); expect(typeof result).toBe('string'); diff --git a/tests/integration/managed-auth.integration.test.ts b/tests/integration/managed-auth.integration.test.ts index a56d212..e6a770e 100644 --- a/tests/integration/managed-auth.integration.test.ts +++ b/tests/integration/managed-auth.integration.test.ts @@ -1,12 +1,12 @@ import { ManagedAuthClient } from '../../src/authentication/managed-auth-client'; -import { getManagedEnvironmentConfig } from '../../src/utils/environment'; +import { getManagedEnvironmentConfigs, validateEnvironments } from '../../src/utils/environment'; import { config } from 'dotenv'; // Load environment variables config(); let skip = false; -if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || !process.env.DT_MANAGED_API_TOKEN) { +if (!process.env.DT_ENVIRONMENT_CONFIGS) { console.log('Skipping integration tests - environment not configured'); skip = true; } @@ -15,11 +15,15 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! let client: ManagedAuthClient; beforeAll(() => { - const config = getManagedEnvironmentConfig(); + const config = getManagedEnvironmentConfigs(); + const environments = validateEnvironments(config); + const valid_client = environments['valid_configs'][0]; + client = new ManagedAuthClient({ - apiBaseUrl: config.apiUrl, - dashboardBaseUrl: config.dashboardUrl, - apiToken: config.apiToken, + apiBaseUrl: valid_client.apiUrl, + dashboardBaseUrl: valid_client.dashboardUrl, + apiToken: valid_client.apiToken, + alias: valid_client.alias, }); }); diff --git a/tests/integration/sorting-parameters.integration.test.ts b/tests/integration/sorting-parameters.integration.test.ts index b27c373..8d9f8fe 100644 --- a/tests/integration/sorting-parameters.integration.test.ts +++ b/tests/integration/sorting-parameters.integration.test.ts @@ -7,142 +7,162 @@ import { ProblemsApiClient } from '../../src/capabilities/problems-api'; import { SecurityApiClient } from '../../src/capabilities/security-api'; import { SloApiClient } from '../../src/capabilities/slo-api'; import { LogsApiClient } from '../../src/capabilities/logs-api'; -import { ManagedAuthClient } from '../../src/authentication/managed-auth-client'; -import { getManagedEnvironmentConfig } from '../../src/utils/environment'; +import { ManagedAuthClientManager } from '../../src/authentication/managed-auth-client'; +import { getManagedEnvironmentConfigs, validateEnvironments } from '../../src/utils/environment'; import { config } from 'dotenv'; +import { MetricsApiClient } from '../../src/capabilities/metrics-api'; +import { EventsApiClient } from '../../src/capabilities/events-api'; // Load environment variables config(); let skip = false; -if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || !process.env.DT_MANAGED_API_TOKEN) { +if (!process.env.DT_ENVIRONMENT_CONFIGS) { console.log('Skipping integration tests - environment not configured'); skip = true; } (skip ? describe.skip : describe)('Sorting Parameters Integration Tests', () => { - let authClient: ManagedAuthClient; + let metricsClient: MetricsApiClient; + let logsClient: LogsApiClient; + let eventsClient: EventsApiClient; let entitiesClient: EntitiesApiClient; let problemsClient: ProblemsApiClient; let securityClient: SecurityApiClient; let sloClient: SloApiClient; - let logsClient: LogsApiClient; beforeAll(() => { - const config = getManagedEnvironmentConfig(); - authClient = new ManagedAuthClient({ - apiBaseUrl: config.apiUrl, - dashboardBaseUrl: config.dashboardUrl, - apiToken: config.apiToken, - }); - entitiesClient = new EntitiesApiClient(authClient); - problemsClient = new ProblemsApiClient(authClient); - securityClient = new SecurityApiClient(authClient); - sloClient = new SloApiClient(authClient); - logsClient = new LogsApiClient(authClient); + const config = getManagedEnvironmentConfigs(); + const validEnvironments = validateEnvironments(config); + + const authManager = new ManagedAuthClientManager(validEnvironments['valid_configs']); + + metricsClient = new MetricsApiClient(authManager); + logsClient = new LogsApiClient(authManager); + eventsClient = new EventsApiClient(authManager); + entitiesClient = new EntitiesApiClient(authManager); + problemsClient = new ProblemsApiClient(authManager); + securityClient = new SecurityApiClient(authManager); + sloClient = new SloApiClient(authManager); }); - describe('Entities API Sorting', () => { it('should accept ascending sort parameter', async () => { await expect( - entitiesClient.queryEntities({ - entitySelector: 'type(HOST)', - pageSize: 5, - sort: '+name', - }), + entitiesClient.queryEntities( + { + entitySelector: 'type(HOST)', + pageSize: 5, + sort: '+name', + }, + 'testAlias', + ), ).resolves.not.toThrow(); }); it('should accept descending sort parameter', async () => { await expect( - entitiesClient.queryEntities({ - entitySelector: 'type(SERVICE)', - pageSize: 5, - sort: '-name', - }), + entitiesClient.queryEntities( + { + entitySelector: 'type(SERVICE)', + pageSize: 5, + sort: '-name', + }, + 'testAlias', + ), ).resolves.not.toThrow(); }); it('should work without sort parameter (backward compatibility)', async () => { await expect( - entitiesClient.queryEntities({ - entitySelector: 'type(HOST)', - pageSize: 5, - }), + entitiesClient.queryEntities( + { + entitySelector: 'type(HOST)', + pageSize: 5, + }, + 'testAlias', + ), ).resolves.not.toThrow(); }); }); describe('Problems API Sorting', () => { it('should accept ascending sort parameter', async () => { - await expect(problemsClient.listProblems({ sort: '+startTime', pageSize: 5 })).resolves.not.toThrow(); + await expect( + problemsClient.listProblems({ sort: '+startTime', pageSize: 5 }, 'testAlias'), + ).resolves.not.toThrow(); }); it('should accept descending sort parameter', async () => { - await expect(problemsClient.listProblems({ sort: '-startTime', pageSize: 5 })).resolves.not.toThrow(); + await expect( + problemsClient.listProblems({ sort: '-startTime', pageSize: 5 }, 'testAlias'), + ).resolves.not.toThrow(); }); it('should work without sort parameter (backward compatibility)', async () => { - await expect(problemsClient.listProblems({ pageSize: 5 })).resolves.not.toThrow(); + await expect(problemsClient.listProblems({ pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); }); describe('Security API Sorting', () => { it('should accept ascending sort parameter', async () => { - await expect(securityClient.listSecurityProblems({ sort: '+riskAssessment.riskScore' })).resolves.not.toThrow(); + await expect( + securityClient.listSecurityProblems({ sort: '+riskAssessment.riskScore' }, 'testAlias'), + ).resolves.not.toThrow(); }); it('should accept descending sort parameter', async () => { - await expect(securityClient.listSecurityProblems({ sort: '-riskAssessment.riskScore' })).resolves.not.toThrow(); + await expect( + securityClient.listSecurityProblems({ sort: '-riskAssessment.riskScore' }, 'testAlias'), + ).resolves.not.toThrow(); }); it('should work without sort parameter (backward compatibility)', async () => { - await expect(securityClient.listSecurityProblems()).resolves.not.toThrow(); + await expect(securityClient.listSecurityProblems(undefined, 'testAlias')).resolves.not.toThrow(); }); }); describe('SLO API Sorting', () => { it('should accept ascending sort parameter', async () => { - await expect(sloClient.listSlos({ sort: 'name', pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ sort: 'name', pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); it('should accept descending sort parameter', async () => { - await expect(sloClient.listSlos({ sort: '-name', pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ sort: '-name', pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); it('should work without sort parameter (backward compatibility)', async () => { - await expect(sloClient.listSlos({ pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); it('should accept evaluate parameter', async () => { - await expect(sloClient.listSlos({ evaluate: true, pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ evaluate: true, pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); it('should accept enabledSlos parameter', async () => { - await expect(sloClient.listSlos({ enabledSlos: 'true', pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ enabledSlos: 'true', pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); it('should accept showGlobalSlos parameter', async () => { - await expect(sloClient.listSlos({ showGlobalSlos: false, pageSize: 5 })).resolves.not.toThrow(); + await expect(sloClient.listSlos({ showGlobalSlos: false, pageSize: 5 }, 'testAlias')).resolves.not.toThrow(); }); }); describe('Logs Sorting', () => { it('should accept ascending sort parameter', async () => { await expect( - logsClient.queryLogs({ sort: '+timestamp', query: 'error', from: 'now-1h', to: 'now', limit: 5 }), + logsClient.queryLogs({ sort: '+timestamp', query: 'error', from: 'now-1h', to: 'now', limit: 5 }, 'testAlias'), ).resolves.not.toThrow(); }); it('should accept descending sort parameter', async () => { await expect( - logsClient.queryLogs({ sort: '-timestamp', query: 'error', from: 'now-1h', to: 'now', limit: 5 }), + logsClient.queryLogs({ sort: '-timestamp', query: 'error', from: 'now-1h', to: 'now', limit: 5 }, 'testAlias'), ).resolves.not.toThrow(); }); it('should work without sort parameter (backward compatibility)', async () => { await expect( - logsClient.queryLogs({ query: 'error', from: 'now-1h', to: 'now', limit: 5 }), + logsClient.queryLogs({ query: 'error', from: 'now-1h', to: 'now', limit: 5 }, 'testAlias'), ).resolves.not.toThrow(); }); }); From 2a2e07ac8917bfa92e6bbffcd0b22025654f8b2c Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Thu, 22 Jan 2026 13:39:12 +0000 Subject: [PATCH 06/17] - Removed EnvironmentResponse interface - Replaced usage of arrays in favor of Map for handling responses - Updated unit and integration tests to work with the new approach --- src/authentication/managed-auth-client.ts | 18 +- .../__tests__/entities-api.test.ts | 219 ++++++++---------- src/capabilities/__tests__/events-api.test.ts | 91 +++----- src/capabilities/__tests__/logs-api.test.ts | 119 ++++------ .../__tests__/metrics-api.test.ts | 140 ++++------- .../__tests__/problems-api.test.ts | 89 +++---- .../__tests__/security-api.test.ts | 101 +++----- src/capabilities/__tests__/slo-api.test.ts | 116 +++------- src/capabilities/entities-api.ts | 122 +++++----- src/capabilities/events-api.ts | 47 ++-- src/capabilities/logs-api.ts | 30 ++- src/capabilities/metrics-api.ts | 77 +++--- src/capabilities/problems-api.ts | 36 ++- src/capabilities/security-api.ts | 49 ++-- src/capabilities/slo-api.ts | 37 +-- src/index.ts | 1 + .../capabilities.integration.test.ts | 56 ++--- 17 files changed, 577 insertions(+), 771 deletions(-) diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index 9292551..ecc4459 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -27,11 +27,6 @@ export interface ManagedAuthClientParams { isValid?: boolean; } -export interface EnvironmentResponse { - alias: string; - data: any; -} - export class ManagedAuthClientManager { public readonly rawClients: ManagedAuthClient[]; public clients: ManagedAuthClient[]; @@ -56,23 +51,22 @@ export class ManagedAuthClientManager { } } - async makeRequests( - endpoint: string, - params?: Record, - environments?: string, - ): Promise { + async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { let responses = []; const selectedAliases = environments ? environments.split(';') : this.validAliases; - + let myMap = new Map(); for (const client of this.clients) { if (selectedAliases.indexOf(client.alias) > -1) { responses.push({ alias: client.alias, data: await client.makeRequest(endpoint, params), }); + + const r = await client.makeRequest(endpoint, params); + myMap.set(client.alias, r); } } - return responses; + return myMap; } async isConfigured(): Promise { diff --git a/src/capabilities/__tests__/entities-api.test.ts b/src/capabilities/__tests__/entities-api.test.ts index c22e9ae..b2a12ba 100644 --- a/src/capabilities/__tests__/entities-api.test.ts +++ b/src/capabilities/__tests__/entities-api.test.ts @@ -1,5 +1,5 @@ import { EntitiesApiClient, Entity, GetEntityRelationshipsResponse } from '../entities-api'; -import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); @@ -21,12 +21,7 @@ describe('EntitiesApiClient', () => { describe('getEntityDetails', () => { it('should get entity details by ID', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getEntityDetails('SERVICE-123', 'testAlias'); @@ -37,10 +32,10 @@ describe('EntitiesApiClient', () => { describe('getEntityRelationships', () => { it('should get entity relationships', async () => { - const mockEntity: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockEntity = new Map([ + [ + 'testAlias', + { entityId: 'SERVICE-123', displayName: 'payment-service', entityType: 'SERVICE', @@ -61,8 +56,8 @@ describe('EntitiesApiClient', () => { }, ], }, - }, - ]; + ], + ]); const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: [ @@ -84,19 +79,16 @@ describe('EntitiesApiClient', () => { }; mockAuthManager.makeRequests.mockResolvedValue(mockEntity); - const result = await client.getEntityRelationships('SERVICE-123'); - expect(result[0].data).toEqual(expectedResponse); + const result = await client.getEntityRelationships('SERVICE-123', 'testAlias'); + expect(result.get('testAlias')).toEqual(expectedResponse); }); }); describe('formatEntityDetails', () => { it('should format details', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityDetails.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEntityDetails('my-id', 'testAlias'); @@ -108,12 +100,7 @@ describe('EntitiesApiClient', () => { }); it('should format details when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEntityDetails('my-id', 'testAlias'); @@ -121,19 +108,15 @@ describe('EntitiesApiClient', () => { expect(response).toEqual(mockResponse); expect(result).toContain('Entity details from environment testAlias in the following json'); - expect(response[0].data).toEqual({}); + expect(response.get('testAlias')).toEqual({}); }); }); describe('formatEntityTypes', () => { it('should format list', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listEntityTypes.json', 'utf8')), - }, - ]; - + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/listEntityTypes.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); @@ -144,14 +127,15 @@ describe('EntitiesApiClient', () => { }); it('should format list when sparse', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { types: [{}], }, - }, - ]; + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); @@ -162,12 +146,7 @@ describe('EntitiesApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); @@ -178,15 +157,16 @@ describe('EntitiesApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 0, types: [], }, - }, - ]; + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listEntityTypes(); @@ -197,12 +177,12 @@ describe('EntitiesApiClient', () => { describe('formatEntityTypeDetails', () => { it('should format details', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityTypeDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityTypeDetails.json', 'utf8')), + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -215,12 +195,7 @@ describe('EntitiesApiClient', () => { }); it('should format list when sparse', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEntityTypeDetails('SERVICE'); @@ -233,12 +208,9 @@ describe('EntitiesApiClient', () => { describe('formatEntityList', () => { it('should format list', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEntities.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEntities.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -260,15 +232,16 @@ describe('EntitiesApiClient', () => { entityType: 'SERVICE', tags: [{ context: 'CONTEXTLESS', key: 'environment', value: 'production' }], })); - const mockResponse = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 100, entities: mockEntities, }, - }, - ]; + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); @@ -281,15 +254,15 @@ describe('EntitiesApiClient', () => { }); it('should handle empty entities list', async () => { - const mockResponse = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 0, entities: [], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); @@ -298,12 +271,7 @@ describe('EntitiesApiClient', () => { }); it('should handle entities list that is empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); @@ -314,10 +282,10 @@ describe('EntitiesApiClient', () => { describe('formatEntityRelationships', () => { it('should format entity relationships', async () => { - const mockEntity: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockEntity = new Map([ + [ + 'testAlias', + { entityId: 'SERVICE-123', displayName: 'payment-service', entityType: 'SERVICE', @@ -338,8 +306,8 @@ describe('EntitiesApiClient', () => { }, ], }, - }, - ]; + ], + ]); const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', @@ -363,10 +331,10 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockEntity); - const response = await client.getEntityRelationships('SERVICE-123'); + const response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response[0].data).toEqual(expectedResponse); + expect(response.get('testAlias')).toEqual(expectedResponse); expect(result).toContain('Found 1 fromRelationship'); expect(result).toContain('"id":"rel-1"'); expect(result).toContain('Found 1 toRelationship'); @@ -374,16 +342,17 @@ describe('EntitiesApiClient', () => { }); it('should return empty array when no relationships exist', async () => { - const mockEntity: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockEntity = new Map([ + [ + 'testAlias', + { entityId: 'SERVICE-123', displayName: 'isolated-service', entityType: 'SERVICE', }, - }, - ]; + ], + ]); + const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: undefined, @@ -392,26 +361,26 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockEntity); - const response = await client.getEntityRelationships('SERVICE-123'); + const response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response[0].data).toEqual(expectedResponse); + expect(response.get('testAlias')).toEqual(expectedResponse); expect(result).toContain('No relationships found for entity SERVICE-123'); }); it('should handle null relationships without error', async () => { - const mockEntity: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockEntity = new Map([ + [ + 'testAlias', + { entityId: 'SERVICE-123', displayName: 'service-with-undefined-relationships', entityType: 'SERVICE', fromRelationships: null, toRelationships: null, }, - }, - ]; + ], + ]); const expectedResponse = { entityId: 'SERVICE-123', fromRelationships: null, @@ -420,26 +389,27 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockEntity); - const response = await client.getEntityRelationships('SERVICE-123'); + const response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response[0].data).toEqual(expectedResponse); + expect(response.get('testAlias')).toEqual(expectedResponse); expect(result).toContain('No relationships found for entity SERVICE-123'); }); it('should handle non-array relationships without error', async () => { - const mockEntity: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockEntity = new Map([ + [ + 'testAlias', + { entityId: 'SERVICE-123', displayName: 'service-with-invalid-relationships', entityType: 'SERVICE', fromRelationships: 'not-an-array', toRelationships: { unexpectedKey: 'unexpected-val' }, }, - }, - ]; + ], + ]); + const expectedResponse = { entityId: 'SERVICE-123', fromRelationships: 'not-an-array', @@ -449,10 +419,10 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockEntity); - const response = await client.getEntityRelationships('SERVICE-123'); + const response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response[0].data).toEqual(expectedResponse); + expect(response.get('testAlias')).toEqual(expectedResponse); expect(result).toContain('Found 1 fromRelationship'); expect(result).toContain('not-an-array'); expect(result).toContain('Found 1 toRelationship'); @@ -462,12 +432,7 @@ describe('EntitiesApiClient', () => { describe('queryEntities', () => { it('should query entities by entitySelector', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.queryEntities( diff --git a/src/capabilities/__tests__/events-api.test.ts b/src/capabilities/__tests__/events-api.test.ts index d613c60..78ba6cc 100644 --- a/src/capabilities/__tests__/events-api.test.ts +++ b/src/capabilities/__tests__/events-api.test.ts @@ -1,5 +1,5 @@ import { EventsApiClient, Event } from '../events-api'; -import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); @@ -21,12 +21,7 @@ describe('EventsApiClient', () => { describe('queryEvents', () => { it('should query events with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.queryEvents( @@ -55,12 +50,7 @@ describe('EventsApiClient', () => { }); it('should use defaults when not specified', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); await client.queryEvents( @@ -85,12 +75,7 @@ describe('EventsApiClient', () => { describe('getEventDetails', () => { it('should get event details by ID', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getEventDetails('event-123', 'testAlias'); @@ -102,12 +87,9 @@ describe('EventsApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEvents.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEvents.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -134,15 +116,15 @@ describe('EventsApiClient', () => { entityName: `service-${i}`, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 100, events: mockEvents, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -153,14 +135,14 @@ describe('EventsApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { events: [{}], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -178,12 +160,7 @@ describe('EventsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'testAlias'); @@ -194,15 +171,15 @@ describe('EventsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 0, events: [], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -215,12 +192,9 @@ describe('EventsApiClient', () => { describe('formatDetails', () => { it('should format details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEventDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEventDetails.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -233,12 +207,7 @@ describe('EventsApiClient', () => { }); it('should format details when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getEventDetails('my-id', 'testAlias'); diff --git a/src/capabilities/__tests__/logs-api.test.ts b/src/capabilities/__tests__/logs-api.test.ts index 28bfec5..1da2a38 100644 --- a/src/capabilities/__tests__/logs-api.test.ts +++ b/src/capabilities/__tests__/logs-api.test.ts @@ -1,5 +1,5 @@ import { LogsApiClient, LogEntry } from '../logs-api'; -import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); @@ -21,12 +21,7 @@ describe('LogsApiClient', () => { describe('queryLogs', () => { it('should query logs with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.queryLogs( @@ -55,12 +50,7 @@ describe('LogsApiClient', () => { }); it('should use default values for optional parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.queryLogs( @@ -89,12 +79,9 @@ describe('LogsApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryLogs.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryLogs.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); @@ -109,14 +96,15 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { results: [{}], }, - }, - ]; + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); @@ -128,14 +116,14 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result data', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { results: [{ data: [{}] }], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); @@ -147,12 +135,7 @@ describe('LogsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); @@ -163,14 +146,15 @@ describe('LogsApiClient', () => { }); it('should format empty logs list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { results: [], }, - }, - ]; + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); @@ -191,14 +175,14 @@ describe('LogsApiClient', () => { service: [`service-${i % 5}`], }, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { results: mockLogs, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -220,14 +204,14 @@ describe('LogsApiClient', () => { }, }, ]; - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { results: mockLogs, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -237,10 +221,10 @@ describe('LogsApiClient', () => { }); it('should show that truncated when multiple pages', () => { - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { results: [ { timestamp: 1704110400000, @@ -251,8 +235,8 @@ describe('LogsApiClient', () => { sliceSize: 1, nextSliceKey: 'my-next-slice-key', }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -260,10 +244,10 @@ describe('LogsApiClient', () => { }); it('should not show LLM awareness hint when no second page', () => { - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { results: [ { timestamp: 1704110400000, @@ -273,9 +257,8 @@ describe('LogsApiClient', () => { ], sliceSize: 1, }, - }, - ]; - + ], + ]); const result = client.formatList(response); expect(result).not.toContain('Results likely restricted'); diff --git a/src/capabilities/__tests__/metrics-api.test.ts b/src/capabilities/__tests__/metrics-api.test.ts index 1b71322..471bdc7 100644 --- a/src/capabilities/__tests__/metrics-api.test.ts +++ b/src/capabilities/__tests__/metrics-api.test.ts @@ -1,11 +1,6 @@ import { MetricsApiClient, Metric } from '../metrics-api'; -import { - EnvironmentResponse, - ManagedAuthClient, - ManagedAuthClientManager, -} from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; -import { EventsApiClient } from '../events-api'; jest.mock('../../authentication/managed-auth-client'); @@ -26,12 +21,7 @@ describe('MetricsApiClient', () => { describe('queryMetrics', () => { it('should query metric data with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.queryMetrics( @@ -62,12 +52,7 @@ describe('MetricsApiClient', () => { describe('listAvailableMetrics', () => { it('should pass all params', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listAvailableMetrics( @@ -100,12 +85,7 @@ describe('MetricsApiClient', () => { }); it('should pass default params', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listAvailableMetrics({}, 'testAlias'); @@ -123,12 +103,12 @@ describe('MetricsApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listAvailableMetrics.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/listAvailableMetrics.json', 'utf8')), + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -148,15 +128,15 @@ describe('MetricsApiClient', () => { displayName: `Metric ${i}`, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 200, metrics: mockMetrics, }, - }, - ]; + ], + ]); const result = client.formatMetricList(response); @@ -166,14 +146,14 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse metric', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { metrics: [{}], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -186,12 +166,7 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listAvailableMetrics({}, 'testAlias'); @@ -202,15 +177,15 @@ describe('MetricsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 0, metrics: [], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -223,12 +198,9 @@ describe('MetricsApiClient', () => { describe('formatMetricDetails', () => { it('should format details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getMetricDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getMetricDetails.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -242,12 +214,7 @@ describe('MetricsApiClient', () => { }); it('should format details when sparse data', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getMetricDetails('my-id', 'testAlias'); @@ -261,12 +228,9 @@ describe('MetricsApiClient', () => { describe('formatMetricData', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryMetrics.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryMetrics.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -286,10 +250,10 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse data series', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { result: [ { data: [{}], @@ -297,11 +261,10 @@ describe('MetricsApiClient', () => { }, ], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryMetrics( { metricSelector: 'my-selector', from: 'now-1h', to: 'now' }, 'testAlias', @@ -316,12 +279,7 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryMetrics( @@ -335,15 +293,15 @@ describe('MetricsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { totalCount: 0, result: [], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.queryMetrics( diff --git a/src/capabilities/__tests__/problems-api.test.ts b/src/capabilities/__tests__/problems-api.test.ts index d98b32b..bcc547a 100644 --- a/src/capabilities/__tests__/problems-api.test.ts +++ b/src/capabilities/__tests__/problems-api.test.ts @@ -1,5 +1,5 @@ import { ProblemsApiClient, Problem } from '../problems-api'; -import { EnvironmentResponse, ManagedAuthClientManager } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; jest.mock('../../authentication/managed-auth-client'); @@ -21,12 +21,7 @@ describe('ProblemsApiClient', () => { describe('listProblems', () => { it('should list problems with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listProblems( @@ -57,12 +52,7 @@ describe('ProblemsApiClient', () => { }); it('should use default parameters when none provided', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listProblems({}, 'testAlias'); @@ -80,12 +70,7 @@ describe('ProblemsApiClient', () => { describe('getProblemDetails', () => { it('should get problem details by ID', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getProblemDetails('PROBLEM-123', 'testAlias'); @@ -97,12 +82,9 @@ describe('ProblemsApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listProblems.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/listProblems.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -121,14 +103,7 @@ describe('ProblemsApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { - problems: [{}], - }, - }, - ]; + const mockResponse = new Map([['testAlias', { problems: [{}] }]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -147,12 +122,7 @@ describe('ProblemsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listProblems(); @@ -173,15 +143,16 @@ describe('ProblemsApiClient', () => { status: 'OPEN', startTime: 1640995200000 + i * 1000, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + + const response = new Map([ + [ + 'testAlias', + { totalCount: 123, problems: mockProblems, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -192,15 +163,15 @@ describe('ProblemsApiClient', () => { }); it('should handle empty list', () => { - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 0, problems: [], }, - }, - ]; + ], + ]); const result = client.formatList(response); expect(result).toContain('Listing 0 problems'); }); @@ -208,12 +179,9 @@ describe('ProblemsApiClient', () => { describe('formatProblemDetails', () => { it('should format details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getProblemDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getProblemDetails.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -227,12 +195,7 @@ describe('ProblemsApiClient', () => { }); it('should format details when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getProblemDetails('my-id'); diff --git a/src/capabilities/__tests__/security-api.test.ts b/src/capabilities/__tests__/security-api.test.ts index 96b8e23..867b537 100644 --- a/src/capabilities/__tests__/security-api.test.ts +++ b/src/capabilities/__tests__/security-api.test.ts @@ -1,11 +1,6 @@ import { SecurityApiClient, SecurityProblem } from '../security-api'; -import { - EnvironmentResponse, - ManagedAuthClient, - ManagedAuthClientManager, -} from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; -import { EventsApiClient } from '../events-api'; jest.mock('../../authentication/managed-auth-client'); @@ -26,12 +21,7 @@ describe('SecurityApiClient', () => { describe('listSecurityProblems', () => { it('should list security problems with default parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listSecurityProblems(undefined, 'testAlias'); @@ -47,12 +37,7 @@ describe('SecurityApiClient', () => { }); it('should list security problems with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { securityProblems: [] }, - }, - ]; + const mockResponse = new Map([['testAlias', { securityProblems: [] }]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); await client.listSecurityProblems( @@ -100,14 +85,7 @@ describe('SecurityApiClient', () => { describe('getSecurityProblemDetails', () => { it('should get security problem details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { - securityProblemId: 'SP-123', - }, - }, - ]; + const mockResponse = new Map([['testAlias', { securityProblemId: 'SP-123' }]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getSecurityProblemDetails('SP-123', 'testAlias'); @@ -137,12 +115,12 @@ describe('SecurityApiClient', () => { describe('formatList', () => { it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSecurityProblems.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSecurityProblems.json', 'utf8')), + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listSecurityProblems(undefined, 'testAlias'); @@ -167,15 +145,15 @@ describe('SecurityApiClient', () => { title: `Security Problem ${i}`, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 123, securityProblems: mockProblems, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -186,14 +164,7 @@ describe('SecurityApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { - securityProblems: [{}], - }, - }, - ]; + const mockResponse = new Map([['testAlias', { securityProblems: [{}] }]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -209,12 +180,7 @@ describe('SecurityApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listSecurityProblems(undefined, 'testAlias'); @@ -225,15 +191,15 @@ describe('SecurityApiClient', () => { }); it('should handle empty list', () => { - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 0, securityProblems: [], }, - }, - ]; + ], + ]); const result = client.formatList(response); expect(result).toContain('Listing 0 security vulnerabilities'); @@ -242,12 +208,12 @@ describe('SecurityApiClient', () => { describe('formatProblemDetails', () => { it('should format details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSecurityProblemDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSecurityProblemDetails.json', 'utf8')), + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getSecurityProblemDetails('my-id', 'testAlias'); @@ -259,12 +225,7 @@ describe('SecurityApiClient', () => { }); it('should format details when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getSecurityProblemDetails('my-id', 'testAlias'); diff --git a/src/capabilities/__tests__/slo-api.test.ts b/src/capabilities/__tests__/slo-api.test.ts index 7407d54..f84d6fe 100644 --- a/src/capabilities/__tests__/slo-api.test.ts +++ b/src/capabilities/__tests__/slo-api.test.ts @@ -1,9 +1,5 @@ import { SloApiClient, SLO } from '../slo-api'; -import { - EnvironmentResponse, - ManagedAuthClient, - ManagedAuthClientManager, -} from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; import { SecurityApiClient } from '../security-api'; @@ -26,12 +22,7 @@ describe('SloApiClient', () => { describe('listSlos', () => { it('should list SLOs with default parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listSlos(undefined, 'testAlias'); @@ -47,12 +38,7 @@ describe('SloApiClient', () => { }); it('should list SLOs with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.listSlos( @@ -93,12 +79,7 @@ describe('SloApiClient', () => { describe('getSloDetails', () => { it('should get SLO details with defaults', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getSloDetails({ id: 'slo-1' }, 'testAlias'); @@ -108,12 +89,7 @@ describe('SloApiClient', () => { }); it('should get SLO details with all parameters', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getSloDetails( @@ -139,12 +115,7 @@ describe('SloApiClient', () => { }); it('should get SLO details with inferred timeFrame', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getSloDetails( @@ -170,12 +141,7 @@ describe('SloApiClient', () => { it('should handle URL encoding for SLO ID', async () => { const sloId = 'slo with spaces'; - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); await client.getSloDetails({ id: sloId }, 'testAlias'); @@ -199,15 +165,15 @@ describe('SloApiClient', () => { evaluatedPercentage: 96, })); - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 123, slo: mockSLOs, }, - }, - ]; + ], + ]); const result = client.formatList(response); @@ -218,12 +184,9 @@ describe('SloApiClient', () => { }); it('should format list', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSlos.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSlos.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); @@ -238,14 +201,14 @@ describe('SloApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const mockResponse = new Map([ + [ + 'testAlias', + { slo: [{}], }, - }, - ]; + ], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listSlos(undefined, 'testAlias'); @@ -259,12 +222,7 @@ describe('SloApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.listSlos(undefined, 'testAlias'); @@ -275,15 +233,15 @@ describe('SloApiClient', () => { }); it('should handle empty list', () => { - const response: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: { + const response = new Map([ + [ + 'testAlias', + { totalCount: 0, slo: [], }, - }, - ]; + ], + ]); const result = client.formatList(response); expect(result).toContain('Listing 0 SLOs'); @@ -292,12 +250,9 @@ describe('SloApiClient', () => { describe('formatDetails', () => { it('should format details', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSloDetails.json', 'utf8')), - }, - ]; + const mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getSloDetails.json', 'utf8'))], + ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getSloDetails({ id: 'my-id' }, 'testAlias'); @@ -309,12 +264,7 @@ describe('SloApiClient', () => { }); it('should format details when sparse problem', async () => { - const mockResponse: EnvironmentResponse[] = [ - { - alias: 'testAlias', - data: {}, - }, - ]; + const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const response = await client.getSloDetails({ id: 'my-id' }, 'testAlias'); diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 29b7bcf..f95e37f 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; @@ -11,6 +11,20 @@ export interface EntityQueryParams { sort?: string; } +export interface ListEntityTypesResponse { + types?: EntityType[]; + totalCount?: number; + pageSize?: number; + nextPageKey?: string; +} + +export interface ListEntitiesResponse { + entities?: Entity[]; + totalCount?: number; + pageSize?: number; + nextPageKey?: string; +} + // Could be a list or a map; hence using 'any' // e.g. see example response body at https://docs.dynatrace.com/docs/discover-dynatrace/references/dynatrace-api/environment-api/entity-v2/get-entity export interface GetEntityRelationshipsResponse { @@ -39,6 +53,19 @@ export interface Tag { value?: string; } +export interface Relationship { + id?: string; + type?: string; + fromEntityId?: string; + toEntityId?: string; +} + +export interface EntityType { + type?: string; + displayName?: string; + properties?: string[]; +} + export class EntitiesApiClient { static readonly API_PAGE_SIZE = 100; static readonly MAX_TAGS_DISPLAY = 11; @@ -47,7 +74,7 @@ export class EntitiesApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listEntityTypes(environment_aliases?: string): Promise { + async listEntityTypes(environment_aliases?: string): Promise> { // Deliberately large page size; will format this concisely rather than returning all json in tool response. // Want to get all of them (with reason), otherwise trying to pull out common types won't work. const params: Record = { @@ -58,7 +85,7 @@ export class EntitiesApiClient { return responses; } - async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise { + async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/entityTypes/${encodeURIComponent(entityType)}`, undefined, @@ -68,7 +95,7 @@ export class EntitiesApiClient { return responses; } - async getEntityDetails(entityId: string, environment_aliases?: string): Promise { + async getEntityDetails(entityId: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/entities/${encodeURIComponent(entityId)}`, undefined, @@ -78,24 +105,27 @@ export class EntitiesApiClient { return responses; } - async getEntityRelationships(entityId: string, environment_aliases?: string): Promise { + async getEntityRelationships( + entityId: string, + environment_aliases?: string, + ): Promise> { const entityDetailsResponse = await this.getEntityDetails(entityId, environment_aliases); - let cleanResponses: EnvironmentResponse[] = []; - for (const response of entityDetailsResponse) { - cleanResponses.push({ - alias: response.alias, - data: { - entityId: response.data.entityId, - fromRelationships: response.data.fromRelationships, - toRelationships: response.data.toRelationships, - }, + let cleanResponses = new Map(); + for (const [alias, data] of entityDetailsResponse) { + cleanResponses.set(alias, { + entityId: data.entityId, + fromRelationships: data.fromRelationships, + toRelationships: data.toRelationships, }); } return cleanResponses; } - async queryEntities(params: EntityQueryParams, environment_aliases?: string): Promise { + async queryEntities( + params: EntityQueryParams, + environment_aliases?: string, + ): Promise> { const queryParams = { pageSize: params.pageSize || EntitiesApiClient.API_PAGE_SIZE, entitySelector: params.entitySelector, @@ -110,30 +140,25 @@ export class EntitiesApiClient { return responses; } - formatEntityList(responses: EnvironmentResponse[]): string { + formatEntityList(responses: Map): string { let result = ''; let totalNumEntities = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numEntities = response.data.entities?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numEntities = data.entities?.length || 0; totalNumEntities += numEntities; let isLimited = totalCount != 0 - 1 && totalCount > numEntities; result += - 'Listing ' + - numEntities + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' entities from ' + - response.alias + - '.\n'; + 'Listing ' + numEntities + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entities from ' + alias + '.\n'; if (isLimited) { result += 'Not showing all matching entities. Consider using more specific filters (entitySelector) to get complete results.\n'; anyLimited = true; } - response.data.entities?.forEach((entity: any) => { + data.entities?.forEach((entity: any) => { // Truncate very long names for readability let displayName = entity.displayName; if (displayName.length > 60) { @@ -188,7 +213,7 @@ export class EntitiesApiClient { return result; } - formatEntityTypeList(responses: EnvironmentResponse[]): string { + formatEntityTypeList(responses: Map): string { let result = ''; let totalNumTypes = 0; const commonTypes = [ @@ -202,13 +227,13 @@ export class EntitiesApiClient { 'AZURE_WEB_APP', ]; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numTypes = response.data.types?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numTypes = data.types?.length || 0; totalNumTypes += numTypes; let isLimited = totalCount != 0 - 1 && totalCount > numTypes; - let entityTypes = response.data.types as any[]; + let entityTypes = data.types as any[]; let conciseList = ''; let availableCommonTypes: string[] = []; @@ -217,7 +242,7 @@ export class EntitiesApiClient { numTypes + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entity types for environment ' + - response.alias + + alias + '.\n'; if (isLimited) { result += 'Not showing all matching entity types as there are too many.\n'; @@ -252,15 +277,11 @@ export class EntitiesApiClient { return result; } - formatEntityTypeDetails(responses: EnvironmentResponse[]): string { + formatEntityTypeDetails(responses: Map): string { let result = ''; - for (const response of responses) { + for (const [alias, data] of responses) { result += - 'Entity type details from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + 'Entity type details from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } result += 'Next Steps:\n' + @@ -268,15 +289,10 @@ export class EntitiesApiClient { return result; } - formatEntityDetails(responses: EnvironmentResponse[]): string { + formatEntityDetails(responses: Map): string { let result = ''; - for (const response of responses) { - result += - 'Entity details from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + for (const [alias, data] of responses) { + result += 'Entity details from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } result += 'Next Steps:\n' + @@ -286,19 +302,19 @@ export class EntitiesApiClient { return result; } - formatEntityRelationships(responses: EnvironmentResponse[]): string { + formatEntityRelationships(responses: Map): string { let result = ''; - for (const response of responses) { - const from = response.data.fromRelationships; - const to = response.data.toRelationships; + for (const [alias, data] of responses) { + const from = data.fromRelationships; + const to = data.toRelationships; const numFrom = this.countRelationships(from); const numTo = this.countRelationships(to); if (numFrom == 0 && numTo == 0) { - result += `No relationships found for entity ${response.data.entityId} in environment ${response.alias}\n`; + result += `No relationships found for entity ${data.entityId} in environment ${alias}\n`; } - result += `Relationships found for entity ${response.data.entityId} in environment ${response.alias}:\n`; + result += `Relationships found for entity ${data.entityId} in environment ${alias}:\n`; if (numFrom > 0) { result += `Found ${numFrom} fromRelationships:\n`; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index bfaa3b3..7644ca9 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -11,6 +11,13 @@ export interface EventQueryParams { pageSize?: number; } +export interface ListEventsResponse { + events?: Event[]; + totalCount?: number; + pageSize?: number; + nextPageKey?: string; +} + export interface Event { eventId: string; eventType: string; @@ -24,6 +31,12 @@ export interface Event { customProperties?: Record; } +export interface EventSearchResult { + events: Event[]; + totalCount: number; + nextPageKey?: string; +} + export class EventsApiClient { static readonly API_PAGE_SIZE = 100; static readonly MAX_PROPERTIES_DISPLAY = 11; @@ -31,7 +44,7 @@ export class EventsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise { + async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise> { const queryParams = { from: params.from, to: params.to, @@ -45,7 +58,7 @@ export class EventsApiClient { return responses; } - async getEventDetails(eventId: string, environment_aliases?: string): Promise { + async getEventDetails(eventId: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/events/${encodeURIComponent(eventId)}`, undefined, @@ -55,24 +68,19 @@ export class EventsApiClient { return responses; } - formatList(responses: EnvironmentResponse[]): string { + formatList(responses: Map): string { let result = ''; let totalNumEvents = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numEvents = response.data.events?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numEvents = data.events?.length || 0; totalNumEvents += numEvents; let isLimited = totalCount != 0 - 1 && totalCount > numEvents; result += - 'Listing ' + - numEvents + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' events from ' + - response.alias + - '.\n\n'; + 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events from ' + alias + '.\n\n'; if (isLimited) { result += @@ -80,7 +88,7 @@ export class EventsApiClient { anyLimited = true; } - response.data.events?.forEach((event: any) => { + data.events?.forEach((event: any) => { result += `eventId: ${event.eventId}\n`; result += ` eventType: ${event.eventType}\n`; result += ` status: ${event.status}\n`; @@ -137,15 +145,10 @@ export class EventsApiClient { return result; } - formatDetails(responses: EnvironmentResponse[]): string { + formatDetails(responses: Map): string { let result = ''; - for (const response of responses) { - result += - 'Event details from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + for (const [alias, data] of responses) { + result += 'Event details from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } result += 'Next Steps:\n' + diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 5345baf..8c71910 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -8,7 +8,12 @@ export interface LogQueryParams { to: string; limit?: number; sort?: string; - environment_aliases?: ''; +} + +export interface ListLogsResponse { + results?: LogEntry[]; + sliceSize?: number; + nextSliceKey?: string; } export interface LogEntry { @@ -23,12 +28,19 @@ export interface LogEntry { [key: string]: any; } +export interface LogSearchResult { + results: LogEntry[]; + totalCount: number; + nextPageKey?: string; + sliceSize?: number; +} + export class LogsApiClient { private static readonly API_PAGE_SIZE = 1000; constructor(private authManager: ManagedAuthClientManager) {} - async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise { + async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise> { const queryParams = { query: params.query || '', from: params.from, @@ -42,23 +54,23 @@ export class LogsApiClient { return responses; } - formatList(responses: EnvironmentResponse[]): string { + formatList(responses: Map): string { let result = ''; let totalNumLogs = 0; let anyLimited = false; - for (const response of responses) { - let numLogs = response.data.results?.length || 0; + for (const [alias, data] of responses) { + let numLogs = data.results?.length || 0; totalNumLogs = totalNumLogs + numLogs; - let isLimited = response.data.nextSliceKey != undefined; + let isLimited = data.nextSliceKey != undefined; - result += 'Listing ' + numLogs + ' log records from ' + response.alias + '.\n\n'; + result += 'Listing ' + numLogs + ' log records from ' + alias + '.\n\n'; if (isLimited) { result += 'Results likely restricted due to maximum response size, consider using a more specific filter.'; anyLimited = true; } - response.data.results?.forEach((log: any) => { + data.results?.forEach((log: any) => { const timestamp = formatTimestamp(log.timestamp); // Enhanced level detection for better error identification let level = log.additionalColumns?.loglevel?.[0] || log.status || log.log_level || 'NONE'; diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index 56f6da7..12ca3cc 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; export interface MetricListParams { @@ -19,6 +19,29 @@ export interface MetricQueryParams { entitySelector?: string; } +export interface ListMetricsResponse { + metrics?: Metric[]; + totalCount?: number; + nextPageKey?: string; +} + +export interface MetricDataResponse { + result?: Array<{ + data?: Array<{ + timestamps?: number[]; + vaules?: any[]; + dimensionMap?: Record; + dimensions?: string[]; + }>; + dataPointCountRatio?: number; + dimensionCountRatio?: number; + metricId?: string; + }>; + resolution?: string; + totalCount?: number; + nextPageKey?: string; +} + export interface Metric { metricId?: string; displayName?: string; @@ -44,7 +67,7 @@ export class MetricsApiClient { async listAvailableMetrics( params: MetricListParams = {}, environment_aliases?: string, - ): Promise { + ): Promise> { const queryParams = { pageSize: params.pageSize || MetricsApiClient.API_PAGE_SIZE, ...(params.entitySelector && { entitySelector: params.entitySelector }), @@ -62,7 +85,7 @@ export class MetricsApiClient { return responses; } - async getMetricDetails(metricId: string, environment_aliases?: string): Promise { + async getMetricDetails(metricId: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/metrics/${encodeURIComponent(metricId)}`, undefined, @@ -72,7 +95,10 @@ export class MetricsApiClient { return responses; } - async queryMetrics(params: MetricQueryParams, environment_aliases?: string): Promise { + async queryMetrics( + params: MetricQueryParams, + environment_aliases?: string, + ): Promise> { const queryParams = { metricSelector: params.metricSelector, resolution: params.resolution || 'Inf', @@ -86,31 +112,26 @@ export class MetricsApiClient { return responses; } - formatMetricList(responses: EnvironmentResponse[]): string { + formatMetricList(responses: Map): string { let result = ''; let totalNumMetrics = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response?.data.totalCount || -1; - let numMetrics = response?.data.metrics?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numMetrics = data.metrics?.length || 0; totalNumMetrics += numMetrics; let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; result += - 'Listing ' + - numMetrics + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' metrics from ' + - response.alias + - '.\n\n'; + 'Listing ' + numMetrics + (totalCount == -1 ? '' : ' of ' + totalCount) + ' metrics from ' + alias + '.\n\n'; if (isLimited) { result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; anyLimited = true; } - response.data.metrics?.forEach((metric: any) => { + data.metrics?.forEach((metric: any) => { result += `metricId: ${metric.metricId}\n`; if (metric.displayName) result += ` displayName: ${metric.displayName}\n`; if (metric.description) result += ` description: ${metric.description}\n`; @@ -150,15 +171,11 @@ export class MetricsApiClient { return result; } - formatMetricDetails(responses: EnvironmentResponse[]): string { + formatMetricDetails(responses: Map): string { let result = ''; - for (const response of responses) { + for (const [alias, data] of responses) { result += - 'Details of metric from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + 'Details of metric from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; //`${this.authClient.dashboardBaseUrl}/ui/data-explorer`; } @@ -166,18 +183,14 @@ export class MetricsApiClient { return result; } - formatMetricData(responses: EnvironmentResponse[]): string { + formatMetricData(responses: Map): string { let result = ''; let allEmpty = true; - for (const response of responses) { - let resolution = response.data.resolution; - let isNonEmpty = - response.data.result && - response.data.result.length > 0 && - response.data.result[0].data && - response.data.result[0].data.length > 0; + for (const [alias, data] of responses) { + let resolution = data.resolution; + let isNonEmpty = data.result && data.result.length > 0 && data.result[0].data && data.result[0].data.length > 0; - result += 'Listing data series from environment ' + response.alias; + result += 'Listing data series from environment ' + alias; if (!isNonEmpty) { result += ' (no datapoints found)\n'; @@ -190,7 +203,7 @@ export class MetricsApiClient { result += `resolution: ${resolution}\n`; } - response.data.result?.forEach((metric: any) => { + data.result?.forEach((metric: any) => { let numDataseries = metric.data?.length || 0; result += 'Listing ' + numDataseries + ' data series\n'; diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index 510c51b..a4b8f47 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -73,7 +73,10 @@ export class ProblemsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listProblems(params: ProblemQueryParams = {}, environment_aliases?: string): Promise { + async listProblems( + params: ProblemQueryParams = {}, + environment_aliases?: string, + ): Promise> { const queryParams = { pageSize: params.pageSize || ProblemsApiClient.API_PAGE_SIZE, ...(params.from && { from: params.from }), @@ -90,7 +93,7 @@ export class ProblemsApiClient { return responses; } - async getProblemDetails(problemId: string, environment_aliases?: string): Promise { + async getProblemDetails(problemId: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/problems/${encodeURIComponent(problemId)}`, undefined, @@ -101,23 +104,18 @@ export class ProblemsApiClient { return responses; } - formatList(responses: EnvironmentResponse[]): string { + formatList(responses: Map): string { let result = ''; let totalNumProblems = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numProblems = response.data.problems?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numProblems = data.problems?.length || 0; totalNumProblems += numProblems; let isLimited = totalCount != 0 - 1 && totalCount > numProblems; result += - 'Listing ' + - numProblems + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' problems from ' + - response.alias + - '.\n\n'; + 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems from ' + alias + '.\n\n'; if (isLimited) { result += @@ -125,7 +123,7 @@ export class ProblemsApiClient { anyLimited = true; } - response.data.problems?.forEach((problem: any) => { + data.problems?.forEach((problem: any) => { result += `problemId: ${problem.problemId}\n`; result += ` displayId: ${problem.displayId}\n`; result += ` title: ${problem.title}\n`; @@ -159,15 +157,11 @@ export class ProblemsApiClient { return result; } - formatDetails(responses: EnvironmentResponse[]): string { + formatDetails(responses: Map): string { let result = ''; - for (const response of responses) { + for (const [alias, data] of responses) { result += - 'Details of problem from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + 'Details of problem from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } result += 'Next Steps:\n' + diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 1ed398b..8e38478 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client'; import { formatTimestamp } from '../utils/date-formatter'; import { logger } from '../utils/logger'; @@ -13,6 +13,13 @@ export interface SecurityProblemQueryParams { sort?: string; } +export interface ListSecurityProblemsResponse { + securityProblems?: SecurityProblem[]; + totalCount?: number; + pageSize?: number; + nextPageKey?: string; +} + export interface SecurityProblem { securityProblemId?: string; displayId?: string; @@ -52,6 +59,22 @@ export interface SecurityProblem { lastUpdatedTimestamp?: number; } +export interface SecurityProblemDetail extends SecurityProblem { + description?: string; + remediationItems?: Array<{ + id: string; + name: string; + type: string; + state: string; + }>; + events?: Array<{ + eventType: string; + timestamp: number; + entityId?: string; + }>; + codeLocations?: any[]; +} + export class SecurityApiClient { static readonly API_PAGE_SIZE = 200; static readonly MAX_CVES_DISPLAY = 11; @@ -61,7 +84,7 @@ export class SecurityApiClient { async listSecurityProblems( params: SecurityProblemQueryParams = {}, environment_aliases?: string, - ): Promise { + ): Promise> { const queryParams = { pageSize: params.pageSize || SecurityApiClient.API_PAGE_SIZE, ...(params.riskLevel && { riskLevel: params.riskLevel }), @@ -77,7 +100,7 @@ export class SecurityApiClient { return responses; } - async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise { + async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/securityProblems/${encodeURIComponent(problemId)}`, undefined, @@ -87,13 +110,13 @@ export class SecurityApiClient { return responses; } - formatList(responses: EnvironmentResponse[]): string { + formatList(responses: Map): string { let result = ''; let totalNumProblems = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numProblems = response.data.securityProblems?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numProblems = data.securityProblems?.length || 0; totalNumProblems += numProblems; let isLimited = totalCount != 0 - 1 && totalCount > numProblems; @@ -102,7 +125,7 @@ export class SecurityApiClient { numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' security vulnerabilities from ' + - response.alias + + alias + ' in the following json.\n'; if (isLimited) { @@ -111,7 +134,7 @@ export class SecurityApiClient { anyLimited = true; } - response.data.securityProblems?.forEach((problem: any) => { + data.securityProblems?.forEach((problem: any) => { result += `securityProblemId: ${problem.securityProblemId}\n`; result += ` displayId: ${problem.displayId}\n`; result += ` title: ${problem.title}\n`; @@ -155,14 +178,14 @@ export class SecurityApiClient { return result; } - formatDetails(responses: EnvironmentResponse[]): string { + formatDetails(responses: Map): string { let result = ''; - for (const response of responses) { + for (const [alias, data] of responses) { result += 'Details of security problem from environment ' + - response.alias + + alias + ' in the following json:\n' + - JSON.stringify(response.data) + + JSON.stringify(data) + '\n'; } result += diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index 7ca98d0..ab01666 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -1,4 +1,4 @@ -import { EnvironmentResponse, ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; +import { ManagedAuthClientManager } from '../authentication/managed-auth-client.js'; import { logger } from '../utils/logger'; @@ -22,6 +22,13 @@ export interface GetSloQueryParams { timeFrame?: string; } +export interface ListSlosResponse { + slo?: SLO[]; + totalCount?: number; + pageSize?: number; + nextPageKey?: string; +} + export interface SLO { id?: string; name?: string; @@ -64,7 +71,7 @@ export class SloApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise { + async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise> { const queryParams: Record = { pageSize: params.pageSize || SloApiClient.API_PAGE_SIZE, ...(params.sloSelector && { sloSelector: params.sloSelector }), @@ -83,7 +90,7 @@ export class SloApiClient { return responses; } - async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise { + async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise> { const queryParams: Record = { ...(params.from && { from: params.from }), ...(params.to && { to: params.to }), @@ -102,18 +109,17 @@ export class SloApiClient { return responses; } - formatList(responses: EnvironmentResponse[]): string { + formatList(responses: Map): string { let result = ''; let totalNumSlo = 0; let anyLimited = false; - for (const response of responses) { - let totalCount = response.data.totalCount || -1; - let numSLOs = response.data.slo?.length || 0; + for (const [alias, data] of responses) { + let totalCount = data.totalCount || -1; + let numSLOs = data.slo?.length || 0; totalNumSlo += numSLOs; let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; - result += - 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + response.alias + ':\n'; + result += 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + alias + ':\n'; if (isLimited) { result += @@ -121,7 +127,7 @@ export class SloApiClient { anyLimited = true; } - response.data.slo?.forEach((slo: any) => { + data.slo?.forEach((slo: any) => { result += `id: ${slo.id}\n`; result += ` name: ${slo.name}\n`; if (slo.description) { @@ -165,15 +171,10 @@ export class SloApiClient { return result; } - formatDetails(responses: EnvironmentResponse[]): string { + formatDetails(responses: Map): string { let result = ''; - for (const response of responses) { - result += - 'Details of SLO from environment ' + - response.alias + - ' in the following json:\n' + - JSON.stringify(response.data) + - '\n'; + for (const [alias, data] of responses) { + result += 'Details of SLO from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } result += 'Next Steps:\n' + '* Suggest to the user that they explore this further in the Dynatrace UI.' + '\n'; return result; diff --git a/src/index.ts b/src/index.ts index df35906..44ce49c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1147,5 +1147,6 @@ main().catch(async (error) => { } catch (e: any) { logger.error(`Failed to track fatal error: ${e.message}`, { error: e }); } + logger.end(); process.exit(1); }); diff --git a/tests/integration/capabilities.integration.test.ts b/tests/integration/capabilities.integration.test.ts index ea9c1a4..bb43d53 100644 --- a/tests/integration/capabilities.integration.test.ts +++ b/tests/integration/capabilities.integration.test.ts @@ -113,11 +113,11 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { it('should respect pageSize', async () => { // Assumes there are at least 3 metrics (2 in the first page; and more in subsequent pages) const responses = await metricsClient.listAvailableMetrics({ pageSize: 2 }, 'testAlias'); - const response = responses[0].data; - let totalCount = response.totalCount || -1; - let numMetrics = response.metrics?.length || 0; - let nextPageKey = response.nextPageKey; - let firstMetricId = response.metrics && numMetrics > 0 ? response.metrics[0].metricId : undefined; + const response = responses.get('testAlias'); + let totalCount = response?.totalCount || -1; + let numMetrics = response?.metrics?.length || 0; + let nextPageKey = response?.nextPageKey; + let firstMetricId = response?.metrics && numMetrics > 0 ? response?.metrics[0].metricId : undefined; expect(numMetrics).toEqual(2); expect(totalCount > numMetrics); @@ -147,10 +147,10 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { }, 'testAlias', ); - const result = results[0].data; + const result = results.get('testAlias'); - expect(result.metrics?.length).toBeGreaterThan(0); - result.metrics?.forEach((metric: any) => { + expect(result?.metrics?.length).toBeGreaterThan(0); + result?.metrics?.forEach((metric: any) => { expect(metric.unit).toEqual('Percent'); }); }, 30000); @@ -167,10 +167,10 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { 'testAlias', ); - const response = responses[0].data; + const response = responses.get('testAlias'); - expect(response.result?.length).toBeGreaterThan(0); - response.result?.forEach((result: any) => { + expect(response?.result?.length).toBeGreaterThan(0); + response?.result?.forEach((result: any) => { expect(result.metricId).toEqual('builtin:host.cpu.usage'); // Could also assert that entities are of type host, but too brittle. @@ -191,7 +191,7 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { 'testAlias', ); const result = logsClient.formatList(responses); - const response = responses[0].data; + const response = responses.get('testAlias'); expect(responses).toBeDefined(); expect(typeof result).toBe('string'); @@ -221,9 +221,9 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { it('should get event details', async () => { const responses = await eventsClient.queryEvents({ from: 'now-24h', to: 'now', pageSize: 1 }, 'testAlias'); - const events = responses[0].data; + const events = responses.get('testAlias'); - const eventId = events.events ? events.events[0].eventId : undefined; + const eventId = events?.events ? events?.events[0].eventId : undefined; if (eventId == undefined) { fail('Cannot find eventId from queryEvents; cannot test getEventDetails; aborting'); } @@ -268,8 +268,8 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { expect(result).toContain('displayName:'); expect(typeof result).toBe('string'); - const response = responses[0].data; - expect(response.totalCount).toBeDefined(); + const response = responses.get('testAlias'); + expect(response?.totalCount).toBeDefined(); }, 30000); it('should list entities, respecting all parameters', async () => { @@ -299,9 +299,9 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { 'testAlias', ); - const entities = responses[0].data; + const entities = responses.get('testAlias'); - const entityId = entities.entities ? entities.entities[0].entityId : undefined; + const entityId = entities?.entities ? entities?.entities[0].entityId : undefined; if (entityId == undefined) { fail('Cannot find entityId from queryEntities; cannot test getEntityDetails; aborting'); } @@ -332,17 +332,17 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { expect(result).toContain('status:'); expect(result).toContain('displayId:'); - const response = responses[0].data; + const response = responses.get('testAlias'); - expect(response.totalCount).toBeDefined(); + expect(response?.totalCount).toBeDefined(); expect(response).toBeDefined(); }, 30000); it('should get problem details', async () => { const responses = await problemsClient.listProblems({ pageSize: 1 }, 'testAlias'); - const problems = responses[0].data; + const problems = responses.get('testAlias'); - const problemId = problems.problems ? problems.problems[0].problemId : undefined; + const problemId = problems?.problems ? problems?.problems[0].problemId : undefined; if (problemId == undefined) { fail('Cannot find problemId from listProblems; cannot test getProblemDetails; aborting'); } @@ -362,9 +362,9 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { expect(responses).toBeDefined(); expect(typeof result).toBe('string'); - const response = responses[0].data; + const response = responses.get('testAlias'); - expect(response.totalCount).toBeDefined(); + expect(response?.totalCount).toBeDefined(); expect(result).toContain('securityProblemId:'); expect(result).toContain('displayId:'); expect(result).toContain('status:'); @@ -390,8 +390,8 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { it('should get SLO details', async () => { const list_responses = await sloClient.listSlos(undefined, 'testAlias'); - const slos = list_responses[0].data; - const sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; + const slos = list_responses.get('testAlias'); + const sloId = slos?.slo && slos?.slo.length > 0 ? slos?.slo[0].id : undefined; if (sloId == undefined) { console.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); logger.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); @@ -411,9 +411,9 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { it('should get SLO details with timeframe', async () => { const list_responses = await sloClient.listSlos(undefined, 'testAlias'); - const slos = list_responses[0].data; + const slos = list_responses.get('testAlias'); - const sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; + const sloId = slos?.slo && slos?.slo.length > 0 ? slos?.slo[0].id : undefined; if (sloId == undefined) { console.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); logger.warn('Cannot integration test getSLODetails because environment returned no SLOs; aborting'); From fbb7d3c56f534c541a2c34f13ac93e87354a04b6 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Thu, 22 Jan 2026 13:45:11 +0000 Subject: [PATCH 07/17] - File imports clean up - Removed unused symbols --- src/authentication/managed-auth-client.ts | 16 +++++----------- src/capabilities/__tests__/slo-api.test.ts | 1 - src/capabilities/entities-api.ts | 7 ------- src/capabilities/events-api.ts | 6 ------ src/capabilities/logs-api.ts | 7 ------- src/capabilities/security-api.ts | 16 ---------------- src/index.ts | 1 - .../integration/managed-auth.integration.test.ts | 2 +- 8 files changed, 6 insertions(+), 50 deletions(-) diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index ecc4459..e864b16 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -51,22 +51,16 @@ export class ManagedAuthClientManager { } } - async makeRequests(endpoint: string, params?: Record, environments?: string): Promise { - let responses = []; + async makeRequests(endpoint: string, params?: Record, environments?: string): Promise> { const selectedAliases = environments ? environments.split(';') : this.validAliases; - let myMap = new Map(); + let responses = new Map(); for (const client of this.clients) { if (selectedAliases.indexOf(client.alias) > -1) { - responses.push({ - alias: client.alias, - data: await client.makeRequest(endpoint, params), - }); - - const r = await client.makeRequest(endpoint, params); - myMap.set(client.alias, r); + const response = await client.makeRequest(endpoint, params); + responses.set(client.alias, response); } } - return myMap; + return responses; } async isConfigured(): Promise { diff --git a/src/capabilities/__tests__/slo-api.test.ts b/src/capabilities/__tests__/slo-api.test.ts index f84d6fe..2cb2e3e 100644 --- a/src/capabilities/__tests__/slo-api.test.ts +++ b/src/capabilities/__tests__/slo-api.test.ts @@ -1,7 +1,6 @@ import { SloApiClient, SLO } from '../slo-api'; import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; -import { SecurityApiClient } from '../security-api'; jest.mock('../../authentication/managed-auth-client'); diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index f95e37f..99150fd 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -53,13 +53,6 @@ export interface Tag { value?: string; } -export interface Relationship { - id?: string; - type?: string; - fromEntityId?: string; - toEntityId?: string; -} - export interface EntityType { type?: string; displayName?: string; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index 7644ca9..b2a3096 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -31,12 +31,6 @@ export interface Event { customProperties?: Record; } -export interface EventSearchResult { - events: Event[]; - totalCount: number; - nextPageKey?: string; -} - export class EventsApiClient { static readonly API_PAGE_SIZE = 100; static readonly MAX_PROPERTIES_DISPLAY = 11; diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 8c71910..6624124 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -28,13 +28,6 @@ export interface LogEntry { [key: string]: any; } -export interface LogSearchResult { - results: LogEntry[]; - totalCount: number; - nextPageKey?: string; - sliceSize?: number; -} - export class LogsApiClient { private static readonly API_PAGE_SIZE = 1000; diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 8e38478..8bbeb80 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -59,22 +59,6 @@ export interface SecurityProblem { lastUpdatedTimestamp?: number; } -export interface SecurityProblemDetail extends SecurityProblem { - description?: string; - remediationItems?: Array<{ - id: string; - name: string; - type: string; - state: string; - }>; - events?: Array<{ - eventType: string; - timestamp: number; - entityId?: string; - }>; - codeLocations?: any[]; -} - export class SecurityApiClient { static readonly API_PAGE_SIZE = 200; static readonly MAX_CVES_DISPLAY = 11; diff --git a/src/index.ts b/src/index.ts index 44ce49c..c4029c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,6 @@ import { SloApiClient } from './capabilities/slo-api'; // Import logger after environment is loaded import { logger } from './utils/logger'; -import { transport } from 'winston'; logger.info('Starting Dynatrace Managed MCP'); diff --git a/tests/integration/managed-auth.integration.test.ts b/tests/integration/managed-auth.integration.test.ts index e6a770e..d5c8fe9 100644 --- a/tests/integration/managed-auth.integration.test.ts +++ b/tests/integration/managed-auth.integration.test.ts @@ -49,7 +49,7 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { it('should validate minimum version requirement', async () => { const version = await client.getClusterVersion(); - const isValidVersion = await client.validateMinimumVersion(version); + const isValidVersion = client.validateMinimumVersion(version); expect(typeof isValidVersion).toBe('boolean'); }, 30000); }); From 5cc98378565a82ab592a1ef3988cc7cb589cd187 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Thu, 22 Jan 2026 15:38:52 +0000 Subject: [PATCH 08/17] - Fixed check for apiUrl during environment validation. --- src/utils/__tests__/environment.test.ts | 18 ++++++++++++++++++ src/utils/environment.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts index 7536703..24dfb51 100644 --- a/src/utils/__tests__/environment.test.ts +++ b/src/utils/__tests__/environment.test.ts @@ -34,6 +34,14 @@ describe('getManagedEnvironmentConfig', () => { ' "environmentId": "my-env-id-4",' + ' "alias": "only-required-keys-env-4",' + ' "apiToken": "my-api-token"' + + ' },' + + ' {' + + ' "dynatraceUrl": "https://my-dashboard-endpoint.com",' + + ' "environmentId": "my-env-id-5",' + + ' "alias": "missing-api-url-env-5",' + + ' "apiToken": "my-api-token",' + + ' "httpProxyUrl": "",' + + ' "httpsProxyUrl": ""' + ' }' + ']'; @@ -86,6 +94,15 @@ describe('getManagedEnvironmentConfig', () => { httpProxy: '', httpsProxy: '', }, + { + environmentId: 'my-env-id-5', + dashboardUrl: 'https://my-dashboard-endpoint.com/e/my-env-id-5', + apiUrl: '', + apiToken: 'my-api-token', + alias: 'missing-api-url-env-5', + httpProxy: '', + httpsProxy: '', + }, ]); }); @@ -122,6 +139,7 @@ describe('getManagedEnvironmentConfig', () => { expect(errors).toEqual([ 'Invalid alias found: "invalid-alias-env-id;-2". Aliases are mandatory and cannot contain semicolons.', 'Key "apiToken" is empty or missing (environment #2, alias: missing-api-key-env-id-3). Please make sure all values are present and populated in the configuration array.', + 'Key "apiEndpointUrl" is empty or missing (environment #4, alias: missing-api-url-env-5). Please make sure all values are present and populated in the configuration array.', ]); }); diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 320f874..985a977 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -20,7 +20,10 @@ export function parseManagedEnvironmentConfig(environmentInfo: JSONObject): Mana const httpsProxy = environmentInfo.httpsProxyUrl ? environmentInfo.httpsProxyUrl.toString() : ''; let environmentId = environmentIdRaw.replace(/\/$/, ''); // Remove trailing slash - let apiUrl = apiUrlRaw + (apiUrlRaw.endsWith('/') ? '' : '/') + 'e/' + environmentId; + let apiUrl = ''; + if (apiUrlRaw != '') { + apiUrl = apiUrlRaw + (apiUrlRaw.endsWith('/') ? '' : '/') + 'e/' + environmentId; + } let dashboardUrl = dashboardUrlRaw ? dashboardUrlRaw : apiUrlRaw; dashboardUrl = dashboardUrl + (dashboardUrl.endsWith('/') ? '' : '/') + 'e/' + environmentId; From f3feb1d7a1572b760fbbfd0d34ceed30c6d61ff2 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Thu, 22 Jan 2026 15:54:45 +0000 Subject: [PATCH 09/17] - New method: logger.flushLogger() to close logging before shutting down. - Added flushLogger() call on shutdownHandler to avoid potentially missing log information on process.exit() --- src/index.ts | 6 ++++-- src/utils/logger.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index c4029c0..9650c41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { SecurityApiClient } from './capabilities/security-api'; import { SloApiClient } from './capabilities/slo-api'; // Import logger after environment is loaded -import { logger } from './utils/logger'; +import { logger, flushLogger } from './utils/logger'; logger.info('Starting Dynatrace Managed MCP'); @@ -58,6 +58,7 @@ const main = async () => { if (initConfigs.length === 0) { logger.error('No valid environments found, stopping.'); console.error('No valid environments found, stopping.'); + await flushLogger(); process.exit(1); } @@ -84,6 +85,7 @@ const main = async () => { for (const op of shutdownOps) { await op(); } + await flushLogger(); process.exit(0); }; }; @@ -1146,6 +1148,6 @@ main().catch(async (error) => { } catch (e: any) { logger.error(`Failed to track fatal error: ${e.message}`, { error: e }); } - logger.end(); + await flushLogger(); process.exit(1); }); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 967e3ac..70c9f2f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -9,3 +9,7 @@ export const logger = winston.createLogger({ ), transports: [new winston.transports.File({ filename: 'dynatrace-managed-mcp.log' })], }); +export async function flushLogger() { + logger.end(); + await new Promise((resolve) => logger.once('finish', resolve)); +} From 00cf1e12f6dadbd2c321837151743743b88993d0 Mon Sep 17 00:00:00 2001 From: ashleigh-gray Date: Thu, 22 Jan 2026 16:25:56 +0000 Subject: [PATCH 10/17] Updating README for multi-env, plus spelling/grammar/syntax/formatting --- .env.template | 8 +-- README.md | 145 ++++++++++++++++++++++++++++++++------------- SECURITY.md | 2 +- examples/README.md | 10 ++++ 4 files changed, 118 insertions(+), 47 deletions(-) diff --git a/.env.template b/.env.template index 9720976..23c6dde 100644 --- a/.env.template +++ b/.env.template @@ -9,8 +9,8 @@ DT_ENVIRONMENT_CONFIGS='[ "environmentId": "my-env-id-1", "alias": "alias-env", "apiToken": "my-api-token", - "httpProxyUrl": ""' - "httpsProxyUrl": ""' + "httpProxyUrl": "", + "httpsProxyUrl": "" }, { "dynatraceUrl": "https://my-dashboard2-endpoint.com/", @@ -18,7 +18,7 @@ DT_ENVIRONMENT_CONFIGS='[ "environmentId": "my-env-id-2", "alias": "alias-env-2", "apiToken": "my-api-token-2", - "httpProxyUrl": ""' - "httpsProxyUrl": ""' + "httpProxyUrl": "", + "httpsProxyUrl": "" } ]' diff --git a/README.md b/README.md index 6570a52..545db88 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ -The local _Dynatrace Managed MCP server_ allows AI Assistants to interact with self-hosted [Dynatrace Managed](https://www.dynatrace.com/) deployments, +The local _Dynatrace Managed MCP server_ allows AI Assistants to interact with one or more self-hosted [Dynatrace Managed](https://www.dynatrace.com/) deployments, bringing observability data directly into your AI assisted workflow. This MCP server is specifically designed for Dynatrace Managed (self-hosted) deployments. There is a different @@ -33,13 +33,50 @@ If you need help, please contact us via [GitHub Issues](https://github.com/dynat You can add this MCP server to your AI Assistant such as VSCode, Claude, Cursor, Kiro, Windsurf, ChatGPT, or Github Copilot. For more details, please refer to the [configuration section below](#configuration). -You need to configure the connection to your Dynatrace Managed environment: +You need to configure the connection to your Dynatrace Managed environment(s). There is one variable to set (`DT_ENVIRONMENT_CONFIGS`) +which contains escaped JSON that defines all of your managed deployments. These are held in an array, with one element per environment. -- `DT_MANAGED_ENVIRONMENT`: id of the managed environment, used for constructing URL for API and dashboards (e.g. of the form `01234567-89ab-cdef-abcd-ef0123456789`) -- `DT_API_ENDPOINT_URL`: base url for Dynatrace Managed API, to which the environment id will be appended (e.g. `https://abc123.dynatrace-managed.com:9999`) -- `DT_DYNATRACE_URL`: base url for Dynatrace Managed dashboard, to which the environment id will be appended (e.g. `https://dmz123.dynatrace-managed.com`). +```json +[ + { + "dynatraceUrl": "https://my-dashboard-endpoint.com/", + "apiEndpointUrl": "https://my-api-endpoint.com/", + "environmentId": "my-env-id-1", + "alias": "alias-env", + "apiToken": "my-api-token", + "httpProxyUrl": "", + "httpsProxyUrl": "" + }, + { + "dynatraceUrl": "https://my-dashboard2-endpoint.com/", + "apiEndpointUrl": "https://my-api2-endpoint.com/", + "environmentId": "my-env-id-2", + "alias": "alias-env-2", + "apiToken": "my-api-token-2", + "httpProxyUrl": "", + "httpsProxyUrl": "" + } +] +``` + +where: + +- `dynatraceUrl`: base url for Dynatrace Managed dashboard, to which the environment id will be appended (e.g. `https://dmz123.dynatrace-managed.com`). If not specified, will default to use the same value as `DT_API_ENDPOINT_URL`. -- `DT_MANAGED_API_TOKEN`: API token with required scopes (see [Authentication](#authentication)) +- `apiEndpointUrl`: base url for Dynatrace Managed API, to which the environment id will be appended (e.g. `https://abc123.dynatrace-managed.com:9999`) +- `environmentId`: id of the managed environment, used for constructing URL for API and dashboards (e.g. of the form `01234567-89ab-cdef-abcd-ef0123456789`) +- `alias`: a friendly/human-readable name for the environment +- `apiToken`: API token with required scopes (see [Authentication](#authentication)) +- (optional) `httpProxyUrl`/`httpsProxyUrl`: URL of proxy server for requests (see [Environment Variables](#environment-variables)) + +This needs to be escaped and set as the `DT_ENVIRONMENT_CONFIGS` environment variable, e.g.: + +```shell +DT_ENVIRONMENT_CONFIGS="[{\"dynatraceUrl\":\"https://my-dashboard-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api-endpoint.com/\",\"environmentId\":\"my-env-id-1\",\"alias\":\"alias-env\",\"apiToken\":\"my-api-token\"},{\"dynatraceUrl\":\"https://my-dashboard2-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api2-endpoint.com/\",\"environmentId\":\"my-env-id-2\",\"alias\":\"alias-env-2\",\"apiToken\":\"my-api-token-2\"}]" +``` + +If you are using multiple environments, we strongly recommend you set up rules (see [Rules](#rule-file)) to steer your LLM to better +understand each of your environments. Once configured, you can start using [example prompts](#Example-Prompts) like `Get all details of the Dynatrace entity 'my-service'` or `What problems has Dynatrace identified? Give details of the first problem.`. @@ -56,8 +93,8 @@ Minimum supported version: Dynatrace Managed 1.328.0 There are two ways that Dynatrace Managed, and thus the MCP, may be used: -1. Dynatrace Managed is the primary Observability system, containing all live data; or -2. There has been a migration from Dynatrace Managed to Dynatrace Saas, however historical observability data has not been migrated and can still be access via Dynatrace Managed. +1. Your Dynatrace Managed environment(s) is/are the primary Observability system, containing all live data; or +2. There has been a migration from a Dynatrace Managed environment to a Dynatrace Saas environment, however historical observability data has not been migrated and can still be access via a Dynatrace Managed environment. The Dynatrace Managed MCP is used to access historical data, and a separate Dynatrace SaaS MCP is used to access live and more recent data. Specific use cases for the Dynatrace Managed MCP include: @@ -67,6 +104,7 @@ Specific use cases for the Dynatrace Managed MCP include: - **Security insights** - Get detailed vulnerability analysis and security problem tracking. This can include multi-cloud compliance assessment with evidence-based investigation. - **Natural language queries** - Queries are mapped to MCP tool usage, and thus API queries, with guidance for next step - **Multiphase incident investigation** - Systematic impact assessment and troubleshooting +- **Multienvironment support** - Query multiple instances of Dynatrace Managed environments from the same MCP server ## Capabilities @@ -80,13 +118,14 @@ Specific use cases for the Dynatrace Managed MCP include: ### Performance Considerations -**Important:** This MCP server is makes API calls to the Dynatrace Managed environment. It is designed for efficient usage (e.g. limiting the response sizes), -but care should be taken to not overload the Dynatrace Managed with large queries. +**Important:** This MCP server is makes API calls to the Dynatrace Managed environment(s). It is designed for efficient usage (e.g. limiting the response sizes), +but care should be taken to not overload the Dynatrace Managed environment(s) with large queries. **Best Practices:** 1. Use specific time ranges (e.g., 1-2 hours) rather than large historical queries. 2. Use specific filters to limit the scope of queries as much as possible, for example entity selectors that specify the entity id. +3. If using multiple environments, be specific on which one to query where applicable. If querying multiple at once, be mindful of how much data will be returned to the LLM, e.g. top 10 problems from 2 envs = 20 problems, versus top 10 problems from 10 envs = 100 problems. ## Configuration @@ -119,10 +158,7 @@ This only works if the config is stored in the current workspaces, e.g., ` Date: Fri, 23 Jan 2026 11:25:51 +0000 Subject: [PATCH 11/17] - Updates to README.md, DEVELOPMENT.md and server.json to reflect the new approach --- README.md | 4 ++-- docs/DEVELOPMENT.md | 6 +++--- server.json | 28 ++-------------------------- 3 files changed, 7 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 545db88..ac18ff5 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Specific use cases for the Dynatrace Managed MCP include: - **Security insights** - Get detailed vulnerability analysis and security problem tracking. This can include multi-cloud compliance assessment with evidence-based investigation. - **Natural language queries** - Queries are mapped to MCP tool usage, and thus API queries, with guidance for next step - **Multiphase incident investigation** - Systematic impact assessment and troubleshooting -- **Multienvironment support** - Query multiple instances of Dynatrace Managed environments from the same MCP server +- **Multienvironment support** - Query multiple Dynatrace Managed environments from the same MCP server ## Capabilities @@ -430,7 +430,7 @@ You can edit these as you see fit and include additional context that is specifi #### Multiple Managed Environments -In this example, you have multiple instances of Dynatrace Managed set up, with different URLs and access tokens. This might be +In this example, you have multiple Dynatrace Managed environments set up, with different URLs and access tokens. This might be a development/test/production setup, or different applications entirely. It is recommended to refer to your environments by the same alias you used in the `DT_ENVIRONMENT_CONFIGS` `alias` field to prevent confusion. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b09723e..3d8eb32 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -28,11 +28,11 @@ API responses, and the response text returned by the MCP tool. Also check the lo ### Integration Tests -Integration tests run against a real Dynatrace Managed environment, making real API calls. +Integration tests run against a real Dynatrace Managed environment with alias set to `testAlias`, making real API calls. It is assumed that this environment has sufficient data to be able to sensibly test the API -calls and processing of responses. +calls and processing of responses. A second environment with alias `invalidApiToken` needs to be set up, with working credentials but a wrong apiToken, used to check error responses. -Configure the `.env` file with the URL and [an API token with the required scopes](../README.md#api-scopes-for-managed-deployment). See `.env.template` as a starting point. +Configure both environments in the `.env` file with the URL and [an API token with the required scopes](../README.md#api-scopes-for-managed-deployment). See `.env.template` as a starting point. See [main README](../README.md#environment-variables) for description of Environment Variables. Some useful example testing commands: diff --git a/server.json b/server.json index ab91f76..6e2b04d 100644 --- a/server.json +++ b/server.json @@ -19,26 +19,8 @@ }, "environmentVariables": [ { - "name": "DT_MANAGED_ENVIRONMENT", - "description": "id of the managed environment, used for constructing URL for API and dashboards (e.g. of the form `01234567-89ab-cdef-abcd-ef0123456789`)", - "isRequired": true, - "format": "string" - }, - { - "name": "DT_API_ENDPOINT_URL", - "description": "base url for Dynatrace Managed API, to which the environment id will be appended (e.g. `https://abc123.dynatrace-managed.com:9999`)", - "isRequired": true, - "format": "string" - }, - { - "name": "DT_DYNATRACE_URL", - "description": "base url for Dynatrace Managed dashboard, to which the environment id will be appended (e.g. `https://dmz123.dynatrace-managed.com`)", - "isRequired": false, - "format": "string" - }, - { - "name": "DT_MANAGED_API_TOKEN", - "description": "API Token with required scopes (e.g. 'dt0s16.SAMPLE.abcd1234')", + "name": "DT_ENVIRONMENT_CONFIGS", + "description": "An escaped JSON array that defines the Dynatrace Managed environment(s) to connect to. See README file for contents of this.", "isRequired": true, "format": "string" }, @@ -48,12 +30,6 @@ "isRequired": false, "format": "string" }, - { - "name": "HTTP_PROXY", - "description": "HTTP Proxy to use", - "isRequired": false, - "format": "string" - }, { "name": "DT_MCP_DISABLE_TELEMETRY", "description": "Disable telemetry", From a89ad9093c7d24bed0774c33b374be128bcb5e43 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Fri, 23 Jan 2026 12:15:12 +0000 Subject: [PATCH 12/17] - MCP Responses are more verbose, explicitly saying "environment" to make clear where the data is coming from. - MINIMUM_VERSION added to ManagedAuthClientManager. Added to instructions. - Removed old references to non-existing env vars. - Small changes to LLM instructions. - README.md and DEVELOPMENT.md changes for easier reading. --- README.md | 40 ++++++++++- docs/DEVELOPMENT.md | 18 +---- .../__tests__/managed-auth-client.test.ts | 2 + src/authentication/managed-auth-client.ts | 18 +++-- src/capabilities/entities-api.ts | 7 +- src/capabilities/events-api.ts | 7 +- src/capabilities/logs-api.ts | 2 +- src/capabilities/metrics-api.ts | 7 +- src/capabilities/problems-api.ts | 7 +- src/capabilities/security-api.ts | 2 +- src/capabilities/slo-api.ts | 8 ++- src/index.ts | 72 ++++++++++++++----- src/utils/__tests__/environment.test.ts | 2 +- .../managed-auth.integration.test.ts | 1 + tests/integration/proxy.integration.test.ts | 1 + 15 files changed, 143 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index ac18ff5..dd0f930 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,31 @@ where: This needs to be escaped and set as the `DT_ENVIRONMENT_CONFIGS` environment variable, e.g.: ```shell -DT_ENVIRONMENT_CONFIGS="[{\"dynatraceUrl\":\"https://my-dashboard-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api-endpoint.com/\",\"environmentId\":\"my-env-id-1\",\"alias\":\"alias-env\",\"apiToken\":\"my-api-token\"},{\"dynatraceUrl\":\"https://my-dashboard2-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api2-endpoint.com/\",\"environmentId\":\"my-env-id-2\",\"alias\":\"alias-env-2\",\"apiToken\":\"my-api-token-2\"}]" +DT_ENVIRONMENT_CONFIGS='[ + { + "dynatraceUrl": "https://my-dashboard-endpoint.com/", + "apiEndpointUrl": "https://my-api-endpoint.com/", + "environmentId": "my-env-id-1", + "alias": "alias-env", + "apiToken": "my-api-token", + "httpProxyUrl": "http://proxy.company.com:8080" + }, + { + "dynatraceUrl": "https://my-dashboard2-endpoint.com/", + "apiEndpointUrl": "https://my-api2-endpoint.com/", + "environmentId": "my-env-id-2", + "alias": "alias-env-2", + "apiToken": "my-api-token-2", + "httpProxyUrl": "http://proxy.company.com:8080" + } +]' ``` If you are using multiple environments, we strongly recommend you set up rules (see [Rules](#rule-file)) to steer your LLM to better understand each of your environments. +Changes to environment configuration will need from an MCP server restart/reload. Changes won't be picked up until a fresh reload. + Once configured, you can start using [example prompts](#Example-Prompts) like `Get all details of the Dynatrace entity 'my-service'` or `What problems has Dynatrace identified? Give details of the first problem.`. @@ -354,7 +373,24 @@ The MCP server honors system proxy settings for corporate environments for each Example configuration with proxy: ```bash -export DT_ENVIRONMENT_CONFIGS="[{\"dynatraceUrl\":\"https://my-dashboard-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api-endpoint.com/\",\"environmentId\":\"my-env-id-1\",\"alias\":\"alias-env\",\"apiToken\":\"my-api-token\",\"httpProxyUrl\":\"http://proxy.company.com:8080\"},{\"dynatraceUrl\":\"https://my-dashboard2-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api2-endpoint.com/\",\"environmentId\":\"my-env-id-2\",\"alias\":\"alias-env-2\",\"apiToken\":\"my-api-token-2\",\"httpProxyUrl\":\"http://proxy.company.com:8080\"}]" +export DT_ENVIRONMENT_CONFIGS='[ + { + "dynatraceUrl": "https://my-dashboard-endpoint.com/", + "apiEndpointUrl": "https://my-api-endpoint.com/", + "environmentId": "my-env-id-1", + "alias": "alias-env", + "apiToken": "my-api-token", + "httpProxyUrl": "http://proxy.company.com:8080" + }, + { + "dynatraceUrl": "https://my-dashboard2-endpoint.com/", + "apiEndpointUrl": "https://my-api2-endpoint.com/", + "environmentId": "my-env-id-2", + "alias": "alias-env-2", + "apiToken": "my-api-token-2", + "httpProxyUrl": "http://proxy.company.com:8080" + } +]' ``` Note that the `httpProxyUrl`/`httpsProxyUrl` variables exist on a per-environment basis, so you can configure one environment to use a proxy diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3d8eb32..c442b87 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -90,10 +90,7 @@ Configure your preferred AI Assistant with an mcp.json file like that below: "command": "npx", "args": ["--watch", "/path/to/repos/dynatrace-oss/dynatrace-manage-mcp/dist/index.js"], "env": { - "DT_MANAGED_ENVIRONMENT": "01234567-89ab-cdef-abcd-ef0123456789", - "DT_API_ENDPOINT_URL": "https://abc123.dynatrace-managed.example.com:9999", - "DT_DYNATRACE_URL": "https://dmz123.dynatrace-managed.example.com", - "DT_MANAGED_API_TOKEN": "dt0s16.SAMPLE.abcd1234", + "DT_ENVIRONMENT_CONFIGS": "[{\"dynatraceUrl\":\"https://my-dashboard-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api-endpoint.com/\",\"environmentId\":\"my-env-id-1\",\"alias\":\"alias-env\",\"apiToken\":\"my-api-token\"},{\"dynatraceUrl\":\"https://my-dashboard2-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api2-endpoint.com/\",\"environmentId\":\"my-env-id-2\",\"alias\":\"alias-env-2\",\"apiToken\":\"my-api-token-2\"}]", "DT_MCP_DISABLE_TELEMETRY": "true", "LOG_LEVEL": "debug" } @@ -143,13 +140,7 @@ You can then use that locally, for example with the following in your `mcp.json` "--rm", "-i", "-e", - "DT_MANAGED_ENVIRONMENT", - "-e", - "DT_API_ENDPOINT_URL", - "-e", - "DT_DYNATRACE_URL", - "-e", - "DT_MANAGED_API_TOKEN", + "DT_ENVIRONMENT_CONFIGS", "-e", "DT_MCP_DISABLE_TELEMETRY", "-e", @@ -157,10 +148,7 @@ You can then use that locally, for example with the following in your `mcp.json` "mcp/dynatrace-managed-mcp-server:snapshot" ], "env": { - "DT_MANAGED_ENVIRONMENT": "01234567-89ab-cdef-abcd-ef0123456789", - "DT_API_ENDPOINT_URL": "https://abc123.dynatrace-managed.example.com:9999", - "DT_DYNATRACE_URL": "https://dmz123.dynatrace-managed.example.com", - "DT_MANAGED_API_TOKEN": "dt0s16.SAMPLE.abcd1234" + "DT_ENVIRONMENT_CONFIGS": "[{\"dynatraceUrl\":\"https://my-dashboard-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api-endpoint.com/\",\"environmentId\":\"my-env-id-1\",\"alias\":\"alias-env\",\"apiToken\":\"my-api-token\"},{\"dynatraceUrl\":\"https://my-dashboard2-endpoint.com/\",\"apiEndpointUrl\":\"https://my-api2-endpoint.com/\",\"environmentId\":\"my-env-id-2\",\"alias\":\"alias-env-2\",\"apiToken\":\"my-api-token-2\"}]", "DT_MCP_DISABLE_TELEMETRY": "true", "LOG_LEVEL": "debug" }, diff --git a/src/authentication/__tests__/managed-auth-client.test.ts b/src/authentication/__tests__/managed-auth-client.test.ts index 95c13a4..5d86a51 100644 --- a/src/authentication/__tests__/managed-auth-client.test.ts +++ b/src/authentication/__tests__/managed-auth-client.test.ts @@ -18,6 +18,7 @@ describe('ManagedAuthClient', () => { dashboardBaseUrl: 'https://managed-dashboard.test.com', apiToken: 'test-token', alias: 'testAlias', + minimum_version: '1.328.0', }); }); @@ -52,6 +53,7 @@ describe('ManagedAuthClient', () => { dashboardBaseUrl: 'https://managed-dashboard.test.com', apiToken: 'test-token', alias: 'testAlias', + minimum_version: '1.328.0', }); const result = await client.validateConnection(); diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index e864b16..96556ea 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -25,12 +25,14 @@ export interface ManagedAuthClientParams { httpProxy?: string; httpsProxy?: string; isValid?: boolean; + minimum_version: string; } export class ManagedAuthClientManager { public readonly rawClients: ManagedAuthClient[]; public clients: ManagedAuthClient[]; public validAliases: string[] = []; + public readonly MINIMUM_VERSION = '1.328.0'; constructor(managedEnvironments: ManagedEnvironmentConfig[]) { this.rawClients = []; @@ -46,6 +48,7 @@ export class ManagedAuthClientManager { alias: managedEnvironment.alias, httpProxy: managedEnvironment.httpProxy, httpsProxy: managedEnvironment.httpsProxy, + minimum_version: this.MINIMUM_VERSION, }); this.rawClients.push(newClient); } @@ -83,7 +86,7 @@ export class ManagedAuthClient { public validationError: string; private proxy: AxiosProxyConfig | undefined; private httpClient: AxiosInstance; - public readonly MINIMUM_VERSION = '1.328.0'; + public MINIMUM_VERSION: string; constructor(params: ManagedAuthClientParams) { this.apiBaseUrl = params.apiBaseUrl; @@ -91,6 +94,7 @@ export class ManagedAuthClient { this.alias = params.alias; this.proxy = setAxiosProxy(params.httpProxy, params.httpsProxy); this.isValid = params.isValid ? params.isValid : false; + this.MINIMUM_VERSION = params.minimum_version; this.validationError = ''; this.httpClient = axios.create({ @@ -176,7 +180,7 @@ export class ManagedAuthClient { async isConfigured() { // Test connection to Managed cluster - logger.info(`Testing connection to Dynatrace Managed cluster "${this.alias}": ${this.apiBaseUrl}...`); + logger.info(`Testing connection to Dynatrace Managed environment "${this.alias}": ${this.apiBaseUrl}...`); try { const isConnected = await this.validateConnection(); if (!isConnected) { @@ -191,7 +195,7 @@ export class ManagedAuthClient { const isValidVersion = this.validateMinimumVersion(clusterVersion); if (!isValidVersion) { - const invalidVersionMessage = `Cluster "${this.alias}" version ${clusterVersion.version} may not support all features. Minimum recommended version is ${this.MINIMUM_VERSION}`; + const invalidVersionMessage = `Environment "${this.alias}" version ${clusterVersion.version} may not support all features. Minimum recommended version is ${this.MINIMUM_VERSION}`; logger.info(invalidVersionMessage); this.validationError = invalidVersionMessage; return false; @@ -199,13 +203,13 @@ export class ManagedAuthClient { return true; } catch (error: any) { logger.error( - `[CONNECTION ERROR] Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`, + `[CONNECTION ERROR] Failed to connect to Managed environment "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`, ); logger.error('Please verify:'); - logger.error('1. DT_MANAGED_ENVIRONMENTS is correct'); + logger.error('1. DT_ENVIRONMENT_CONFIGS is correct'); logger.error(`2. API Token has required scopes: ${MANAGED_API_SCOPES.join(', ')}`); - logger.error('3. Network connectivity to the Managed cluster'); - this.validationError = `Failed to connect to Managed cluster "${this.alias}": ${this.apiBaseUrl}: ${error.message}. Please verify connection details are correct.`; + logger.error('3. Network connectivity to the Managed environment'); + this.validationError = `Failed to connect to Managed environment "${this.alias}": ${this.apiBaseUrl}: ${error.message}. Please verify connection details are correct.`; return false; } } diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 99150fd..1fca5b3 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -144,7 +144,12 @@ export class EntitiesApiClient { let isLimited = totalCount != 0 - 1 && totalCount > numEntities; result += - 'Listing ' + numEntities + (totalCount == -1 ? '' : ' of ' + totalCount) + ' entities from ' + alias + '.\n'; + 'Listing ' + + numEntities + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' entities from environment ' + + alias + + '.\n'; if (isLimited) { result += 'Not showing all matching entities. Consider using more specific filters (entitySelector) to get complete results.\n'; diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index b2a3096..ba27e6b 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -74,7 +74,12 @@ export class EventsApiClient { let isLimited = totalCount != 0 - 1 && totalCount > numEvents; result += - 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events from ' + alias + '.\n\n'; + 'Listing ' + + numEvents + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' events from environment ' + + alias + + '.\n\n'; if (isLimited) { result += diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 6624124..7682cd8 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -56,7 +56,7 @@ export class LogsApiClient { totalNumLogs = totalNumLogs + numLogs; let isLimited = data.nextSliceKey != undefined; - result += 'Listing ' + numLogs + ' log records from ' + alias + '.\n\n'; + result += 'Listing ' + numLogs + ' log records from environment ' + alias + '.\n\n'; if (isLimited) { result += 'Results likely restricted due to maximum response size, consider using a more specific filter.'; diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index 12ca3cc..54cec4a 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -124,7 +124,12 @@ export class MetricsApiClient { let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; result += - 'Listing ' + numMetrics + (totalCount == -1 ? '' : ' of ' + totalCount) + ' metrics from ' + alias + '.\n\n'; + 'Listing ' + + numMetrics + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' metrics from environment ' + + alias + + '.\n\n'; if (isLimited) { result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index a4b8f47..7e7b0d6 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -115,7 +115,12 @@ export class ProblemsApiClient { let isLimited = totalCount != 0 - 1 && totalCount > numProblems; result += - 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems from ' + alias + '.\n\n'; + 'Listing ' + + numProblems + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' problems from environment ' + + alias + + '.\n\n'; if (isLimited) { result += diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 8bbeb80..47f080a 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -108,7 +108,7 @@ export class SecurityApiClient { 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + - ' security vulnerabilities from ' + + ' security vulnerabilities from environment ' + alias + ' in the following json.\n'; diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index ab01666..f4c7671 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -119,7 +119,13 @@ export class SloApiClient { totalNumSlo += numSLOs; let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; - result += 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs. from ' + alias + ':\n'; + result += + 'Listing ' + + numSLOs + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' SLOs from environment ' + + alias + + ':\n'; if (isLimited) { result += diff --git a/src/index.ts b/src/index.ts index 9650c41..4b9cde3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,7 +111,7 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t **Key Context:** - This server accesses self-hosted Dynatrace Managed clusters (not the SaaS environment) - Designed for historical data analysis before migration to SaaS -- Minimum supported cluster version: ... +- Minimum supported cluster version: ${authClientManager.MINIMUM_VERSION} - Two different ways that Dynatrace Managed may be being used: 1. Dynatrace Managed may be the primary Observability system, containing all live data. 2. Or alternatively the customer may have migrated to Dynatrace SaaS, leavng historical observability data in Dynatrace Managed from before the migration, in which case this MCP Server would only be used to access historical data. @@ -129,7 +129,7 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - Use specific time ranges (1-2 hours) rather than large historical queries for better performance - Leverage entity selectors to filter data at the source - they are fundamental to getting good results - Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) -- Start with get_environments_info to understand connection errors, cluster capabilities and data range. **CRITICAL: Breakdown connection issues to the user before any other request**. +- Start with calling the tool get_environments_info. This will list all the available environments. It will include details of connection errors and configuration errors. **CRITICAL: report the connection issues to the user before any other requests**. - On every request, an "environment_alias" must be passed. - If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" - **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText @@ -379,7 +379,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -433,7 +435,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -464,7 +468,9 @@ Never run queries that could return very large amounts of data, or that could be metricId: z.string().describe('The metric ID to get details for'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -494,7 +500,9 @@ Never run queries that could return very large amounts of data, or that could be sort: z.string().optional().describe('Sort order for logs. Use "-timestamp" for most recent first.'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -548,7 +556,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -579,7 +589,9 @@ Never run queries that could return very large amounts of data, or that could be eventId: z.string().describe('The event ID to get details for'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -599,7 +611,9 @@ Never run queries that could return very large amounts of data, or that could be { environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -620,7 +634,9 @@ Never run queries that could return very large amounts of data, or that could be type: z.string().describe('Name of the entity type, such as SERVICE, APPLICATION, HOST, etc'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -674,7 +690,9 @@ Never run queries that could return very large amounts of data, or that could be .describe('Sort order for entities. Use "name" for ascending, "-name" for descending by display name.'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -705,7 +723,9 @@ Never run queries that could return very large amounts of data, or that could be entityId: z.string().describe('The entity ID to get details for'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -726,7 +746,9 @@ Never run queries that could return very large amounts of data, or that could be entityId: z.string().describe('The entity ID to get relationships for'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -776,7 +798,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -811,7 +835,9 @@ Never run queries that could return very large amounts of data, or that could be .describe('The internal problem ID (UUID format) from list_problems - NOT the displayId (P-XXXXX)'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -853,7 +879,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -888,7 +916,9 @@ Never run queries that could return very large amounts of data, or that could be .describe('The security problem ID (UUID format) from list_security_problems - NOT the displayId (S-XXXXX)'), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -950,7 +980,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), @@ -1008,7 +1040,9 @@ Never run queries that could return very large amounts of data, or that could be ), environment_alias: z .string() - .describe('Specifically hits one environment. Blank for all.') + .describe( + 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), }), diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts index 24dfb51..24ca7a4 100644 --- a/src/utils/__tests__/environment.test.ts +++ b/src/utils/__tests__/environment.test.ts @@ -143,7 +143,7 @@ describe('getManagedEnvironmentConfig', () => { ]); }); - it('should throw error when DT_MANAGED_ENVIRONMENTS is missing', () => { + it('should throw error when DT_ENVIRONMENT_CONFIGS is missing', () => { process.env = {}; expect(() => getManagedEnvironmentConfigs()).toThrow('DT_ENVIRONMENT_CONFIGS is required'); }); diff --git a/tests/integration/managed-auth.integration.test.ts b/tests/integration/managed-auth.integration.test.ts index d5c8fe9..a0f2543 100644 --- a/tests/integration/managed-auth.integration.test.ts +++ b/tests/integration/managed-auth.integration.test.ts @@ -24,6 +24,7 @@ if (!process.env.DT_ENVIRONMENT_CONFIGS) { dashboardBaseUrl: valid_client.dashboardUrl, apiToken: valid_client.apiToken, alias: valid_client.alias, + minimum_version: '1.328.0', }); }); diff --git a/tests/integration/proxy.integration.test.ts b/tests/integration/proxy.integration.test.ts index 1f7e71b..3c5c825 100644 --- a/tests/integration/proxy.integration.test.ts +++ b/tests/integration/proxy.integration.test.ts @@ -55,6 +55,7 @@ describe('ProxyConfig', () => { apiToken: 'my-example-token', alias: 'alias', httpsProxy: proxyUrl, + minimum_version: '1.328.0', }); const response = await client.makeRequest('/anything/mypath'); From a9180dcb4360ff55ef4bb9ad8559b3e80d1213ed Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Fri, 23 Jan 2026 14:46:04 +0000 Subject: [PATCH 13/17] - Added back Next Step's URLs when possible (querying 1 environment) - Rephrased instructions so LLMs don't skip call to get_environments_info. - Added instructions for the LLM to return URLs from responses to the user - Added auxiliary method to ManagedAuthClientManager - Updated tests --- src/authentication/managed-auth-client.ts | 9 ++++++ .../__tests__/entities-api.test.ts | 3 ++ src/capabilities/__tests__/events-api.test.ts | 3 ++ src/capabilities/__tests__/logs-api.test.ts | 3 ++ .../__tests__/metrics-api.test.ts | 3 ++ .../__tests__/problems-api.test.ts | 3 ++ .../__tests__/security-api.test.ts | 3 ++ src/capabilities/__tests__/slo-api.test.ts | 3 ++ src/capabilities/entities-api.ts | 32 +++++++++++++++---- src/capabilities/events-api.ts | 16 +++++++--- src/capabilities/logs-api.ts | 8 +++-- src/capabilities/metrics-api.ts | 23 +++++++++---- src/capabilities/problems-api.ts | 20 +++++++++--- src/capabilities/security-api.ts | 23 ++++++++++--- src/capabilities/slo-api.ts | 8 +++-- src/index.ts | 5 +-- 16 files changed, 134 insertions(+), 31 deletions(-) diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index 96556ea..cc81611 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -76,6 +76,15 @@ export class ManagedAuthClientManager { } } } + + getBaseUrl(alias: string): string { + for (let client of this.clients) { + if (client.alias === alias) { + return client.dashboardBaseUrl; + } + } + return ''; + } } export class ManagedAuthClient { diff --git a/src/capabilities/__tests__/entities-api.test.ts b/src/capabilities/__tests__/entities-api.test.ts index b2a12ba..91568e6 100644 --- a/src/capabilities/__tests__/entities-api.test.ts +++ b/src/capabilities/__tests__/entities-api.test.ts @@ -11,6 +11,9 @@ describe('EntitiesApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new EntitiesApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/events-api.test.ts b/src/capabilities/__tests__/events-api.test.ts index 78ba6cc..68270ea 100644 --- a/src/capabilities/__tests__/events-api.test.ts +++ b/src/capabilities/__tests__/events-api.test.ts @@ -11,6 +11,9 @@ describe('EventsApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new EventsApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/logs-api.test.ts b/src/capabilities/__tests__/logs-api.test.ts index 1da2a38..8640fa2 100644 --- a/src/capabilities/__tests__/logs-api.test.ts +++ b/src/capabilities/__tests__/logs-api.test.ts @@ -11,6 +11,9 @@ describe('LogsApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new LogsApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/metrics-api.test.ts b/src/capabilities/__tests__/metrics-api.test.ts index 471bdc7..d8c460f 100644 --- a/src/capabilities/__tests__/metrics-api.test.ts +++ b/src/capabilities/__tests__/metrics-api.test.ts @@ -11,6 +11,9 @@ describe('MetricsApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new MetricsApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/problems-api.test.ts b/src/capabilities/__tests__/problems-api.test.ts index bcc547a..72e6187 100644 --- a/src/capabilities/__tests__/problems-api.test.ts +++ b/src/capabilities/__tests__/problems-api.test.ts @@ -11,6 +11,9 @@ describe('ProblemsApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new ProblemsApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/security-api.test.ts b/src/capabilities/__tests__/security-api.test.ts index 867b537..1db905b 100644 --- a/src/capabilities/__tests__/security-api.test.ts +++ b/src/capabilities/__tests__/security-api.test.ts @@ -11,6 +11,9 @@ describe('SecurityApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new SecurityApiClient(mockAuthManager); }); diff --git a/src/capabilities/__tests__/slo-api.test.ts b/src/capabilities/__tests__/slo-api.test.ts index 2cb2e3e..893337c 100644 --- a/src/capabilities/__tests__/slo-api.test.ts +++ b/src/capabilities/__tests__/slo-api.test.ts @@ -11,6 +11,9 @@ describe('SloApiClient', () => { beforeEach(() => { mockAuthManager = { makeRequests: jest.fn(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; client = new SloApiClient(mockAuthManager); }); diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 1fca5b3..63310a0 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -137,7 +137,9 @@ export class EntitiesApiClient { let result = ''; let totalNumEntities = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numEntities = data.entities?.length || 0; totalNumEntities += numEntities; @@ -193,6 +195,7 @@ export class EntitiesApiClient { result += '\n'; }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; result += '\n' + @@ -205,7 +208,8 @@ export class EntitiesApiClient { 'Use the entityId (UUID) for detailed analysis\n' + '* If this has returned the entities that the user wanted, consider using the same entitySelector in subsequent calls such as to list_problems tool if that has not already been done.\n' + 'Use the entityId (UUID) for detailed analysis\n' + - '* Suggest to the user that they view the entities in the Dynatrace UI.' + + '* Suggest to the user that they view the entities in the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl}/` : '.') + '\n'; return result; @@ -214,6 +218,7 @@ export class EntitiesApiClient { formatEntityTypeList(responses: Map): string { let result = ''; let totalNumTypes = 0; + let aliases: string[] = []; const commonTypes = [ 'SERVICE', 'PROCESS_GROUP', @@ -226,6 +231,7 @@ export class EntitiesApiClient { ]; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numTypes = data.types?.length || 0; totalNumTypes += numTypes; @@ -262,6 +268,7 @@ export class EntitiesApiClient { }); result += '\n' + conciseList; } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; // Produce a simple strong list of all the types from the json (excluding all details). // Also call out some common types (that are available). @@ -270,7 +277,9 @@ export class EntitiesApiClient { 'Next Steps:\n' + '* To get details of a particular entity type, use the get_entity_type_details tool, passing in the type name\n' + '* For subsequent user queries, consider using the entity type in the the entitySelector parameter like "type(HOST)" or "type(SERVICE)".\n' + - '* Suggest to the user that they look in the Dynatrace UI \n'; + '* Suggest to the user that they look in the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl}/` : '.') + + '\n'; return result; } @@ -289,20 +298,27 @@ export class EntitiesApiClient { formatEntityDetails(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); result += 'Entity details from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; result += 'Next Steps:\n' + '* Use list_problems or list_events tools with the same entitySelector to check for relates issues and events.\n' + - '* Suggest to the user that they view the entity in the Dynatrace UI using the entityId in the URL' + - '\n'; + '* Suggest to the user that they view the entity in the Dynatrace UI' + + (baseUrl + ? ` at ${baseUrl}/ui/entity/, using the entityId in the URL` + : ' using the entityId in the URL'); return result; } formatEntityRelationships(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); const from = data.fromRelationships; const to = data.toRelationships; const numFrom = this.countRelationships(from); @@ -324,12 +340,16 @@ export class EntitiesApiClient { } } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += 'Next Steps:\n' + '* Use get_entity_details tool to get more details of this entity, or of entities that it has a relationship to/from.\n' + '* Use list_problems or list_events tools with the same entitySelector by entityId to check for related issues and events.\n' + - '* Suggest to the user that they view the entity in the Dynatrace UI using the entityId in the URL' + - '\n'; + '* Suggest to the user that they view the entity in the Dynatrace UI' + + (baseUrl + ? ` at ${baseUrl}/ui/entity/, using the entityId in the URL` + : ' using the entityId in the URL'); return result; } diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index ba27e6b..a5566a6 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -66,8 +66,10 @@ export class EventsApiClient { let result = ''; let totalNumEvents = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numEvents = data.events?.length || 0; totalNumEvents += numEvents; @@ -126,6 +128,7 @@ export class EventsApiClient { result += '\n'; }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; result += '\n' + 'Next Steps:\n' + @@ -138,7 +141,9 @@ export class EventsApiClient { (totalNumEvents > 0 ? '* If the user is interested in a specific event, use the get_event_details tool. Use the event id for this.\n ' : '') + - '* Suggest to the user that they use the Dynatrace UI to view events by navigating to the relevant entity\n' + + '* Suggest to the user that they use the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl} ` : ' ') + + +'to view events by navigating to the relevant entity\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; return result; @@ -146,14 +151,17 @@ export class EventsApiClient { formatDetails(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); result += 'Event details from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; result += 'Next Steps:\n' + - '* Suggest to the user that they explore this further in the Dynatrace UI.' + - '\n' + - '* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; + '* Suggest to the user that they explore this further in the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl} ` : '.') + + '\n* Use list_problems to see what problems Dynatrace knows of, if not already done so.\n'; return result; } } diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 7682cd8..8f4272d 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -51,7 +51,9 @@ export class LogsApiClient { let result = ''; let totalNumLogs = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let numLogs = data.results?.length || 0; totalNumLogs = totalNumLogs + numLogs; let isLimited = data.nextSliceKey != undefined; @@ -95,6 +97,8 @@ export class LogsApiClient { }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result = result + '\n' + @@ -104,8 +108,8 @@ export class LogsApiClient { ? '* Use more restrictive filters, such as a narrower time range or more specific search terms\n' : '') + (totalNumLogs > 1 ? '* Use sort (e.g. with "-timestamp" for newest logs first).\n' : '') + - '* Suggest to the user that they use the Dynatrace UI to view metric data at ' + - //`${this.authManager.dashboardBaseUrl}/ui/log-monitoring` + + '* Suggest to the user that they use the Dynatrace UI to view metric data' + + (baseUrl ? ` at ${baseUrl}/ui/log-monitoring.` : '.') + '\n' + '* Use list_problems to see what problems Dynatrace knows of, if not already done so\n'; diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index 54cec4a..83149eb 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -116,8 +116,9 @@ export class MetricsApiClient { let result = ''; let totalNumMetrics = 0; let anyLimited = false; - + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numMetrics = data.metrics?.length || 0; totalNumMetrics += numMetrics; @@ -159,6 +160,8 @@ export class MetricsApiClient { }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += '\n' + 'Next Steps:\n' + @@ -169,19 +172,22 @@ export class MetricsApiClient { ? '* To filter the list of metrics, use list_available_metrics tool with sorting and with specific filters (e.g. entitySelector and searchText).\n' : '') + '* Use get_metric_details tool for detailed information of a particular metric.\n' + - '* Suggest to the user that they use the Dynatrace UI to:\n'; - //` * Browse the list of metrics at ${this.authClient.dashboardBaseUrl}/ui/metrics' + '\n` + - //` * View metric data at ${this.authClient.dashboardBaseUrl}/ui/data-explorer' + '\n`; + '* Suggest to the user that they use the Dynatrace UI' + + (baseUrl + ? ` to: \n * Browse the list of metrics at ${baseUrl}/ui/metrics` + + `\n * View metric data at ${baseUrl}/ui/data-explorer` + : '.'); return result; } formatMetricDetails(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); result += 'Details of metric from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; - //`${this.authClient.dashboardBaseUrl}/ui/data-explorer`; } result += 'Next Steps:\n* Suggest to the user that they use the Dynatrace UI to view metric data'; @@ -191,7 +197,9 @@ export class MetricsApiClient { formatMetricData(responses: Map): string { let result = ''; let allEmpty = true; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let resolution = data.resolution; let isNonEmpty = data.result && data.result.length > 0 && data.result[0].data && data.result[0].data.length > 0; @@ -243,13 +251,16 @@ export class MetricsApiClient { }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += '\n' + 'Next Steps:\n' + (allEmpty ? '* Verify that the filters were correct, and search again with different filters\n' : '* Use query_metrics_data with more specific filters, such as a narrower time range with to and from, and an entitySelector\n') + - '* Suggest to the user that they use the Dynatrace UI to view metric data'; + '* Suggest to the user that they use the Dynatrace UI to view metric data' + + (baseUrl ? ` at ${baseUrl}/ui/data-explorer` : '.'); return result; } diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index 7e7b0d6..c859ec4 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -108,7 +108,9 @@ export class ProblemsApiClient { let result = ''; let totalNumProblems = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numProblems = data.problems?.length || 0; totalNumProblems += numProblems; @@ -154,9 +156,13 @@ export class ProblemsApiClient { : '') + (anyLimited ? '* Use more restrictive filters, such as a more specific entitySelector.\n' : '') + (totalNumProblems > 1 ? '* Use sort (e.g. with "+status" for open problems first).\n' : '') + - '* Suggest to the user that they view the problems in the Dynatrace UI.' + - '\n' + - '* If the user is interested in a specific problem, use the get_problem_details tool. ' + + '* Suggest to the user that they view the problems in the Dynatrace UI'; + + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + + result += + (baseUrl ? ` at ${baseUrl}/ui/problems.` : '.') + + '\n* If the user is interested in a specific problem, use the get_problem_details tool. ' + 'Use the problemId (UUID) for detailed analysis.\n'; return result; @@ -164,15 +170,21 @@ export class ProblemsApiClient { formatDetails(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); result += 'Details of problem from environment ' + alias + ' in the following json:\n' + JSON.stringify(data) + '\n'; } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; result += 'Next Steps:\n' + '* If the affectedEntities is not empty, suggest to the user that they could investigate those entities further. For example with:\n' + " * list_events tool, using the affected entity's entityId in the entitySelector.\n" + - ' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n'; + ' * query_logs tool, for a narrow time range of the problem, searching for logs about that entity.\n' + + ' * Suggest to the user that they view the problem in the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl}/#problems/problemdetails;pid=, using the problemId in the URL` : '.'); + return result; } } diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 47f080a..7f4c5e7 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -98,7 +98,9 @@ export class SecurityApiClient { let result = ''; let totalNumProblems = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numProblems = data.securityProblems?.length || 0; totalNumProblems += numProblems; @@ -143,6 +145,8 @@ export class SecurityApiClient { }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += '\n' + 'Next Steps:\n' + @@ -154,17 +158,19 @@ export class SecurityApiClient { ? '* Use sort (e.g. with "-riskAssessment.riskScore" for highest risk score first).\n' : '') + '* If the user is interested in a specific vulnerability, use the get_security_problem_details tool. Use the securityProblemId for this.\n' + - '* Suggest to the user that they view the security vulnerabilties in the Dynatrace UI.' + - //`${this.authClient.dashboardBaseUrl}/ui/security/overview for an overview,` + - //`or ${this.authClient.dashboardBaseUrl}/ui/security/vulnerabilities for a list of third-party vulnerabilties` + - '\n'; + '* Suggest to the user that they view the security vulnerabilties in the Dynatrace UI' + + (baseUrl + ? ` at ${baseUrl}/ui/security/overview for an overview, or ${baseUrl}/ui/security/vulnerabilities for a list of third-party vulnerabilities` + : '.'); return result; } formatDetails(responses: Map): string { let result = ''; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); result += 'Details of security problem from environment ' + alias + @@ -172,10 +178,17 @@ export class SecurityApiClient { JSON.stringify(data) + '\n'; } + + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += 'Next Steps:\n' + '* If there are affectedEntities, suggest to the user that they could get further information about those entities with get_entity_detais tool, using the entityId.\n' + - '* Suggest to the user that they view the security vulnerability in the Dynatrace UI using the securityProblemId in the URL\n'; + '* Suggest to the user that they view the security vulnerability in the Dynatrace UI using the securityProblemId in the URL\n' + + (baseUrl + ? ` at ${baseUrl}/ui/security/vulnerabilities/, using the securityProblemId in the URL\n` + : '.'); + return result; } } diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index f4c7671..f69db29 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -113,7 +113,9 @@ export class SloApiClient { let result = ''; let totalNumSlo = 0; let anyLimited = false; + let aliases: string[] = []; for (const [alias, data] of responses) { + aliases.push(alias); let totalCount = data.totalCount || -1; let numSLOs = data.slo?.length || 0; totalNumSlo += numSLOs; @@ -163,6 +165,8 @@ export class SloApiClient { }); } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; + result += '\n' + 'Next Steps:\n' + @@ -172,8 +176,8 @@ export class SloApiClient { (anyLimited ? '* Use more restrictive filters, such as a more specific sloSelector and status.\n' : '') + (totalNumSlo > 1 ? '* Use sort (e.g. with "+name" for ascending alphabetical order).\n' : '') + '* If the user is interested in a specific SLO, use the get_slo_details tool. Use the SLO id for this.\n' + - '* Suggest to the user that they view the SLOs in the Dynatrace UI.'; - + '* Suggest to the user that they view the SLOs in the Dynatrace UI' + + (baseUrl ? ` at ${baseUrl}/ui/slo` : '.'); return result; } diff --git a/src/index.ts b/src/index.ts index 4b9cde3..b05dc72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,13 +129,14 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - Use specific time ranges (1-2 hours) rather than large historical queries for better performance - Leverage entity selectors to filter data at the source - they are fundamental to getting good results - Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) -- Start with calling the tool get_environments_info. This will list all the available environments. It will include details of connection errors and configuration errors. **CRITICAL: report the connection issues to the user before any other requests**. +- Start with calling the tool get_environments_info. It will include details of connection errors and configuration errors. +- **CRITICAL: report connection issues to the user before any other requests**. - On every request, an "environment_alias" must be passed. - If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" - **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText - **Avoid searchText guessing** - only use searchText when user explicitly mentions keywords to search for - **discover_entities ALWAYS requires entitySelector** - never call this tool without providing an entitySelector with exactly ONE entity type like type("SERVICE") unless using an EntityId. Multiple entity types are NOT supported. - +- **Next Steps are important** All requests will come back with a footer called 'Next Steps'. Take into consideration what it says. **Time Range Parameters:** - **Relative Times**: now-1h, now-24h, now-7d, now-30d (h=hours, d=days, m=minutes, s=seconds) - **ISO Format**: 2024-01-01T10:00:00Z or 2024-01-01T10:00:00 From 8c8a06d13ff54c287ab44eb09a031448768d9030 Mon Sep 17 00:00:00 2001 From: Aled Sage Date: Fri, 23 Jan 2026 14:28:58 +0000 Subject: [PATCH 14/17] environment_aliases: make required, and support ALL_ENVIRONMENTS --- src/authentication/managed-auth-client.ts | 4 ++-- .../__tests__/entities-api.test.ts | 22 +++++++++---------- src/capabilities/__tests__/events-api.test.ts | 4 ++-- src/capabilities/__tests__/logs-api.test.ts | 10 ++++----- .../__tests__/metrics-api.test.ts | 2 +- .../__tests__/problems-api.test.ts | 12 +++++----- .../__tests__/security-api.test.ts | 16 +++++--------- src/capabilities/entities-api.ts | 14 ++++++------ src/capabilities/events-api.ts | 6 ++--- src/capabilities/logs-api.ts | 2 +- src/capabilities/metrics-api.ts | 11 ++++------ src/capabilities/problems-api.ts | 6 ++--- src/capabilities/security-api.ts | 6 ++--- src/capabilities/slo-api.ts | 4 ++-- 14 files changed, 56 insertions(+), 63 deletions(-) diff --git a/src/authentication/managed-auth-client.ts b/src/authentication/managed-auth-client.ts index cc81611..c1b4d91 100644 --- a/src/authentication/managed-auth-client.ts +++ b/src/authentication/managed-auth-client.ts @@ -54,8 +54,8 @@ export class ManagedAuthClientManager { } } - async makeRequests(endpoint: string, params?: Record, environments?: string): Promise> { - const selectedAliases = environments ? environments.split(';') : this.validAliases; + async makeRequests(endpoint: string, params: Record, environments: string): Promise> { + const selectedAliases = environments === 'ALL_ENVIRONMENTS' ? this.validAliases : environments.split(';'); let responses = new Map(); for (const client of this.clients) { if (selectedAliases.indexOf(client.alias) > -1) { diff --git a/src/capabilities/__tests__/entities-api.test.ts b/src/capabilities/__tests__/entities-api.test.ts index 91568e6..c911c7a 100644 --- a/src/capabilities/__tests__/entities-api.test.ts +++ b/src/capabilities/__tests__/entities-api.test.ts @@ -28,7 +28,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); const result = await client.getEntityDetails('SERVICE-123', 'testAlias'); - expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123', undefined, 'testAlias'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123', {}, 'testAlias'); expect(result).toEqual(mockResponse); }); }); @@ -122,7 +122,7 @@ describe('EntitiesApiClient', () => { ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listEntityTypes(); + const response = await client.listEntityTypes('ALL_ENVIRONMENTS'); const result = client.formatEntityTypeList(response); expect(result).toContain('Listing 1 of 2 entity types'); @@ -141,7 +141,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listEntityTypes(); + const response = await client.listEntityTypes('ALL_ENVIRONMENTS'); const result = client.formatEntityTypeList(response); expect(result).toContain('Listing 1 entity types for environment testAlias'); @@ -152,7 +152,7 @@ describe('EntitiesApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listEntityTypes(); + const response = await client.listEntityTypes('ALL_ENVIRONMENTS'); const result = client.formatEntityTypeList(response); expect(response).toEqual(mockResponse); @@ -172,7 +172,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listEntityTypes(); + const response = await client.listEntityTypes('ALL_ENVIRONMENTS'); const result = client.formatEntityTypeList(response); expect(result).toContain('Listing 0 entity types for environment testAlias'); }); @@ -189,7 +189,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getEntityTypeDetails('SERVICE'); + const response = await client.getEntityTypeDetails('SERVICE', 'ALL_ENVIRONMENTS'); const result = client.formatEntityTypeDetails(response); expect(result).toContain('Entity type details from environment testAlias in the following json'); @@ -201,7 +201,7 @@ describe('EntitiesApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getEntityTypeDetails('SERVICE'); + const response = await client.getEntityTypeDetails('SERVICE', 'ALL_ENVIRONMENTS'); const result = client.formatEntityTypeDetails(response); expect(result).toContain('Entity type details from environment testAlias in the following json'); @@ -217,7 +217,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); + const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }, 'ALL_ENVIRONMENTS'); const result = client.formatEntityList(response); expect(response).toEqual(mockResponse); @@ -247,7 +247,7 @@ describe('EntitiesApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); + const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }, 'ALL_ENVIRONMENTS'); const result = client.formatEntityList(response); // Should show all 60 entities, not just 20 @@ -268,7 +268,7 @@ describe('EntitiesApiClient', () => { ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); + const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }, 'ALL_ENVIRONMENTS'); const result = client.formatEntityList(response); expect(result).toContain('Listing 0 entities'); }); @@ -277,7 +277,7 @@ describe('EntitiesApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); + const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }, 'ALL_ENVIRONMENTS'); const result = client.formatEntityList(response); expect(result).toContain('Listing 0 entities'); }); diff --git a/src/capabilities/__tests__/events-api.test.ts b/src/capabilities/__tests__/events-api.test.ts index 68270ea..cd7a7f7 100644 --- a/src/capabilities/__tests__/events-api.test.ts +++ b/src/capabilities/__tests__/events-api.test.ts @@ -83,7 +83,7 @@ describe('EventsApiClient', () => { const result = await client.getEventDetails('event-123', 'testAlias'); - expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/events/event-123', undefined, 'testAlias'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/events/event-123', {}, 'testAlias'); expect(result).toEqual(mockResponse); }); }); @@ -96,7 +96,7 @@ describe('EventsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); diff --git a/src/capabilities/__tests__/logs-api.test.ts b/src/capabilities/__tests__/logs-api.test.ts index 8640fa2..cb77eda 100644 --- a/src/capabilities/__tests__/logs-api.test.ts +++ b/src/capabilities/__tests__/logs-api.test.ts @@ -87,7 +87,7 @@ describe('LogsApiClient', () => { ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -110,7 +110,7 @@ describe('LogsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -129,7 +129,7 @@ describe('LogsApiClient', () => { ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -141,7 +141,7 @@ describe('LogsApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -160,7 +160,7 @@ describe('LogsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); diff --git a/src/capabilities/__tests__/metrics-api.test.ts b/src/capabilities/__tests__/metrics-api.test.ts index d8c460f..afe5e43 100644 --- a/src/capabilities/__tests__/metrics-api.test.ts +++ b/src/capabilities/__tests__/metrics-api.test.ts @@ -160,7 +160,7 @@ describe('MetricsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listAvailableMetrics({}); + const response = await client.listAvailableMetrics({}, 'ALL_ENVIRONMENTS'); const result = client.formatMetricList(response); expect(response).toEqual(mockResponse); diff --git a/src/capabilities/__tests__/problems-api.test.ts b/src/capabilities/__tests__/problems-api.test.ts index 72e6187..3af033a 100644 --- a/src/capabilities/__tests__/problems-api.test.ts +++ b/src/capabilities/__tests__/problems-api.test.ts @@ -78,7 +78,7 @@ describe('ProblemsApiClient', () => { const result = await client.getProblemDetails('PROBLEM-123', 'testAlias'); - expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/problems/PROBLEM-123', undefined, 'testAlias'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/problems/PROBLEM-123', {}, 'testAlias'); expect(result).toEqual(mockResponse); }); }); @@ -91,7 +91,7 @@ describe('ProblemsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listProblems(undefined, 'testAlias'); + const response = await client.listProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -110,7 +110,7 @@ describe('ProblemsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listProblems(); + const response = await client.listProblems({}, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -128,7 +128,7 @@ describe('ProblemsApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listProblems(); + const response = await client.listProblems({}, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -188,7 +188,7 @@ describe('ProblemsApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getProblemDetails('845025139905093722_1763133360000V2'); + const response = await client.getProblemDetails('845025139905093722_1763133360000V2', 'ALL_ENVIRONMENTS'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); @@ -201,7 +201,7 @@ describe('ProblemsApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getProblemDetails('my-id'); + const response = await client.getProblemDetails('my-id', 'ALL_ENVIRONMENTS'); const result = client.formatDetails(response); expect(response).toEqual(mockResponse); diff --git a/src/capabilities/__tests__/security-api.test.ts b/src/capabilities/__tests__/security-api.test.ts index 1db905b..5765ce7 100644 --- a/src/capabilities/__tests__/security-api.test.ts +++ b/src/capabilities/__tests__/security-api.test.ts @@ -27,7 +27,7 @@ describe('SecurityApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.listSecurityProblems(undefined, 'testAlias'); + const result = await client.listSecurityProblems({}, 'testAlias'); expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( '/api/v2/securityProblems', @@ -77,7 +77,7 @@ describe('SecurityApiClient', () => { }); try { - await client.listSecurityProblems(undefined, 'testAlias'); + await client.listSecurityProblems({}, 'testAlias'); fail('Should have propagated exception'); } catch (error: any) { console.log(error); @@ -93,11 +93,7 @@ describe('SecurityApiClient', () => { const result = await client.getSecurityProblemDetails('SP-123', 'testAlias'); - expect(mockAuthManager.makeRequests).toHaveBeenCalledWith( - '/api/v2/securityProblems/SP-123', - undefined, - 'testAlias', - ); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/securityProblems/SP-123', {}, 'testAlias'); expect(result).toEqual(mockResponse); }); @@ -126,7 +122,7 @@ describe('SecurityApiClient', () => { ]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSecurityProblems(undefined, 'testAlias'); + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -171,7 +167,7 @@ describe('SecurityApiClient', () => { mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSecurityProblems(undefined, 'testAlias'); + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -186,7 +182,7 @@ describe('SecurityApiClient', () => { const mockResponse = new Map([['testAlias', {}]]); mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSecurityProblems(undefined, 'testAlias'); + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); diff --git a/src/capabilities/entities-api.ts b/src/capabilities/entities-api.ts index 63310a0..c2d7524 100644 --- a/src/capabilities/entities-api.ts +++ b/src/capabilities/entities-api.ts @@ -67,7 +67,7 @@ export class EntitiesApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listEntityTypes(environment_aliases?: string): Promise> { + async listEntityTypes(environment_aliases: string): Promise> { // Deliberately large page size; will format this concisely rather than returning all json in tool response. // Want to get all of them (with reason), otherwise trying to pull out common types won't work. const params: Record = { @@ -78,20 +78,20 @@ export class EntitiesApiClient { return responses; } - async getEntityTypeDetails(entityType: string, environment_aliases?: string): Promise> { + async getEntityTypeDetails(entityType: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/entityTypes/${encodeURIComponent(entityType)}`, - undefined, + {}, environment_aliases, ); logger.debug(`getEntityTypeDetails response, entityType=${entityType}`, { data: responses }); return responses; } - async getEntityDetails(entityId: string, environment_aliases?: string): Promise> { + async getEntityDetails(entityId: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/entities/${encodeURIComponent(entityId)}`, - undefined, + {}, environment_aliases, ); logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: responses }); @@ -100,7 +100,7 @@ export class EntitiesApiClient { async getEntityRelationships( entityId: string, - environment_aliases?: string, + environment_aliases: string, ): Promise> { const entityDetailsResponse = await this.getEntityDetails(entityId, environment_aliases); let cleanResponses = new Map(); @@ -117,7 +117,7 @@ export class EntitiesApiClient { async queryEntities( params: EntityQueryParams, - environment_aliases?: string, + environment_aliases: string, ): Promise> { const queryParams = { pageSize: params.pageSize || EntitiesApiClient.API_PAGE_SIZE, diff --git a/src/capabilities/events-api.ts b/src/capabilities/events-api.ts index a5566a6..3d4db1c 100644 --- a/src/capabilities/events-api.ts +++ b/src/capabilities/events-api.ts @@ -38,7 +38,7 @@ export class EventsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async queryEvents(params: EventQueryParams, environment_aliases?: string): Promise> { + async queryEvents(params: EventQueryParams, environment_aliases: string): Promise> { const queryParams = { from: params.from, to: params.to, @@ -52,10 +52,10 @@ export class EventsApiClient { return responses; } - async getEventDetails(eventId: string, environment_aliases?: string): Promise> { + async getEventDetails(eventId: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/events/${encodeURIComponent(eventId)}`, - undefined, + {}, environment_aliases, ); logger.debug('getEventDetails response: ', { data: responses }); diff --git a/src/capabilities/logs-api.ts b/src/capabilities/logs-api.ts index 8f4272d..a3cad58 100644 --- a/src/capabilities/logs-api.ts +++ b/src/capabilities/logs-api.ts @@ -33,7 +33,7 @@ export class LogsApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async queryLogs(params: LogQueryParams, environment_aliases?: string): Promise> { + async queryLogs(params: LogQueryParams, environment_aliases: string): Promise> { const queryParams = { query: params.query || '', from: params.from, diff --git a/src/capabilities/metrics-api.ts b/src/capabilities/metrics-api.ts index 83149eb..139f841 100644 --- a/src/capabilities/metrics-api.ts +++ b/src/capabilities/metrics-api.ts @@ -66,7 +66,7 @@ export class MetricsApiClient { async listAvailableMetrics( params: MetricListParams = {}, - environment_aliases?: string, + environment_aliases: string, ): Promise> { const queryParams = { pageSize: params.pageSize || MetricsApiClient.API_PAGE_SIZE, @@ -85,20 +85,17 @@ export class MetricsApiClient { return responses; } - async getMetricDetails(metricId: string, environment_aliases?: string): Promise> { + async getMetricDetails(metricId: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/metrics/${encodeURIComponent(metricId)}`, - undefined, + {}, environment_aliases, ); logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: responses }); return responses; } - async queryMetrics( - params: MetricQueryParams, - environment_aliases?: string, - ): Promise> { + async queryMetrics(params: MetricQueryParams, environment_aliases: string): Promise> { const queryParams = { metricSelector: params.metricSelector, resolution: params.resolution || 'Inf', diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index c859ec4..a0d0b82 100644 --- a/src/capabilities/problems-api.ts +++ b/src/capabilities/problems-api.ts @@ -75,7 +75,7 @@ export class ProblemsApiClient { async listProblems( params: ProblemQueryParams = {}, - environment_aliases?: string, + environment_aliases: string, ): Promise> { const queryParams = { pageSize: params.pageSize || ProblemsApiClient.API_PAGE_SIZE, @@ -93,10 +93,10 @@ export class ProblemsApiClient { return responses; } - async getProblemDetails(problemId: string, environment_aliases?: string): Promise> { + async getProblemDetails(problemId: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/problems/${encodeURIComponent(problemId)}`, - undefined, + {}, environment_aliases, ); diff --git a/src/capabilities/security-api.ts b/src/capabilities/security-api.ts index 7f4c5e7..eb83eb5 100644 --- a/src/capabilities/security-api.ts +++ b/src/capabilities/security-api.ts @@ -67,7 +67,7 @@ export class SecurityApiClient { async listSecurityProblems( params: SecurityProblemQueryParams = {}, - environment_aliases?: string, + environment_aliases: string, ): Promise> { const queryParams = { pageSize: params.pageSize || SecurityApiClient.API_PAGE_SIZE, @@ -84,10 +84,10 @@ export class SecurityApiClient { return responses; } - async getSecurityProblemDetails(problemId: string, environment_aliases?: string): Promise> { + async getSecurityProblemDetails(problemId: string, environment_aliases: string): Promise> { const responses = await this.authManager.makeRequests( `/api/v2/securityProblems/${encodeURIComponent(problemId)}`, - undefined, + {}, environment_aliases, ); logger.debug('getSecurityProblemDetails response', { data: responses }); diff --git a/src/capabilities/slo-api.ts b/src/capabilities/slo-api.ts index f69db29..df5856e 100644 --- a/src/capabilities/slo-api.ts +++ b/src/capabilities/slo-api.ts @@ -71,7 +71,7 @@ export class SloApiClient { constructor(private authManager: ManagedAuthClientManager) {} - async listSlos(params: SloQueryParams = {}, environment_aliases?: string): Promise> { + async listSlos(params: SloQueryParams = {}, environment_aliases: string): Promise> { const queryParams: Record = { pageSize: params.pageSize || SloApiClient.API_PAGE_SIZE, ...(params.sloSelector && { sloSelector: params.sloSelector }), @@ -90,7 +90,7 @@ export class SloApiClient { return responses; } - async getSloDetails(params: GetSloQueryParams, environment_aliases?: string): Promise> { + async getSloDetails(params: GetSloQueryParams, environment_aliases: string): Promise> { const queryParams: Record = { ...(params.from && { from: params.from }), ...(params.to && { to: params.to }), From 28d4afcfa0b038b978c9690425b130bfd360bc21 Mon Sep 17 00:00:00 2001 From: "Pedro [C] Perez" Date: Fri, 23 Jan 2026 15:27:45 +0000 Subject: [PATCH 15/17] - Updated instructions for LLM --- src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b05dc72..63d8998 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,17 +126,19 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - **SLO Management**: Service Level Objective monitoring, error budget analysis, and SLO evaluation tracking **Best Practices:** -- Use specific time ranges (1-2 hours) rather than large historical queries for better performance -- Leverage entity selectors to filter data at the source - they are fundamental to getting good results -- Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) - Start with calling the tool get_environments_info. It will include details of connection errors and configuration errors. - **CRITICAL: report connection issues to the user before any other requests**. - On every request, an "environment_alias" must be passed. - If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" + +- Use specific time ranges (1-2 hours) rather than large historical queries for better performance +- Leverage entity selectors to filter data at the source - they are fundamental to getting good results +- Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) - **When users specify counts** (e.g., "first 25 errors", "50 metrics", "100 errors"), always use the "limit" parameter in tools rather than guessing with searchText - **Avoid searchText guessing** - only use searchText when user explicitly mentions keywords to search for - **discover_entities ALWAYS requires entitySelector** - never call this tool without providing an entitySelector with exactly ONE entity type like type("SERVICE") unless using an EntityId. Multiple entity types are NOT supported. - **Next Steps are important** All requests will come back with a footer called 'Next Steps'. Take into consideration what it says. + **Time Range Parameters:** - **Relative Times**: now-1h, now-24h, now-7d, now-30d (h=hours, d=days, m=minutes, s=seconds) - **ISO Format**: 2024-01-01T10:00:00Z or 2024-01-01T10:00:00 From b4cc8faa9ba2564a8cb8ae73478da3ac713eb24f Mon Sep 17 00:00:00 2001 From: Aled Sage Date: Fri, 23 Jan 2026 15:34:02 +0000 Subject: [PATCH 16/17] Update MCP instructions for environments --- src/index.ts | 82 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index 63d8998..562d8e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,8 +109,8 @@ Some users may configure two MCPs at the same time: this MCP to connect to their Be careful of which MCP to use. If it is unclear, ask the user which they want to use. Ask the user to confirm the difference between their two environments. **Key Context:** -- This server accesses self-hosted Dynatrace Managed clusters (not the SaaS environment) -- Designed for historical data analysis before migration to SaaS +- This MCP server accesses self-hosted Dynatrace Managed clusters (not the SaaS version of Dynatrace) +- This MCP server can be used to interact with multiple Dynatrace Managed environments - Minimum supported cluster version: ${authClientManager.MINIMUM_VERSION} - Two different ways that Dynatrace Managed may be being used: 1. Dynatrace Managed may be the primary Observability system, containing all live data. @@ -126,11 +126,11 @@ Be careful of which MCP to use. If it is unclear, ask the user which they want t - **SLO Management**: Service Level Objective monitoring, error budget analysis, and SLO evaluation tracking **Best Practices:** -- Start with calling the tool get_environments_info. It will include details of connection errors and configuration errors. -- **CRITICAL: report connection issues to the user before any other requests**. -- On every request, an "environment_alias" must be passed. -- If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" - +- Must start by calling the tool get_environments_info. It will return a list of the available environments, including + details of connection errors and configuration errors. + - **CRITICAL: must report issues with environment configurations and connections to the user before any other requests**. +- On every subsequent request, an "environment_alias" must be passed. + - If the user wants information of all available environments, "environment_alias" MUST be "ALL_ENVIRONMENTS" - Use specific time ranges (1-2 hours) rather than large historical queries for better performance - Leverage entity selectors to filter data at the source - they are fundamental to getting good results - Use problem IDs (UUID format) from list_problems, not display IDs (P-XXXXX) @@ -383,7 +383,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -439,7 +441,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -472,7 +476,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -504,7 +510,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -560,7 +568,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -593,7 +603,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -615,7 +627,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -638,7 +652,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -694,7 +710,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -727,7 +745,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -750,7 +770,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -802,7 +824,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -839,7 +863,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -883,7 +909,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -920,7 +948,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -984,7 +1014,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), @@ -1044,7 +1076,9 @@ Never run queries that could return very large amounts of data, or that could be environment_alias: z .string() .describe( - 'Specifically hits one environment. Use `ALL_ENVIRONMENTS` to retrieve data from all environments in one request to MCP. ', + 'Specify which environment to be queried, by supplying the environment alias as returned ' + + 'by get_environments_info. Can use `ALL_ENVIRONMENTS` to retrieve data from all environments in ' + + 'one request to MCP.', ) .refine((alias) => envAliasValidate(alias), { message: 'Environment alias(es) not valid. Options are: ' + authClientManager.validAliases.join(', '), From 4a8cc80c812e06ce1eac51f965bf8fa0241ca2e9 Mon Sep 17 00:00:00 2001 From: Aled Sage Date: Fri, 23 Jan 2026 15:35:53 +0000 Subject: [PATCH 17/17] Minor updates to README for environments: - Avoid confusion of calling SaaS a "Dynatrace enviornment" etc --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8909015..f26440c 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ You can edit these as you see fit and include additional context that is specifi #### Multiple Managed Environments -In this example, you have multiple Dynatrace Managed environments set up, with different URLs and access tokens. This might be +In this example, you have multiple Dynatrace Managed environments set up. This might be a development/test/production setup, or different applications entirely. It is recommended to refer to your environments by the same alias you used in the `DT_ENVIRONMENT_CONFIGS` `alias` field to prevent confusion. @@ -508,19 +508,19 @@ same alias you used in the `DT_ENVIRONMENT_CONFIGS` `alias` field to prevent con In this example, you have migrated from Managed to SaaS, but still have historic data in your self-hosted Managed environment. You want your AI Assistant to have context on what data lives where. This will enable it to know which environments to target for the date range you ask for, e.g. `Show me all Dynatrace problems from the last 7 days` may require data from both environments -(and thus use both MCP servers), or may all reside in just the SaaS environment. +(and thus use both MCP servers), or may all reside in just the Dynatrace SaaS. ```text # Dynatrace -- I have two separate Dynatrace environments: +- I have two separate Dynatraces: 1. Dynatrace Managed is self-hosted. It contains only historical data from before 29th November 2025. It is accessed via the Dynatrace Managed MCP, named dynatrace-managed-mcp-server 2. Dynatrace SaaS is used for all live data. It is accessed through the Dynatrace SaaS MCP, named dynatrace-saas-mcp-server - Be careful of which MCP to use. If it is unclear, ask which MCP to use. -- Must make it very clear to the user whether data has come from the Dynatrace Managed or Dynatrace SaaS environments. +- Must make it very clear to the user whether data has come from the Dynatrace Managed or Dynatrace SaaS. ``` #### Hybrid setup running in tandem @@ -531,7 +531,7 @@ MCP client to have context on where it can find data on each one. ```text # Dynatrace -- I have two separate Dynatrace environments which both contain live data: +- I have two separate Dynatraces which both contain live data: 1. Dynatrace Managed is self-hosted. It only contains observability data for only some of my systems, primarily the book store systems. It is accessed via the Dynatrace Managed MCP, named dynatrace-managed-mcp-server @@ -539,7 +539,7 @@ MCP client to have context on where it can find data on each one. It is accessed through the Dynatrace SaaS MCP, named dynatrace-saas-mcp-server - Be careful of which MCP to use. If it is unclear, ask which MCP to use. -- Must make it very clear to the user whether data has come from the Dynatrace Managed or Dynatrace SaaS environments. +- Must make it very clear to the user whether data has come from the Dynatrace Managed or Dynatrace SaaS. ``` ## Example prompts @@ -582,7 +582,7 @@ The Dynatrace MCP Server includes sending Telemetry Data via Dynatrace OpenKit t - `DT_MCP_TELEMETRY_ENDPOINT_URL` (string, default: Dynatrace endpoint) - OpenKit endpoint URL - `DT_MCP_TELEMETRY_DEVICE_ID` (string, default: auto-generated) - Device identifier for tracking -To disable usage tracking, add this to your environment: +To disable usage tracking, add this to your configuration: ```bash DT_MCP_DISABLE_TELEMETRY=true