diff --git a/.env.template b/.env.template index 0d01a9e..23c6dde 100644 --- a/.env.template +++ b/.env.template @@ -1,8 +1,24 @@ ## 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) +# Mandatory keys are: "apiEndpointUrl", "environmentId", "alias" and "apiToken" -# Proxy configuration (optional - for corporate environments) -# HTTPS_PROXY=http://proxy.example.com:8080 +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": "", + "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/README.md b/README.md index 7df9142..f26440c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ -The **Dynatrace Managed MCP server** enables AI Assistants to interact with self-hosted [Dynatrace Managed](https://www.dynatrace.com/) environments, bringing observability data directly into your AI-assisted workflows. +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 supports **two modes**: @@ -39,13 +40,69 @@ 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", + "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.`. @@ -68,8 +125,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: @@ -79,6 +136,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 Dynatrace Managed environments from the same MCP server ## Capabilities @@ -92,13 +150,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 @@ -131,10 +190,7 @@ This only works if the config is stored in the current workspaces, e.g., `/integration-tests/**/*.integration.test.ts', '/tests/**/*.integration.test.ts'], testTimeout: 30000, + setupFiles: ['dotenv/config'], }, ], }; diff --git a/server.json b/server.json index b740b88..9b6b8d5 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", diff --git a/src/authentication/__tests__/managed-auth-client.test.ts b/src/authentication/__tests__/managed-auth-client.test.ts index 51c9b17..5d86a51 100644 --- a/src/authentication/__tests__/managed-auth-client.test.ts +++ b/src/authentication/__tests__/managed-auth-client.test.ts @@ -17,6 +17,8 @@ describe('ManagedAuthClient', () => { apiBaseUrl: 'https://managed.test.com', dashboardBaseUrl: 'https://managed-dashboard.test.com', apiToken: 'test-token', + alias: 'testAlias', + minimum_version: '1.328.0', }); }); @@ -50,6 +52,8 @@ describe('ManagedAuthClient', () => { apiBaseUrl: 'https://managed.test.com', 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/__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 a21219d..c1b4d91 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,20 +21,90 @@ export interface ManagedAuthClientParams { apiBaseUrl: string; dashboardBaseUrl: string; apiToken: string; + alias: string; + 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 = []; + this.clients = []; + this.validAliases = ['ALL_ENVIRONMENTS']; + + 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, + httpProxy: managedEnvironment.httpProxy, + httpsProxy: managedEnvironment.httpsProxy, + minimum_version: this.MINIMUM_VERSION, + }); + this.rawClients.push(newClient); + } + } + + 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) { + const response = await client.makeRequest(endpoint, params); + responses.set(client.alias, response); + } + } + return responses; + } + + async isConfigured(): 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); + } + } + } + + getBaseUrl(alias: string): string { + for (let client of this.clients) { + if (client.alias === alias) { + return client.dashboardBaseUrl; + } + } + return ''; + } } 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'; + public MINIMUM_VERSION: string; 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.MINIMUM_VERSION = params.minimum_version; + this.validationError = ''; this.httpClient = axios.create({ baseURL: this.apiBaseUrl, @@ -42,13 +124,16 @@ 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 +163,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 +180,57 @@ 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 environment "${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 = `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; + } + return true; + } catch (error: any) { + logger.error( + `[CONNECTION ERROR] Failed to connect to Managed environment "${this.alias}": ${this.apiBaseUrl}: ${error.message}.`, + ); + logger.error('Please verify:'); + 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 environment'); + this.validationError = `Failed to connect to Managed environment "${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.'); + 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; } try { diff --git a/src/capabilities/__tests__/entities-api.test.ts b/src/capabilities/__tests__/entities-api.test.ts index 9804c18..c911c7a 100644 --- a/src/capabilities/__tests__/entities-api.test.ts +++ b/src/capabilities/__tests__/entities-api.test.ts @@ -1,18 +1,21 @@ import { EntitiesApiClient, Entity, GetEntityRelationshipsResponse } from '../entities-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new EntitiesApiClient(mockAuthClient); + client = new EntitiesApiClient(mockAuthManager); }); afterEach(() => { @@ -21,39 +24,43 @@ describe('EntitiesApiClient', () => { describe('getEntityDetails', () => { it('should get entity details by ID', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + const result = await client.getEntityDetails('SERVICE-123', 'testAlias'); - const result = await client.getEntityDetails('SERVICE-123'); - - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/entities/SERVICE-123', {}, '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: [ + const mockEntity = new Map([ + [ + 'testAlias', { - id: 'rel-1', - type: 'CALLS', - fromEntityId: 'SERVICE-123', - toEntityId: 'SERVICE-456', + 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,50 +81,48 @@ describe('EntitiesApiClient', () => { ], }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); - - const result = await client.getEntityRelationships('SERVICE-123'); - - expect(result).toEqual(expectedResponse); + mockAuthManager.makeRequests.mockResolvedValue(mockEntity); + const result = await client.getEntityRelationships('SERVICE-123', 'testAlias'); + expect(result.get('testAlias')).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 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'); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getEntityDetails('my-id'); + 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.get('testAlias')).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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/listEntityTypes.json', 'utf8'))], + ]); + 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'); @@ -125,75 +130,94 @@ describe('EntitiesApiClient', () => { }); it('should format list when sparse', async () => { - const mockResponse = { - types: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + types: [{}], + }, + ], + ]); - const response = await client.listEntityTypes(); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listEntityTypes('ALL_ENVIRONMENTS'); 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 = 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); - 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 = new Map([ + [ + 'testAlias', + { + totalCount: 0, + types: [], + }, + ], + ]); + + 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'); + 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 = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEntityTypeDetails.json', 'utf8')), + ], + ]); - const response = await client.getEntityTypeDetails('SERVICE'); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.getEntityTypeDetails('SERVICE', 'ALL_ENVIRONMENTS'); 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 = 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 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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEntities.json', 'utf8'))], + ]); + + 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); @@ -211,13 +235,19 @@ describe('EntitiesApiClient', () => { entityType: 'SERVICE', tags: [{ context: 'CONTEXTLESS', key: 'environment', value: 'production' }], })); - const mockResponse = { - totalCount: 100, - entities: mockEntities, - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + totalCount: 100, + entities: mockEntities, + }, + ], + ]); - const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryEntities({ entitySelector: 'type(SERVICE)' }, 'ALL_ENVIRONMENTS'); const result = client.formatEntityList(response); // Should show all 60 entities, not just 20 @@ -227,22 +257,27 @@ describe('EntitiesApiClient', () => { }); it('should handle empty entities list', async () => { - const mockResponse = { - totalCount: 0, - entities: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + totalCount: 0, + entities: [], + }, + ], + ]); + 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'); }); it('should handle entities list that is empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + 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'); }); @@ -250,27 +285,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', - }, - ], - toRelationships: [ + const mockEntity = new Map([ + [ + 'testAlias', { - id: 'rel-2', - type: 'RUNS_ON', - fromEntityId: 'SERVICE-123', - toEntityId: 'HOST-789', + 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', + }, + ], }, ], - }; + ]); + const expectedResponse: GetEntityRelationshipsResponse = { entityId: 'SERVICE-123', fromRelationships: [ @@ -291,12 +332,12 @@ describe('EntitiesApiClient', () => { ], }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + 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).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'); @@ -304,57 +345,74 @@ 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 = new Map([ + [ + 'testAlias', + { + 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 response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response).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 = { - entityId: 'SERVICE-123', - displayName: 'service-with-undefined-relationships', - entityType: 'SERVICE', - fromRelationships: null, - toRelationships: null, - }; + 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, toRelationships: null, }; - mockAuthClient.makeRequest.mockResolvedValue(mockEntity); + 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).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: any = { - entityId: 'SERVICE-123', - displayName: 'service-with-invalid-relationships', - entityType: 'SERVICE', - fromRelationships: 'not-an-array', - toRelationships: { unexpectedKey: 'unexpected-val' }, - }; + 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', @@ -362,12 +420,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 response = await client.getEntityRelationships('SERVICE-123', 'testAlias'); const result = client.formatEntityRelationships(response); - expect(response).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'); @@ -377,26 +435,33 @@ 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 = new Map([['testAlias', {}]]); + 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..cd7a7f7 100644 --- a/src/capabilities/__tests__/events-api.test.ts +++ b/src/capabilities/__tests__/events-api.test.ts @@ -1,18 +1,21 @@ import { EventsApiClient, Event } from '../events-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new EventsApiClient(mockAuthClient); + client = new EventsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,62 +24,79 @@ describe('EventsApiClient', () => { describe('queryEvents', () => { it('should query events with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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', {}, '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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryEvents.json', 'utf8'))], + ]); - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -98,10 +118,16 @@ describe('EventsApiClient', () => { startTime: 1640995200000 + i * 1000, entityName: `service-${i}`, })); - const response = { - totalCount: 100, - events: mockEvents, - }; + + const response = new Map([ + [ + 'testAlias', + { + totalCount: 100, + events: mockEvents, + }, + ], + ]); const result = client.formatList(response); @@ -112,12 +138,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 = new Map([ + [ + 'testAlias', + { + 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 +163,10 @@ describe('EventsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.queryEvents({ from: 'now-1h', to: 'now' }); + const response = await client.queryEvents({ from: 'now-1h', to: 'now' }, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -142,13 +174,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 = new Map([ + [ + 'testAlias', + { + 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 +195,29 @@ 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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getEventDetails.json', 'utf8'))], + ]); + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getEventDetails('my-id'); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getEventDetails('my-id'); + 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..cb77eda 100644 --- a/src/capabilities/__tests__/logs-api.test.ts +++ b/src/capabilities/__tests__/logs-api.test.ts @@ -1,18 +1,21 @@ import { LogsApiClient, LogEntry } from '../logs-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new LogsApiClient(mockAuthClient); + client = new LogsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,54 +24,70 @@ 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = 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' }); + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -80,12 +99,18 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result', async () => { - const mockResponse = { - results: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + results: [{}], + }, + ], + ]); + + 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); @@ -94,12 +119,17 @@ describe('LogsApiClient', () => { }); it('should format list when sparse result data', async () => { - const mockResponse = { - results: [{ data: [{}] }], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + results: [{ data: [{}] }], + }, + ], + ]); + 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); @@ -108,10 +138,10 @@ describe('LogsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + 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); @@ -119,10 +149,18 @@ describe('LogsApiClient', () => { }); it('should format empty logs list', async () => { - const mockResponse = { results: [] }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + results: [], + }, + ], + ]); - const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.queryLogs({ query: 'content:test', from: 'now-1h', to: 'now' }, 'ALL_ENVIRONMENTS'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -140,9 +178,14 @@ describe('LogsApiClient', () => { service: [`service-${i % 5}`], }, })); - const response = { - results: mockLogs, - }; + const response = new Map([ + [ + 'testAlias', + { + results: mockLogs, + }, + ], + ]); const result = client.formatList(response); @@ -164,9 +207,14 @@ describe('LogsApiClient', () => { }, }, ]; - const response = { - results: mockLogs, - }; + const response = new Map([ + [ + 'testAlias', + { + results: mockLogs, + }, + ], + ]); const result = client.formatList(response); @@ -176,17 +224,22 @@ describe('LogsApiClient', () => { }); it('should show that truncated when multiple pages', () => { - const response = { - results: [ + const response = new Map([ + [ + 'testAlias', { - timestamp: 1704110400000, - content: `My log message 1`, - status: 'INFO', + 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,17 +247,21 @@ describe('LogsApiClient', () => { }); it('should not show LLM awareness hint when no second page', () => { - const response = { - results: [ + const response = new Map([ + [ + 'testAlias', { - timestamp: 1704110400000, - content: `My log message 1`, - status: 'INFO', + results: [ + { + timestamp: 1704110400000, + content: `My log message 1`, + status: 'INFO', + }, + ], + sliceSize: 1, }, ], - 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 0285c7d..afe5e43 100644 --- a/src/capabilities/__tests__/metrics-api.test.ts +++ b/src/capabilities/__tests__/metrics-api.test.ts @@ -1,18 +1,21 @@ import { MetricsApiClient, Metric } from '../metrics-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new MetricsApiClient(mockAuthClient); + client = new MetricsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,76 +24,98 @@ 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.listAvailableMetrics(); + const result = await client.listAvailableMetrics({}, 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/metrics', { - pageSize: 500, - }); + 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 = new Map([ + [ + 'testAlias', + 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 +130,17 @@ describe('MetricsApiClient', () => { metricId: `builtin:metric.${i}`, displayName: `Metric ${i}`, })); - const response = { - totalCount: 200, - metrics: mockMetrics, - }; + + const response = new Map([ + [ + 'testAlias', + { + totalCount: 200, + metrics: mockMetrics, + }, + ], + ]); + const result = client.formatMetricList(response); expect(result).toContain('Listing 100 of 200 metrics'); @@ -117,12 +149,18 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse metric', async () => { - const mockResponse = { - metrics: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + metrics: [{}], + }, + ], + ]); + + 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); @@ -131,10 +169,10 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listAvailableMetrics({}); + const response = await client.listAvailableMetrics({}, 'testAlias'); const result = client.formatMetricList(response); expect(response).toEqual(mockResponse); @@ -142,13 +180,19 @@ describe('MetricsApiClient', () => { }); it('should handle empty list', async () => { - const mockResponse = { - totalCount: 0, - metrics: [], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([ + [ + 'testAlias', + { + totalCount: 0, + metrics: [], + }, + ], + ]); + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listAvailableMetrics({}); + const response = await client.listAvailableMetrics({}, 'testAlias'); const result = client.formatMetricList(response); expect(result).toContain('Listing 0 metrics'); @@ -157,43 +201,50 @@ 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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getMetricDetails.json', 'utf8'))], + ]); + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getMetricDetails('my-id'); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getMetricDetails('my-id'); + 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 mockResponse = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/queryMetrics.json', 'utf8'))], + ]); - 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); - 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 +253,25 @@ describe('MetricsApiClient', () => { }); it('should format list when sparse data series', async () => { - const mockResponse = { - result: [ + const mockResponse = new Map([ + [ + 'testAlias', { - data: [{}], - metricId: 'builtin:host.cpu.usage', + 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 +282,38 @@ describe('MetricsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + 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(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 = new Map([ + [ + 'testAlias', + { + 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..3af033a 100644 --- a/src/capabilities/__tests__/problems-api.test.ts +++ b/src/capabilities/__tests__/problems-api.test.ts @@ -1,18 +1,21 @@ import { ProblemsApiClient, Problem } from '../problems-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new ProblemsApiClient(mockAuthClient); + client = new ProblemsApiClient(mockAuthManager); }); afterEach(() => { @@ -21,60 +24,74 @@ describe('ProblemsApiClient', () => { describe('listProblems', () => { it('should list problems with all parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue({}); + const mockResponse = new Map([['testAlias', {}]]); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.listProblems(); + const result = await client.listProblems({}, 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/problems', { - pageSize: 50, - }); + 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 = new Map([['testAlias', {}]]); + 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', {}, '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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/listProblems.json', 'utf8'))], + ]); + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listProblems(); + const response = await client.listProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -89,12 +106,11 @@ describe('ProblemsApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - problems: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', { problems: [{}] }]]); + + 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); @@ -109,10 +125,10 @@ describe('ProblemsApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + 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); @@ -130,10 +146,16 @@ describe('ProblemsApiClient', () => { status: 'OPEN', startTime: 1640995200000 + i * 1000, })); - const response = { - totalCount: 123, - problems: mockProblems, - }; + + const response = new Map([ + [ + 'testAlias', + { + totalCount: 123, + problems: mockProblems, + }, + ], + ]); const result = client.formatList(response); @@ -144,10 +166,15 @@ describe('ProblemsApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - problems: [], - }; + const response = new Map([ + [ + 'testAlias', + { + totalCount: 0, + problems: [], + }, + ], + ]); const result = client.formatList(response); expect(result).toContain('Listing 0 problems'); }); @@ -155,29 +182,30 @@ 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 = new Map([ + ['testAlias', JSON.parse(readFileSync('src/capabilities/__tests__/resources/getProblemDetails.json', 'utf8'))], + ]); + + 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); - 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 = 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); - 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..5765ce7 100644 --- a/src/capabilities/__tests__/security-api.test.ts +++ b/src/capabilities/__tests__/security-api.test.ts @@ -1,18 +1,21 @@ import { SecurityApiClient, SecurityProblem } from '../security-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new SecurityApiClient(mockAuthClient); + client = new SecurityApiClient(mockAuthManager); }); afterEach(() => { @@ -21,49 +24,60 @@ describe('SecurityApiClient', () => { describe('listSecurityProblems', () => { it('should list security problems with default parameters', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.listSecurityProblems(); + const result = await client.listSecurityProblems({}, 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/securityProblems', { - pageSize: SecurityApiClient.API_PAGE_SIZE, - }); + 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 = new Map([['testAlias', { 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({}, 'testAlias'); fail('Should have propagated exception'); } catch (error: any) { console.log(error); @@ -74,24 +88,22 @@ describe('SecurityApiClient', () => { describe('getSecurityProblemDetails', () => { it('should get security problem details', async () => { - const mockResponse = { - securityProblemId: 'SP-123', - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', { securityProblemId: 'SP-123' }]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const result = await client.getSecurityProblemDetails('SP-123'); + const result = await client.getSecurityProblemDetails('SP-123', 'testAlias'); - expect(mockAuthClient.makeRequest).toHaveBeenCalledWith('/api/v2/securityProblems/SP-123'); + expect(mockAuthManager.makeRequests).toHaveBeenCalledWith('/api/v2/securityProblems/SP-123', {}, '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 +114,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 = new Map([ + [ + 'testAlias', + JSON.parse(readFileSync('src/capabilities/__tests__/resources/listSecurityProblems.json', 'utf8')), + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -128,10 +143,16 @@ describe('SecurityApiClient', () => { status: 'OPEN', title: `Security Problem ${i}`, })); - const response = { - totalCount: 123, - securityProblems: mockProblems, - }; + + const response = new Map([ + [ + 'testAlias', + { + totalCount: 123, + securityProblems: mockProblems, + }, + ], + ]); const result = client.formatList(response); @@ -142,12 +163,11 @@ describe('SecurityApiClient', () => { }); it('should format list when sparse problem', async () => { - const mockResponse = { - securityProblems: [{}], - }; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', { securityProblems: [{}] }]]); + + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSecurityProblems(); + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -159,10 +179,10 @@ describe('SecurityApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSecurityProblems(); + const response = await client.listSecurityProblems({}, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -170,10 +190,16 @@ describe('SecurityApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - securityProblems: [], - }; + const response = new Map([ + [ + 'testAlias', + { + totalCount: 0, + securityProblems: [], + }, + ], + ]); + const result = client.formatList(response); expect(result).toContain('Listing 0 security vulnerabilities'); }); @@ -181,28 +207,31 @@ 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 = 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'); 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getSecurityProblemDetails('my-id'); + 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..893337c 100644 --- a/src/capabilities/__tests__/slo-api.test.ts +++ b/src/capabilities/__tests__/slo-api.test.ts @@ -1,18 +1,21 @@ import { SloApiClient, SLO } from '../slo-api'; -import { ManagedAuthClient } from '../../authentication/managed-auth-client'; +import { ManagedAuthClientManager } from '../../authentication/managed-auth-client'; import { readFileSync } from 'fs'; 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(), + getBaseUrl: jest.fn(() => { + return 'http://dashboardbaseurl.com/e/environment_id'; + }), } as any; - client = new SloApiClient(mockAuthClient); + client = new SloApiClient(mockAuthManager); }); afterEach(() => { @@ -21,106 +24,131 @@ 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 = new Map([['testAlias', {}]]); + 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 +166,16 @@ describe('SloApiClient', () => { errorBudget: 80, evaluatedPercentage: 96, })); - const response = { - totalCount: 123, - slo: mockSLOs, - }; + + const response = new Map([ + [ + 'testAlias', + { + totalCount: 123, + slo: mockSLOs, + }, + ], + ]); const result = client.formatList(response); @@ -152,10 +186,13 @@ 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 = new Map([ + ['testAlias', 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 +203,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 = new Map([ + [ + 'testAlias', + { + slo: [{}], + }, + ], + ]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); + + const response = await client.listSlos(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -182,10 +224,10 @@ describe('SloApiClient', () => { }); it('should format list when empty', async () => { - const mockResponse = {}; - mockAuthClient.makeRequest.mockResolvedValue(mockResponse); + const mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.listSlos(); + const response = await client.listSlos(undefined, 'testAlias'); const result = client.formatList(response); expect(response).toEqual(mockResponse); @@ -193,10 +235,16 @@ describe('SloApiClient', () => { }); it('should handle empty list', () => { - const response = { - totalCount: 0, - slo: [], - }; + const response = new Map([ + [ + 'testAlias', + { + totalCount: 0, + slo: [], + }, + ], + ]); + const result = client.formatList(response); expect(result).toContain('Listing 0 SLOs'); }); @@ -204,26 +252,28 @@ 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 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' }); + 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 mockResponse = new Map([['testAlias', {}]]); + mockAuthManager.makeRequests.mockResolvedValue(mockResponse); - const response = await client.getSloDetails({ id: 'my-id' }); + 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 d13ebc0..c2d7524 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 { @@ -54,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; @@ -73,42 +65,60 @@ 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)}`, + {}, + 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)}`, + {}, + environment_aliases, + ); + logger.debug(`getEntityDetails response, entityId=${entityId}`, { data: responses }); + return responses; } - async getEntityRelationships(entityId: string): Promise { - const response = await this.getEntityDetails(entityId); + async getEntityRelationships( + entityId: string, + environment_aliases: string, + ): Promise> { + const entityDetailsResponse = await this.getEntityDetails(entityId, environment_aliases); + let cleanResponses = new Map(); + for (const [alias, data] of entityDetailsResponse) { + cleanResponses.set(alias, { + entityId: data.entityId, + fromRelationships: data.fromRelationships, + toRelationships: data.toRelationships, + }); + } - return { - entityId: response.entityId, - fromRelationships: response.fromRelationships, - toRelationships: response.toRelationships, - }; + return cleanResponses; } - 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 +128,97 @@ 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; + formatEntityList(responses: Map): string { + 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; + 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) + '...'; - } - - 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`; + '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'; + anyLimited = true; } - 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'; - }); + 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'; + }); + } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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' + + (baseUrl ? ` at ${baseUrl}/` : '.') + '\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: Map): string { + let result = ''; + let totalNumTypes = 0; + let aliases: string[] = []; const commonTypes = [ 'SERVICE', 'PROCESS_GROUP', @@ -209,94 +229,127 @@ 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 [alias, data] of responses) { + aliases.push(alias); + let totalCount = data.totalCount || -1; + let numTypes = data.types?.length || 0; + totalNumTypes += numTypes; + let isLimited = totalCount != 0 - 1 && totalCount > numTypes; + + let entityTypes = data.types as any[]; + let conciseList = ''; + let availableCommonTypes: string[] = []; + + result += + 'Listing ' + + numTypes + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' entity types for environment ' + + alias + + '.\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; } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; - 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' + + (baseUrl ? ` at ${baseUrl}/` : '.') + + '\n'; return result; } - formatEntityTypeDetails(response: any): string { - let result = - `Entity type details in the following json:\n` + - JSON.stringify(response) + - '\n' + + formatEntityTypeDetails(responses: Map): string { + let result = ''; + for (const [alias, data] of responses) { + result += + 'Entity type details from environment ' + alias + ' in the following json:\n' + JSON.stringify(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' + + 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 at ' + - `${this.authClient.dashboardBaseUrl}/ui/entity/, 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(response: GetEntityRelationshipsResponse): string { - const from = response.fromRelationships; - const to = response.toRelationships; - const numFrom = this.countRelationships(from); - const numTo = this.countRelationships(to); + 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); + const numTo = this.countRelationships(to); + + if (numFrom == 0 && numTo == 0) { + result += `No relationships found for entity ${data.entityId} in environment ${alias}\n`; + } - if (numFrom == 0 && numTo == 0) { - return `No relationships found for entity ${response.entityId}.\n`; - } + result += `Relationships found for entity ${data.entityId} in environment ${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`; + } } + 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 at ' + - `${this.authClient.dashboardBaseUrl}/ui/entity/, 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 fe93149..3d4db1c 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,121 @@ 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)}`, + {}, + 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: Map): string { + let result = ''; + let totalNumEvents = 0; + let anyLimited = false; + let aliases: string[] = []; - let result = 'Listing ' + numEvents + (totalCount == -1 ? '' : ' of ' + totalCount) + ' events.\n'; + for (const [alias, data] of responses) { + aliases.push(alias); + let totalCount = data.totalCount || -1; + let numEvents = 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'; - } - - 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`; + 'Listing ' + + numEvents + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' events from environment ' + + alias + + '.\n\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'; - }); + 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'; + }); + } + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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' + + (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; } - formatDetails(response: any): string { - let result = - `Event details in the following json:\n` + - JSON.stringify(response) + - '\n' + + 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 at ' + - `${this.authClient.dashboardBaseUrl}/` + - '\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 30e4c30..a3cad58 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'; @@ -28,19 +28,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 +42,74 @@ 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; - - let result = 'Listing ' + numLogs + ' log records.\n'; - - if (isLimited) { - result += 'Results likely restricted due to maximum response size, consider using a more specific filter.'; - } + formatList(responses: Map): string { + 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; + + 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.'; + 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'; + 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'; + }); + } + + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; - 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' : '') + - '* Suggest to the user that they use the Dynatrace UI to view metric data at ' + - `${this.authClient.dashboardBaseUrl}/ui/log-monitoring` + + (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' + + (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 d58b315..139f841 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,12 @@ 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 +78,24 @@ 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)}`); - 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)}`, + {}, + environment_aliases, + ); + logger.debug(`getMetricDetails response, metricId=${metricId}`, { data: responses }); + return responses; } - async queryMetrics(params: MetricQueryParams): Promise { + async queryMetrics(params: MetricQueryParams, environment_aliases: string): Promise> { const queryParams = { metricSelector: params.metricSelector, resolution: params.resolution || 'Inf', @@ -96,132 +104,160 @@ export class MetricsApiClient { ...(params.entitySelector && { entitySelector: params.entitySelector }), }; - const response = await this.authClient.makeRequest('/api/v2/metrics/query', queryParams); - 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(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'; - - if (isLimited) { - result += 'Not showing all matching metrics. Consider using more specific filters to get complete results.\n'; - } + formatMetricList(responses: Map): string { + 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; + let isLimited = totalCount != 0 - 1 && totalCount > numMetrics; + + result += + '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'; + anyLimited = true; + } - 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`; + 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`; - // High Priority: Add aggregation types - if (metric.aggregationTypes && metric.aggregationTypes.length > 0) { - result += ` aggregationTypes: ${metric.aggregationTypes.join(', ')}\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`; - } + // 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'; - }); + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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' + + (baseUrl + ? ` to: \n * Browse the list of metrics at ${baseUrl}/ui/metrics` + + `\n * View metric data at ${baseUrl}/ui/data-explorer` + : '.'); 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: 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'; + } + 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: 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; + + result += 'Listing data series from environment ' + 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; + 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'; + }); }); - }); + } + + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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' + + (baseUrl ? ` at ${baseUrl}/ui/data-explorer` : '.'); return result; } diff --git a/src/capabilities/problems-api.ts b/src/capabilities/problems-api.ts index a18889c..a0d0b82 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,12 @@ 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,77 +87,103 @@ export class ProblemsApiClient { ...(params.sort && { sort: params.sort }), }; - const response = await this.authClient.makeRequest('/api/v2/problems', queryParams); + 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): Promise { - const response = await this.authClient.makeRequest(`/api/v2/problems/${encodeURIComponent(problemId)}`); + async getProblemDetails(problemId: string, environment_aliases: string): Promise> { + const responses = await this.authManager.makeRequests( + `/api/v2/problems/${encodeURIComponent(problemId)}`, + {}, + environment_aliases, + ); - logger.debug('getProblemDetails response', { data: response }); - return response; + logger.debug('getProblemDetails response', { data: responses }); + return responses; } - formatList(response: ListProblemResponse): string { - let totalCount = response.totalCount || -1; - let numProblems = response.problems?.length || 0; - let isLimited = totalCount != 0 - 1 && totalCount > numProblems; + formatList(responses: Map): string { + 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; + let isLimited = totalCount != 0 - 1 && totalCount > numProblems; - let result = 'Listing ' + numProblems + (totalCount == -1 ? '' : ' of ' + totalCount) + ' problems.\n'; - - if (isLimited) { result += - 'Not showing all matching problems. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\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`; + 'Listing ' + + numProblems + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' problems from environment ' + + 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; } - result += '\n'; - }); + + 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'; + }); + } 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'; + + 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; } - formatDetails(response: any): string { - let result = - 'Details of problem in the following json.\n' + - JSON.stringify(response) + - '\n' + + 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'); - '* 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'; + '* 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' + + (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 47ee1fe..eb83eb5 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'; @@ -59,29 +59,16 @@ 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 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,83 +79,115 @@ 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)}`, + {}, + 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; - - let result = - 'Listing ' + - numProblems + - (totalCount == -1 ? '' : ' of ' + totalCount) + - ' security vulnerabilities in the following json.\n'; + formatList(responses: Map): string { + 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; + let isLimited = totalCount != 0 - 1 && totalCount > numProblems; - if (isLimited) { result += - 'Not showing all matching vulnerabilities. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\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) { + 'Listing ' + + numProblems + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' security vulnerabilities from environment ' + + alias + + ' in the following json.\n'; + + 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`; + 'Not showing all matching vulnerabilities. Consider using more specific filters (status, impactLevel, entitySelector) to get complete results.\n'; + anyLimited = true; } - if (problem.firstSeenTimestamp) { - result += ` firstSeen: ${formatTimestamp(problem.firstSeenTimestamp)}\n`; - } - result += '\n'; - }); + + 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'; + }); + } + + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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` + - '\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(response: any): string { - let result = - 'Details of security problem in the following json.\n' + - JSON.stringify(response) + - '\n' + + 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 + + ' in the following json:\n' + + 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 ' + - `${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' + + (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 4a300db..df5856e 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,93 @@ 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; + formatList(responses: Map): string { + 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; + let isLimited = totalCount != 0 - 1 && totalCount > numSLOs; - let result = 'Listing ' + numSLOs + (totalCount == -1 ? '' : ' of ' + totalCount) + ' SLOs.\n'; - - if (isLimited) { result += - 'Not showing all matching SLOs. Consider using more specific filters (sloSelector) to get complete results.\n'; + 'Listing ' + + numSLOs + + (totalCount == -1 ? '' : ' of ' + totalCount) + + ' SLOs from environment ' + + alias + + ':\n'; + + if (isLimited) { + result += + 'Not showing all matching SLOs. Consider using more specific filters (sloSelector) to get complete results.\n'; + anyLimited = true; + } + + 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'; - }); + const baseUrl = aliases.length == 1 ? this.authManager.getBaseUrl(aliases[0]) : ''; 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' + + (baseUrl ? ` at ${baseUrl}/ui/slo` : '.'); 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: Map): string { + let result = ''; + 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 c8a6874..562d8e3 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, validateEnvironments } from './utils/environment'; +import { createTelemetry } from './utils/telemetry-openkit'; import { MetricsApiClient } from './capabilities/metrics-api'; import { LogsApiClient } from './capabilities/logs-api'; @@ -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'); @@ -43,32 +43,36 @@ const main = async () => { logger.info(`Initializing Dynatrace Managed MCP Server v${getPackageJsonVersion()}...`); // Read Managed environment configuration - let managedConfig; - try { - managedConfig = getManagedEnvironmentConfig(); - } catch (err) { - console.error('Failed to get managed environment configuration', err); - logger.error('Failed to get managed environment configuration', { error: err }); - process.exit(1); + + 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 }); } - const { environmentId, apiUrl, dashboardUrl, apiToken } = managedConfig; + if (initConfigs.length === 0) { + logger.error('No valid environments found, stopping.'); + console.error('No valid environments found, stopping.'); + await flushLogger(); + process.exit(1); + } - // Initialize Managed authentication client - const authClient = new ManagedAuthClient({ - apiBaseUrl: apiUrl, - dashboardBaseUrl: dashboardUrl, - apiToken: apiToken, - }); + const authClientManager = new ManagedAuthClientManager(initConfigs); + await authClientManager.isConfigured(); // 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(); @@ -81,6 +85,7 @@ const main = async () => { for (const op of shutdownOps) { await op(); } + await flushLogger(); process.exit(0); }; }; @@ -97,16 +102,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} +- 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. 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. @@ -121,13 +126,18 @@ 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:** +- 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) -- Start with get_environment_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. +- **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) @@ -184,41 +194,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 +271,78 @@ 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) => { + 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_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 - Listing info for ${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`; + } + } - 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`; + 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; }, @@ -321,7 +357,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 +368,55 @@ 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() + .describe( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ entitySelector, searchText, limit }) => { - const response = await metricsClient.listAvailableMetrics({ - entitySelector: entitySelector, - text: searchText, - pageSize: limit, - }); - return metricsClient.formatMetricList(response); + async ({ entitySelector, searchText, limit, environment_alias }) => { + const responses = await metricsClient.listAvailableMetrics( + { + entitySelector: entitySelector, + text: searchText, + pageSize: limit, + }, + 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 +426,45 @@ 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() + .describe( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ metricSelector, from, to, resolution, entitySelector }) => { - const response = await metricsClient.queryMetrics({ - metricSelector: metricSelector, - from: from, - to: to, - resolution: resolution, - entitySelector: entitySelector, - }); + 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 +473,72 @@ 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( + '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(', '), + }), }, { 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() + .describe( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ query, from, to, limit, sort }) => { - const response = await logsClient.queryLogs({ - query: query, - from: from, - to: to, - limit: limit, - sort: sort, - }); - return logsClient.formatList(response); + 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, + ); + 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 +547,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 +565,33 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ from, to, eventType, entitySelector, limit }) => { - const response = await eventsClient.queryEvents({ - from: from, - to: to, - eventType: eventType, - entitySelector: entitySelector, - pageSize: limit, - }); + 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 +600,22 @@ 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( + '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(', '), + }), }, { 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 +623,24 @@ 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() + .describe( + '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(', '), + }), + }, { 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 +649,44 @@ 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( + '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(', '), + }), }, { 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 +707,33 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ entitySelector, mzSelector, from, to, limit, sort }) => { - const response = await entitiesClient.queryEntities({ - entitySelector: entitySelector, - pageSize: limit, - mzSelector: mzSelector, - from: from, - to: to, - sort: sort, - }); - return entitiesClient.formatEntityList(response); + 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, + ); + return entitiesClient.formatEntityList(responses); }, ); @@ -600,12 +742,22 @@ 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( + '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(', '), + }), }, { 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 +767,23 @@ 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( + '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(', '), + }), }, { 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,22 +821,35 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ from, to, status, impactLevel, entitySelector, limit, sort }) => { - const response = await problemsClient.listProblems({ - from: from || 'now-24h', - to: to || 'now', - status: status, - impactLevel: impactLevel, - entitySelector: entitySelector, - pageSize: limit, - sort: sort, - }); + 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, + ); - return problemsClient.formatList(response); + return problemsClient.formatList(responses); }, ); @@ -685,12 +860,22 @@ 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( + '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(', '), + }), }, { 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,22 +906,35 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ riskLevel, status, entitySelector, from, to, limit, sort }) => { - const response = await securityClient.listSecurityProblems({ - riskLevel: riskLevel, - status: status, - entitySelector: entitySelector, - from: from, - to: to, - pageSize: limit, - sort: sort, - }); + 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, + ); - return securityClient.formatList(response); + return securityClient.formatList(responses); }, ); @@ -747,12 +945,22 @@ 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( + '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(', '), + }), }, { 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,24 +1011,49 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ sloSelector, timeFrame, from, to, evaluate, sort, enabledSlos, showGlobalSlos, demo, limit }) => { - const response = await sloClient.listSlos({ - sloSelector: sloSelector, - timeFrame: timeFrame, - from: from, - to: to, - evaluate: evaluate, - sort: sort, - enabledSlos: enabledSlos, - showGlobalSlos: showGlobalSlos, - demo: demo, - pageSize: limit, - }); - return sloClient.formatList(response); + 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); }, ); @@ -840,17 +1073,30 @@ 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( + '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(', '), + }), }, { readOnlyHint: true, }, - async ({ sloId, from, to, timeFrame }) => { - const response = await sloClient.getSloDetails({ - id: sloId, - from: from, - to: to, - timeFrame: 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); }, ); @@ -973,5 +1219,6 @@ main().catch(async (error) => { } catch (e: any) { logger.error(`Failed to track fatal error: ${e.message}`, { error: e }); } + await flushLogger(); process.exit(1); }); diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts index 14c0d2f..24ca7a4 100644 --- a/src/utils/__tests__/environment.test.ts +++ b/src/utils/__tests__/environment.test.ts @@ -1,7 +1,49 @@ -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"' + + ' },' + + ' {' + + ' "dynatraceUrl": "https://my-dashboard-endpoint.com",' + + ' "environmentId": "my-env-id-5",' + + ' "alias": "missing-api-url-env-5",' + + ' "apiToken": "my-api-token",' + + ' "httpProxyUrl": "",' + + ' "httpsProxyUrl": ""' + + ' }' + + ']'; beforeEach(() => { process.env = {}; @@ -11,71 +53,132 @@ 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: '', + }, + { + 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: '', + }, + ]); + }); - 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.', + '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.', + ]); + }); - 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_ENVIRONMENT_CONFIGS 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 e9926ac..985a977 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,30 +1,29 @@ +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; - - if (!environmentIdRaw) { - throw new Error('DT_MANAGED_ENVIRONMENT is required'); - } - - if (!apiUrlRaw) { - throw new Error('DT_API_ENDPOINT_URL is required'); - } - - if (!apiToken) { - throw new Error('DT_MANAGED_API_TOKEN is required'); - } +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() : ''; 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; @@ -33,5 +32,70 @@ 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 environmentConfigurations; + try { + environmentConfigurations = JSON.parse(environmentConfigs); + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error(`JSON syntax error in environment file: ${e}`); + } else { + throw e; + } + } + 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 = ['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 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 { valid_configs: validConfigurations, errors: errors }; +} 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)); +} diff --git a/tests/integration/capabilities.integration.test.ts b/tests/integration/capabilities.integration.test.ts index ee3b9fa..bb43d53 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'; @@ -26,13 +30,12 @@ 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; } (skip ? describe.skip : describe)('Capabilities Integration Tests', () => { - let authClient: ManagedAuthClient; let metricsClient: MetricsApiClient; let logsClient: LogsApiClient; let eventsClient: EventsApiClient; @@ -40,27 +43,33 @@ 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 = getManagedEnvironmentConfig(); + const config = getManagedEnvironmentConfigs(); + const validEnvironments = validateEnvironments(config); - 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); + 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); + 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 +78,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 +86,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 +102,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,11 +112,12 @@ 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 }); - 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 responses = await metricsClient.listAvailableMetrics({ pageSize: 2 }, 'testAlias'); + 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); @@ -113,10 +126,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,28 +140,37 @@ 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, - }); - - expect(result.metrics?.length).toBeGreaterThan(0); - result.metrics?.forEach((metric: any) => { + const results = await metricsClient.listAvailableMetrics( + { + metadataSelector: 'unit("Percent")', + pageSize: 5, + }, + 'testAlias', + ); + const result = results.get('testAlias'); + + expect(result?.metrics?.length).toBeGreaterThan(0); + result?.metrics?.forEach((metric: any) => { expect(metric.unit).toEqual('Percent'); }); }, 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.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. @@ -156,15 +181,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.get('testAlias'); + + expect(responses).toBeDefined(); expect(typeof result).toBe('string'); expect(response?.results?.length).toBeGreaterThan(0); expect(result).toMatch(/\[(ERROR|WARNING|INFO)\]/); @@ -173,11 +202,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,13 +220,15 @@ 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 eventId = events.events ? events.events[0].eventId : undefined; + const responses = await eventsClient.queryEvents({ from: 'now-24h', to: 'now', pageSize: 1 }, 'testAlias'); + const events = responses.get('testAlias'); + + const eventId = events?.events ? events?.events[0].eventId : undefined; if (eventId == undefined) { 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'); @@ -205,7 +239,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 +249,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,42 +260,53 @@ 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.get('testAlias'); + 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 entityId = entities.entities ? entities.entities[0].entityId : undefined; + const responses = await entitiesClient.queryEntities( + { + entitySelector: 'type(SERVICE)', + pageSize: 1, + }, + 'testAlias', + ); + + const entities = responses.get('testAlias'); + + const entityId = entities?.entities ? entities?.entities[0].entityId : undefined; if (entityId == undefined) { 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'); @@ -272,46 +317,54 @@ 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.get('testAlias'); + + expect(response?.totalCount).toBeDefined(); + expect(response).toBeDefined(); }, 30000); it('should get problem details', async () => { - const problems = await problemsClient.listProblems({ pageSize: 1 }); - const problemId = problems.problems ? problems.problems[0].problemId : undefined; + const responses = await problemsClient.listProblems({ pageSize: 1 }, 'testAlias'); + const problems = responses.get('testAlias'); + + 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'); - expect(response.totalCount).toBeDefined(); + const response = responses.get('testAlias'); + + expect(response?.totalCount).toBeDefined(); expect(result).toContain('securityProblemId:'); expect(result).toContain('displayId:'); expect(result).toContain('status:'); @@ -320,7 +373,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,17 +389,18 @@ 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 sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; + const list_responses = await sloClient.listSlos(undefined, 'testAlias'); + 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'); 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,8 +410,10 @@ 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 sloId = slos.slo && slos.slo.length > 0 ? slos.slo[0].id : undefined; + const list_responses = await sloClient.listSlos(undefined, 'testAlias'); + 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'); @@ -367,7 +423,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 +440,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 +457,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 +467,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 +476,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/managed-auth.integration.test.ts b/tests/integration/managed-auth.integration.test.ts index a56d212..a0f2543 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,16 @@ 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, + minimum_version: '1.328.0', }); }); @@ -45,7 +50,7 @@ if (!process.env.DT_MANAGED_ENVIRONMENT || !process.env.DT_API_ENDPOINT_URL || ! 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); }); diff --git a/tests/integration/proxy.integration.test.ts b/tests/integration/proxy.integration.test.ts index 26ab8f8..3c5c825 100644 --- a/tests/integration/proxy.integration.test.ts +++ b/tests/integration/proxy.integration.test.ts @@ -49,13 +49,15 @@ 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, + minimum_version: '1.328.0', }); + const response = await client.makeRequest('/anything/mypath'); console.log(`response: ${JSON.stringify(response)}`); 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(); }); });