diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index d6509b694a1d..e3cfbf43f841 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -17,10 +17,11 @@ import { import { ApmConfiguration } from './config'; -const initialEnv = { ...process.env }; - describe('ApmConfiguration', () => { beforeEach(() => { + // start with an empty env to avoid CI from spoiling snapshots, env is unique for each jest file + process.env = {}; + packageMock.raw = { version: '8.0.0', build: { @@ -30,7 +31,6 @@ describe('ApmConfiguration', () => { }); afterEach(() => { - process.env = { ...initialEnv }; resetAllMocks(); }); @@ -46,7 +46,7 @@ describe('ApmConfiguration', () => { it('sets the git revision from `git rev-parse` command in non distribution mode', () => { gitRevExecMock.mockReturnValue('some-git-rev'); const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName').globalLabels.git_rev).toBe('some-git-rev'); + expect(config.getConfig('serviceName').globalLabels?.git_rev).toBe('some-git-rev'); }); it('sets the git revision from `pkg.build.sha` in distribution mode', () => { @@ -58,13 +58,13 @@ describe('ApmConfiguration', () => { }, }; const config = new ApmConfiguration(mockedRootDir, {}, true); - expect(config.getConfig('serviceName').globalLabels.git_rev).toBe('distribution-sha'); + expect(config.getConfig('serviceName').globalLabels?.git_rev).toBe('distribution-sha'); }); it('reads the kibana uuid from the uuid file', () => { readUuidFileMock.mockReturnValue('instance-uuid'); const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName').globalLabels.kibana_uuid).toBe('instance-uuid'); + expect(config.getConfig('serviceName').globalLabels?.kibana_uuid).toBe('instance-uuid'); }); it('uses the uuid from the kibana config if present', () => { @@ -75,23 +75,51 @@ describe('ApmConfiguration', () => { }, }; const config = new ApmConfiguration(mockedRootDir, kibanaConfig, false); - expect(config.getConfig('serviceName').globalLabels.kibana_uuid).toBe('uuid-from-config'); + expect(config.getConfig('serviceName').globalLabels?.kibana_uuid).toBe('uuid-from-config'); }); - it('uses the correct default config depending on the `isDistributable` parameter', () => { + it('overrides metricsInterval, breakdownMetrics, captureHeaders, and captureBody when `isDistributable` is true', () => { let config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - breakdownMetrics: true, - }) - ); + expect(config.getConfig('serviceName')).toMatchInlineSnapshot(` + Object { + "active": false, + "breakdownMetrics": true, + "captureSpanStackTraces": false, + "centralConfig": false, + "environment": "development", + "globalLabels": Object {}, + "logUncaughtExceptions": true, + "metricsInterval": "30s", + "secretToken": "ZQHYvrmXEx04ozge8F", + "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "serviceName": "serviceName", + "serviceVersion": "8.0.0", + "transactionSampleRate": 1, + } + `); config = new ApmConfiguration(mockedRootDir, {}, true); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - breakdownMetrics: false, - }) - ); + expect(config.getConfig('serviceName')).toMatchInlineSnapshot(` + Object { + "active": false, + "breakdownMetrics": false, + "captureBody": "off", + "captureHeaders": false, + "captureSpanStackTraces": false, + "centralConfig": false, + "environment": "development", + "globalLabels": Object { + "git_rev": "sha", + }, + "logUncaughtExceptions": true, + "metricsInterval": "120s", + "secretToken": "ZQHYvrmXEx04ozge8F", + "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "serviceName": "serviceName", + "serviceVersion": "8.0.0", + "transactionSampleRate": 1, + } + `); }); it('loads the configuration from the kibana config file', () => { @@ -119,7 +147,7 @@ describe('ApmConfiguration', () => { active: true, serverUrl: 'https://dev-url.co', }; - const config = new ApmConfiguration(mockedRootDir, {}, true); + const config = new ApmConfiguration(mockedRootDir, {}, false); expect(config.getConfig('serviceName')).toEqual( expect.objectContaining({ active: true, @@ -128,7 +156,20 @@ describe('ApmConfiguration', () => { ); }); - it('respect the precedence of the dev config', () => { + it('does not load the configuration from the dev config in distributable', () => { + devConfigMock.raw = { + active: true, + serverUrl: 'https://dev-url.co', + }; + const config = new ApmConfiguration(mockedRootDir, {}, true); + expect(config.getConfig('serviceName')).toEqual( + expect.objectContaining({ + active: false, + }) + ); + }); + + it('overwrites the standard config file with the dev config', () => { const kibanaConfig = { elastic: { apm: { @@ -142,7 +183,7 @@ describe('ApmConfiguration', () => { active: true, serverUrl: 'https://dev-url.co', }; - const config = new ApmConfiguration(mockedRootDir, kibanaConfig, true); + const config = new ApmConfiguration(mockedRootDir, kibanaConfig, false); expect(config.getConfig('serviceName')).toEqual( expect.objectContaining({ active: true, @@ -152,7 +193,7 @@ describe('ApmConfiguration', () => { ); }); - it('correctly sets environment', () => { + it('correctly sets environment by reading env vars', () => { delete process.env.ELASTIC_APM_ENVIRONMENT; delete process.env.NODE_ENV; diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index 8a3da17bc2bd..d33d4b71841c 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -7,45 +7,47 @@ */ import { join } from 'path'; -import { merge, get } from 'lodash'; +import { merge } from 'lodash'; import { execSync } from 'child_process'; // deep import to avoid loading the whole package import { getDataPath } from '@kbn/utils/target/path'; import { readFileSync } from 'fs'; import { ApmAgentConfig } from './types'; -const getDefaultConfig = (isDistributable: boolean): ApmAgentConfig => { - // https://www.elastic.co/guide/en/apm/agent/nodejs/current/configuration.html +// https://www.elastic.co/guide/en/apm/agent/nodejs/current/configuration.html +const DEFAULT_CONFIG: ApmAgentConfig = { + active: false, + environment: 'development', + logUncaughtExceptions: true, + globalLabels: {}, +}; - return { - active: process.env.ELASTIC_APM_ACTIVE === 'true' || false, - environment: process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV || 'development', +const CENTRALIZED_SERVICE_BASE_CONFIG: ApmAgentConfig = { + serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', - serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', + // The secretToken below is intended to be hardcoded in this file even though + // it makes it public. This is not a security/privacy issue. Normally we'd + // instead disable the need for a secretToken in the APM Server config where + // the data is transmitted to, but due to how it's being hosted, it's easier, + // for now, to simply leave it in. + secretToken: 'ZQHYvrmXEx04ozge8F', - // The secretToken below is intended to be hardcoded in this file even though - // it makes it public. This is not a security/privacy issue. Normally we'd - // instead disable the need for a secretToken in the APM Server config where - // the data is transmitted to, but due to how it's being hosted, it's easier, - // for now, to simply leave it in. - secretToken: 'ZQHYvrmXEx04ozge8F', + centralConfig: false, + metricsInterval: '30s', + captureSpanStackTraces: false, + transactionSampleRate: 1.0, + breakdownMetrics: true, +}; - logUncaughtExceptions: true, - globalLabels: {}, - centralConfig: false, - metricsInterval: isDistributable ? '120s' : '30s', - captureSpanStackTraces: false, - transactionSampleRate: process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE - ? parseFloat(process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE) - : 1.0, - - // Can be performance intensive, disabling by default - breakdownMetrics: isDistributable ? false : true, - }; +const CENTRALIZED_SERVICE_DIST_CONFIG: ApmAgentConfig = { + metricsInterval: '120s', + captureBody: 'off', + captureHeaders: false, + breakdownMetrics: false, }; export class ApmConfiguration { - private baseConfig?: any; + private baseConfig?: ApmAgentConfig; private kibanaVersion: string; private pkgBuild: Record; @@ -69,52 +71,77 @@ export class ApmConfiguration { private getBaseConfig() { if (!this.baseConfig) { - const apmConfig = merge( - getDefaultConfig(this.isDistributable), + this.baseConfig = merge( + { + serviceVersion: this.kibanaVersion, + }, + DEFAULT_CONFIG, + this.getUuidConfig(), + this.getGitConfig(), + this.getCiConfig(), this.getConfigFromKibanaConfig(), this.getDevConfig(), - this.getDistConfig(), - this.getCIConfig() + this.getConfigFromEnv() ); - const rev = this.getGitRev(); - if (rev !== null) { - apmConfig.globalLabels.git_rev = rev; - } + /** + * When the user doesn't override the serverUrl we define our central APM service + * as the serverUrl along with a few other overrides to prevent potentially + * sensitive data from being sent to this service. + */ + const centralizedConfig = this.isDistributable + ? merge({}, CENTRALIZED_SERVICE_BASE_CONFIG, CENTRALIZED_SERVICE_DIST_CONFIG) + : CENTRALIZED_SERVICE_BASE_CONFIG; - const uuid = this.getKibanaUuid(); - if (uuid) { - apmConfig.globalLabels.kibana_uuid = uuid; + if ( + !this.baseConfig?.serverUrl || + this.baseConfig.serverUrl === centralizedConfig.serverUrl + ) { + this.baseConfig = merge(this.baseConfig, centralizedConfig); } - - apmConfig.serviceVersion = this.kibanaVersion; - this.baseConfig = apmConfig; } return this.baseConfig; } - private getConfigFromKibanaConfig(): ApmAgentConfig { - return get(this.rawKibanaConfig, 'elastic.apm', {}); - } + /** + * Override some config values when specific environment variables are used + */ + private getConfigFromEnv(): ApmAgentConfig { + const config: ApmAgentConfig = {}; - private getKibanaUuid() { - // try to access the `server.uuid` value from the config file first. - // if not manually defined, we will then read the value from the `{DATA_FOLDER}/uuid` file. - // note that as the file is created by the platform AFTER apm init, the file - // will not be present at first startup, but there is nothing we can really do about that. - if (get(this.rawKibanaConfig, 'server.uuid')) { - return this.rawKibanaConfig.server.uuid; + if (process.env.ELASTIC_APM_ACTIVE === 'true') { + config.active = true; } - const dataPath: string = get(this.rawKibanaConfig, 'path.data') || getDataPath(); - try { - const filename = join(dataPath, 'uuid'); - return readFileSync(filename, 'utf-8'); - } catch (e) {} // eslint-disable-line no-empty + if (process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV) { + config.environment = process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV; + } + + if (process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE) { + config.transactionSampleRate = parseFloat(process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE); + } + + return config; } + /** + * Get the elastic.apm configuration from the --config file, supersedes the + * default config. + */ + private getConfigFromKibanaConfig(): ApmAgentConfig { + return this.rawKibanaConfig?.elastic?.apm ?? {}; + } + + /** + * Get the configuration from the apm.dev.js file, supersedes config + * from the --config file, disabled when running the distributable + */ private getDevConfig(): ApmAgentConfig { + if (this.isDistributable) { + return {}; + } + try { const apmDevConfigPath = join(this.rootDir, 'config', 'apm.dev.js'); return require(apmDevConfigPath); @@ -123,20 +150,51 @@ export class ApmConfiguration { } } - /** Config keys that cannot be overridden in production builds */ - private getDistConfig(): ApmAgentConfig { - if (!this.isDistributable) { - return {}; + /** + * Determine the Kibana UUID, initialized the value of `globalLabels.kibana_uuid` + * when the UUID can be determined. + */ + private getUuidConfig(): ApmAgentConfig { + // try to access the `server.uuid` value from the config file first. + // if not manually defined, we will then read the value from the `{DATA_FOLDER}/uuid` file. + // note that as the file is created by the platform AFTER apm init, the file + // will not be present at first startup, but there is nothing we can really do about that. + const uuidFromConfig = this.rawKibanaConfig?.server?.uuid; + if (uuidFromConfig) { + return { + globalLabels: { + kibana_uuid: uuidFromConfig, + }, + }; } - return { - // Headers & body may contain sensitive info - captureHeaders: false, - captureBody: 'off', - }; + const dataPath: string = this.rawKibanaConfig?.path?.data || getDataPath(); + try { + const filename = join(dataPath, 'uuid'); + const uuid = readFileSync(filename, 'utf-8'); + if (!uuid) { + return {}; + } + + return { + globalLabels: { + kibana_uuid: uuid, + }, + }; + } catch (e) { + if (e.code === 'ENOENT') { + return {}; + } + + throw e; + } } - private getCIConfig(): ApmAgentConfig { + /** + * When running Kibana with ELASTIC_APM_ENVIRONMENT=ci we attempt to grab + * some environment variables we populate in CI related to the build under test + */ + private getCiConfig(): ApmAgentConfig { if (process.env.ELASTIC_APM_ENVIRONMENT !== 'ci') { return {}; } @@ -152,17 +210,30 @@ export class ApmConfiguration { }; } - private getGitRev() { + /** + * When running from the distributable pull the build sha from the package.json + * file. Otherwise attempt to read the current HEAD sha using `git`. + */ + private getGitConfig() { if (this.isDistributable) { - return this.pkgBuild.sha; + return { + globalLabels: { + git_rev: this.pkgBuild.sha, + }, + }; } + try { - return execSync('git rev-parse --short HEAD', { - encoding: 'utf-8' as BufferEncoding, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (e) { - return null; + return { + globalLabels: { + git_rev: execSync('git rev-parse --short HEAD', { + encoding: 'utf-8' as BufferEncoding, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(), + }, + }; + } catch { + return {}; } } } diff --git a/packages/kbn-apm-config-loader/src/types.ts b/packages/kbn-apm-config-loader/src/types.ts index f79db252edde..2e80631638e4 100644 --- a/packages/kbn-apm-config-loader/src/types.ts +++ b/packages/kbn-apm-config-loader/src/types.ts @@ -10,4 +10,20 @@ // but it's not exported, and using ts tricks to retrieve the type via Parameters[0] // causes errors in the generated .d.ts file because of esModuleInterop and the fact that the apm module // is just exporting an instance of the `ApmAgent` type. -export type ApmAgentConfig = Record; +export interface ApmAgentConfig { + active?: boolean; + environment?: string; + serviceName?: string; + serviceVersion?: string; + serverUrl?: string; + secretToken?: string; + logUncaughtExceptions?: boolean; + globalLabels?: Record; + centralConfig?: boolean; + metricsInterval?: string; + captureSpanStackTraces?: boolean; + transactionSampleRate?: number; + breakdownMetrics?: boolean; + captureHeaders?: boolean; + captureBody?: 'off' | 'all' | 'errors' | 'transactions'; +}