diff --git a/packages/collector/src/announceCycle/unannounced.js b/packages/collector/src/announceCycle/unannounced.js index ecb88f67ef..016db6cdfd 100644 --- a/packages/collector/src/announceCycle/unannounced.js +++ b/packages/collector/src/announceCycle/unannounced.js @@ -48,6 +48,19 @@ const maxRetryDelay = 60 * 1000; // one minute * @property {boolean} [span-batching-enabled] * @property {import('@instana/core/src/config/types').Disable} [disable] * @property {StackTraceConfig} [global] + * @property {HTTPTracingConfig} [http] + */ + +/** + * @typedef {Object} HTTPExitConfig + * @property {boolean} [classify-all-4xx-as-errors] + * @property {Array} [classify-as-errors] + */ + +/** + * @typedef {Object} HTTPTracingConfig + * @property {Array.} [extra-http-headers] + * @property {HTTPExitConfig} [exit] */ /** @@ -132,6 +145,7 @@ function tryToAnnounce(ctx, retryDelay = initialRetryDelay) { function applyAgentConfiguration(agentResponse) { applySecretsConfiguration(agentResponse); applyExtraHttpHeaderConfiguration(agentResponse); + applyHttpExitConfiguration(agentResponse); applyKafkaTracingConfiguration(agentResponse); applyOtlpExporterConfiguration(agentResponse); applySpanBatchingConfiguration(agentResponse); @@ -288,7 +302,7 @@ function applyStackTraceConfiguration(agentResponse) { ensureNestedObjectExists(agentOpts.config, ['tracing', 'global']); if (globalConfig['stack-trace'] !== undefined) { - const stackTraceModeValidation = coreConfig.validate.validateStackTraceMode(globalConfig['stack-trace']); + const stackTraceModeValidation = coreConfig.validators.validateStackTraceMode(globalConfig['stack-trace']); if (stackTraceModeValidation.isValid) { const normalizedStackTrace = coreConfig.normalizers.stackTrace.normalizeStackTraceModeFromAgent( globalConfig['stack-trace'] @@ -302,7 +316,9 @@ function applyStackTraceConfiguration(agentResponse) { } if (globalConfig['stack-trace-length'] !== undefined) { - const stackTraceLengthValidation = coreConfig.validate.validateStackTraceLength(globalConfig['stack-trace-length']); + const stackTraceLengthValidation = coreConfig.validators.validateStackTraceLength( + globalConfig['stack-trace-length'] + ); if (stackTraceLengthValidation.isValid) { const normalizedStackTraceLength = coreConfig.normalizers.stackTrace.normalizeStackTraceLengthFromAgent( globalConfig['stack-trace-length'] @@ -333,6 +349,29 @@ function applyDisableConfiguration(agentResponse) { tracing: { disable: disablingConfig } }).value; } + +/** + * @param {AgentAnnounceResponse} agentResponse + */ +function applyHttpExitConfiguration(agentResponse) { + const exitConfig = agentResponse?.tracing?.http?.exit; + if (!exitConfig) { + return; + } + + ensureNestedObjectExists(agentOpts.config, ['tracing', 'http', 'exit']); + + const classifyAll4xxAsErrors = coreConfig.validators.booleanValidator(exitConfig['classify-all-4xx-as-errors']); + if (classifyAll4xxAsErrors !== undefined) { + agentOpts.config.tracing.http.exit.classifyAll4xxAsErrors = classifyAll4xxAsErrors; + } + + const classifyAsErrors = coreConfig.validators.httpExitErrorCodeValidator(exitConfig['classify-as-errors']); + if (classifyAsErrors !== undefined) { + agentOpts.config.tracing.http.exit.classifyAsErrors = classifyAsErrors; + } +} + module.exports = { init, diff --git a/packages/collector/src/types/collector.d.ts b/packages/collector/src/types/collector.d.ts index 18c03e1e76..436ed0e4e4 100644 --- a/packages/collector/src/types/collector.d.ts +++ b/packages/collector/src/types/collector.d.ts @@ -5,6 +5,10 @@ export interface AgentConfig { tracing?: { http?: { extraHttpHeadersToCapture?: string[]; + exit?: { + classifyAll4xxAsErrors?: boolean; + classifyAsErrors?: number[]; + }; }; kafka?: { traceCorrelation?: boolean; diff --git a/packages/collector/test/apps/agentStub.js b/packages/collector/test/apps/agentStub.js index f8be8c4b96..02ba2ac86f 100644 --- a/packages/collector/test/apps/agentStub.js +++ b/packages/collector/test/apps/agentStub.js @@ -47,6 +47,7 @@ const ignoreEndpoints = process.env.IGNORE_ENDPOINTS && JSON.parse(process.env.I const disable = process.env.AGENT_DISABLE_TRACING && JSON.parse(process.env.AGENT_DISABLE_TRACING); const stackTraceConfig = process.env.STACK_TRACE_CONFIG && JSON.parse(process.env.STACK_TRACE_CONFIG); const otlpExporter = process.env.OTLP_EXPORTER && JSON.parse(process.env.OTLP_EXPORTER); +const httpExitConfig = process.env.AGENT_STUB_HTTP_EXIT_CONFIG && JSON.parse(process.env.AGENT_STUB_HTTP_EXIT_CONFIG); const uuids = {}; const agentLogs = []; @@ -126,7 +127,8 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { ignoreEndpoints || disable || stackTraceConfig || - otlpExporter + otlpExporter || + httpExitConfig ) { response.tracing = {}; @@ -157,6 +159,10 @@ app.put('/com.instana.plugin.nodejs.discovery', (req, res) => { if (otlpExporter) { response.tracing.otlp = otlpExporter; } + if (httpExitConfig) { + response.tracing.http = response.tracing.http || {}; + response.tracing.http.exit = httpExitConfig; + } } res.send(response); diff --git a/packages/collector/test/apps/agentStubControls.js b/packages/collector/test/apps/agentStubControls.js index 20d9af445b..fe23c1f180 100644 --- a/packages/collector/test/apps/agentStubControls.js +++ b/packages/collector/test/apps/agentStubControls.js @@ -71,6 +71,9 @@ class AgentStubControls { if (opts.otlpExporter) { env.OTLP_EXPORTER = JSON.stringify(opts.otlpExporter); } + if (opts.httpExitConfig) { + env.AGENT_STUB_HTTP_EXIT_CONFIG = JSON.stringify(opts.httpExitConfig); + } this.agentStub = spawn('node', [path.join(__dirname, 'agentStub.js')], { stdio: config.getAppStdio(), diff --git a/packages/collector/test/integration/currencies/protocols/http/client/clientApp.js b/packages/collector/test/integration/currencies/protocols/http/client/clientApp.js index 83a824b04a..f0afe4b63d 100644 --- a/packages/collector/test/integration/currencies/protocols/http/client/clientApp.js +++ b/packages/collector/test/integration/currencies/protocols/http/client/clientApp.js @@ -281,6 +281,22 @@ if (process.env.APP_USES_HTTPS === 'true') { }); } +app.get('/fetch-with-status', (req, res) => { + const status = req.query.status || '200'; + const options = { + hostname: 'localhost', + port: process.env.SERVER_PORT, + method: 'GET', + path: `/fetch-with-status?status=${status}` + }; + const req_ = httpModule.request(options, response => { + response.resume(); + response.on('end', () => res.status(response.statusCode).send()); + }); + req_.on('error', err => res.status(503).send(err.message)); + req_.end(); +}); + app.get('/matrix-params/:params', (req, res) => { res.sendStatus(200); }); diff --git a/packages/collector/test/integration/currencies/protocols/http/client/serverApp.js b/packages/collector/test/integration/currencies/protocols/http/client/serverApp.js index 6759d020a6..590ba6a8fa 100644 --- a/packages/collector/test/integration/currencies/protocols/http/client/serverApp.js +++ b/packages/collector/test/integration/currencies/protocols/http/client/serverApp.js @@ -60,6 +60,11 @@ app.get('/timeout', (req, res) => { }, 10000); }); +app.all('/fetch-with-status', (req, res) => { + const status = parseInt(req.query.status, 10) || 200; + res.status(status).json({ status }); +}); + app.put('/continue', (req, res) => { // Node http server will automatically send 100 Continue when it receives a request with an "Expect: 100-continue" // header present, unless we override the 'checkContinue' listener. For our test case, the default behaviour is just diff --git a/packages/collector/test/integration/currencies/protocols/http/client/test_base.js b/packages/collector/test/integration/currencies/protocols/http/client/test_base.js index 7a7e812f32..c6e2309d82 100644 --- a/packages/collector/test/integration/currencies/protocols/http/client/test_base.js +++ b/packages/collector/test/integration/currencies/protocols/http/client/test_base.js @@ -578,6 +578,282 @@ module.exports = function (name, version, isLatest) { }); }); + describe('HTTP client exit span error classification', function () { + describe('classifyAll4xxAsErrors: true — all 4xx are errors', function () { + let serverControls; + let clientControls; + + before(async () => { + serverControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + useGlobalAgent: true, + appUsesHttps: false + }); + clientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + useGlobalAgent: true, + appUsesHttps: false, + env: { + ...commonEnv, + SERVER_PORT: serverControls.getPort(), + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS: 'true' + } + }); + await serverControls.startAndWaitForAgentConnection(); + await clientControls.startAndWaitForAgentConnection(); + }); + + after(() => Promise.all([serverControls.stop(), clientControls.stop()])); + + beforeEach(() => globalAgent.instance.clearReceivedTraceData()); + + afterEach(() => Promise.all([serverControls.clearIpcMessages(), clientControls.clearIpcMessages()])); + + it('400 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 404 }); + }); + }); + }); + + describe('classifyAsErrors: [400] — only 400 is an error; takes precedence over classifyAll4xxAsErrors', function () { + let serverControls; + let clientControls; + + before(async () => { + serverControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + useGlobalAgent: true, + appUsesHttps: false + }); + clientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + useGlobalAgent: true, + appUsesHttps: false, + env: { + ...commonEnv, + SERVER_PORT: serverControls.getPort(), + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS: '400', + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS: 'true' + } + }); + await serverControls.startAndWaitForAgentConnection(); + await clientControls.startAndWaitForAgentConnection(); + }); + + after(() => Promise.all([serverControls.stop(), clientControls.stop()])); + + beforeEach(() => globalAgent.instance.clearReceivedTraceData()); + + afterEach(() => Promise.all([serverControls.clearIpcMessages(), clientControls.clearIpcMessages()])); + + it('400 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should not set ec (ec = 0), verifying classifyAsErrors takes precedence over classifyAll4xxAsErrors', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 404 }); + }); + }); + + it('500 response should always set ec = 1 regardless of HTTP exit configuration', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=500', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 500 }); + }); + }); + }); + + describe('config applied via agent — classifyAll4xxAsErrors: true', function () { + const customAgentControls = new AgentStubControls(); + let serverControls; + let clientControls; + + before(async () => { + await customAgentControls.startAgent({ + httpExitConfig: { + 'classify-all-4xx-as-errors': true + } + }); + + serverControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + agentControls: customAgentControls, + appUsesHttps: false + }); + clientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + agentControls: customAgentControls, + appUsesHttps: false, + env: { + ...commonEnv, + SERVER_PORT: serverControls.getPort() + } + }); + await serverControls.startAndWaitForAgentConnection(); + await clientControls.startAndWaitForAgentConnection(); + }); + + after(() => Promise.all([serverControls.stop(), clientControls.stop(), customAgentControls.stopAgent()])); + + beforeEach(() => customAgentControls.clearReceivedTraceData()); + + afterEach(() => Promise.all([serverControls.clearIpcMessages(), clientControls.clearIpcMessages()])); + + it('400 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 404 }); + }); + }); + + it('200 response should not set ec (ec = 0)', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=200', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 200 }); + }); + }); + }); + + describe('config applied via agent — classifyAsErrors: [400]', function () { + const customAgentControls = new AgentStubControls(); + let serverControls; + let clientControls; + + before(async () => { + await customAgentControls.startAgent({ + httpExitConfig: { + 'classify-as-errors': [400], + 'classify-all-4xx-as-errors': true + } + }); + + serverControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + agentControls: customAgentControls, + appUsesHttps: false + }); + clientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + agentControls: customAgentControls, + appUsesHttps: false, + env: { + ...commonEnv, + SERVER_PORT: serverControls.getPort() + } + }); + await serverControls.startAndWaitForAgentConnection(); + await clientControls.startAndWaitForAgentConnection(); + }); + + after(() => Promise.all([serverControls.stop(), clientControls.stop(), customAgentControls.stopAgent()])); + + beforeEach(() => customAgentControls.clearReceivedTraceData()); + + afterEach(() => Promise.all([serverControls.clearIpcMessages(), clientControls.clearIpcMessages()])); + + it('400 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should not set ec (ec = 0), verifying classifyAsErrors takes precedence over classifyAll4xxAsErrors', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 404 }); + }); + }); + }); + }); + + describe('default behaviour', function () { + let serverControls; + let clientControls; + + before(async () => { + serverControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + useGlobalAgent: true, + appUsesHttps: false + }); + clientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + useGlobalAgent: true, + appUsesHttps: false, + env: { + ...commonEnv, + SERVER_PORT: serverControls.getPort() + } + }); + await serverControls.startAndWaitForAgentConnection(); + await clientControls.startAndWaitForAgentConnection(); + }); + + after(() => Promise.all([serverControls.stop(), clientControls.stop()])); + + beforeEach(() => globalAgent.instance.clearReceivedTraceData()); + + afterEach(() => Promise.all([serverControls.clearIpcMessages(), clientControls.clearIpcMessages()])); + + it('400 response should not set ec (ec = 0)', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 400 }); + }); + }); + + it('500 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=500', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 500 }); + }); + }); + }); + function registerTests(appUsesHttps) { let serverControls; let clientControls; @@ -1319,4 +1595,13 @@ module.exports = function (name, version, isLatest) { expect(span.data.http.params).to.not.exist; } } + + function verifyHttpExitEc({ spans, expectedEc, expectedStatus }) { + return expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.data.http.status).to.equal(expectedStatus), + span => expect(span.ec).to.equal(expectedEc) + ]); + } }; diff --git a/packages/collector/test/integration/currencies/protocols/http/native_fetch/clientApp.js b/packages/collector/test/integration/currencies/protocols/http/native_fetch/clientApp.js index 276149def3..a9f827cbee 100644 --- a/packages/collector/test/integration/currencies/protocols/http/native_fetch/clientApp.js +++ b/packages/collector/test/integration/currencies/protocols/http/native_fetch/clientApp.js @@ -85,6 +85,17 @@ app.get('/fetch', async (req, res) => { } }); +app.get('/fetch-with-status', async (req, res) => { + const status = req.query.status || '200'; + let response; + try { + response = await fetch(`${baseUrl}/fetch-with-status?status=${status}`); + res.status(response.status).send(); + } catch (e) { + res.status(503).send(e.message); + } +}); + class CustomResource { constructor(url) { this.url = url; diff --git a/packages/collector/test/integration/currencies/protocols/http/native_fetch/serverApp.js b/packages/collector/test/integration/currencies/protocols/http/native_fetch/serverApp.js index cea04c873a..e793ab3ae8 100644 --- a/packages/collector/test/integration/currencies/protocols/http/native_fetch/serverApp.js +++ b/packages/collector/test/integration/currencies/protocols/http/native_fetch/serverApp.js @@ -52,6 +52,11 @@ app.all('/fetch', (req, res) => { } }); +app.all('/fetch-with-status', (req, res) => { + const status = parseInt(req.query.status, 10) || 200; + res.status(status).json({ status }); +}); + app.listen(port, () => { log(`Listening on port ${port}.`); }); diff --git a/packages/collector/test/integration/currencies/protocols/http/native_fetch/test_base.js b/packages/collector/test/integration/currencies/protocols/http/native_fetch/test_base.js index 0e2e56f433..477d7ba32d 100644 --- a/packages/collector/test/integration/currencies/protocols/http/native_fetch/test_base.js +++ b/packages/collector/test/integration/currencies/protocols/http/native_fetch/test_base.js @@ -12,6 +12,7 @@ const { delay, expectExactlyOneMatching, retry } = require('@_local/core/test/te const ProcessControls = require('@_local/collector/test/test_util/ProcessControls'); const globalAgent = require('@_local/collector/test/globalAgent'); const instrumentation = require('@_local/core/src/tracing/instrumentation/protocols/nativeFetch'); +const { AgentStubControls } = require('@_local/collector/test/apps/agentStubControls'); module.exports = function (name, version, isLatest) { this.timeout(config.getTestTimeout() * 2); @@ -610,6 +611,252 @@ module.exports = function (name, version, isLatest) { }); }); + describe('HTTP client exit span error classification', () => { + describe('classifyAll4xxAsErrors: true — all 4xx are errors', () => { + let httpExitServerControls; + let httpExitClientControls; + + before(async () => { + httpExitServerControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + useGlobalAgent: true + }); + httpExitClientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + useGlobalAgent: true, + env: { + ...commonEnv, + SERVER_PORT: httpExitServerControls.port, + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS: 'true' + } + }); + await httpExitServerControls.startAndWaitForAgentConnection(); + await httpExitClientControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await httpExitClientControls.stop(); + await httpExitServerControls.stop(); + }); + + beforeEach(() => globalAgent.instance.clearReceivedTraceData()); + + afterEach(async () => { + await httpExitServerControls.clearIpcMessages(); + await httpExitClientControls.clearIpcMessages(); + }); + + it('400 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 404 }); + }); + }); + }); + + describe('classifyAsErrors: [400] — only 400 is an error; takes precedence over classifyAll4xxAsErrors', () => { + let httpExitServerControls; + let httpExitClientControls; + + before(async () => { + httpExitServerControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + useGlobalAgent: true + }); + httpExitClientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + useGlobalAgent: true, + env: { + ...commonEnv, + SERVER_PORT: httpExitServerControls.port, + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS: '400', + INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS: 'true' + } + }); + await httpExitServerControls.startAndWaitForAgentConnection(); + await httpExitClientControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await httpExitClientControls.stop(); + await httpExitServerControls.stop(); + }); + + beforeEach(() => globalAgent.instance.clearReceivedTraceData()); + + afterEach(async () => { + await httpExitServerControls.clearIpcMessages(); + await httpExitClientControls.clearIpcMessages(); + }); + + it('400 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should not set ec (ec = 0), verifying classifyAsErrors takes precedence over classifyAll4xxAsErrors', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 404 }); + }); + }); + + it('500 response should always set ec = 1 regardless of HTTP exit configuration', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=500', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 500 }); + }); + }); + }); + + describe('config applied via agent — classifyAll4xxAsErrors: true', () => { + const customAgentControls = new AgentStubControls(); + let httpExitServerControls; + let httpExitClientControls; + + before(async () => { + await customAgentControls.startAgent({ + httpExitConfig: { + 'classify-all-4xx-as-errors': true + } + }); + + httpExitServerControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + agentControls: customAgentControls + }); + httpExitClientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + agentControls: customAgentControls, + env: { + ...commonEnv, + SERVER_PORT: httpExitServerControls.port + } + }); + await httpExitServerControls.startAndWaitForAgentConnection(); + await httpExitClientControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await httpExitClientControls.stop(); + await httpExitServerControls.stop(); + await customAgentControls.stopAgent(); + }); + + beforeEach(() => customAgentControls.clearReceivedTraceData()); + + afterEach(async () => { + await httpExitServerControls.clearIpcMessages(); + await httpExitClientControls.clearIpcMessages(); + }); + + it('400 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 404 }); + }); + }); + + it('200 response should not set ec (ec = 0)', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=200', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 200 }); + }); + }); + }); + + describe('config applied via agent — classifyAsErrors: [400]', () => { + const customAgentControls = new AgentStubControls(); + let httpExitServerControls; + let httpExitClientControls; + + before(async () => { + await customAgentControls.startAgent({ + httpExitConfig: { + 'classify-as-errors': [400], + 'classify-all-4xx-as-errors': true + } + }); + + httpExitServerControls = new ProcessControls({ + dirname: __dirname, + appName: 'serverApp', + agentControls: customAgentControls + }); + httpExitClientControls = new ProcessControls({ + dirname: __dirname, + appName: 'clientApp', + agentControls: customAgentControls, + env: { + ...commonEnv, + SERVER_PORT: httpExitServerControls.port + } + }); + await httpExitServerControls.startAndWaitForAgentConnection(); + await httpExitClientControls.startAndWaitForAgentConnection(); + }); + + after(async () => { + await httpExitClientControls.stop(); + await httpExitServerControls.stop(); + await customAgentControls.stopAgent(); + }); + + beforeEach(() => customAgentControls.clearReceivedTraceData()); + + afterEach(async () => { + await httpExitServerControls.clearIpcMessages(); + await httpExitClientControls.clearIpcMessages(); + }); + + it('400 response should set ec = 1', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 400 }); + }); + }); + + it('404 response should not set ec (ec = 0), verifying classifyAsErrors takes precedence over classifyAll4xxAsErrors', async () => { + await httpExitClientControls.sendRequest({ path: '/fetch-with-status?status=404', simple: false }); + await retry(async () => { + const spans = await customAgentControls.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 404 }); + }); + }); + }); + }); + it('must suppress tracing ', async () => { const response = await clientControls.sendRequest({ path: constructPath({}), @@ -644,6 +891,22 @@ module.exports = function (name, version, isLatest) { }); }); + it('400 response should not set ec (ec = 0)', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=400', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 0, expectedStatus: 400 }); + }); + }); + + it('500 response should set ec = 1', async () => { + await clientControls.sendRequest({ path: '/fetch-with-status?status=500', simple: false }); + await retry(async () => { + const spans = await globalAgent.instance.getSpans(); + verifyHttpExitEc({ spans, expectedEc: 1, expectedStatus: 500 }); + }); + }); + function constructPath({ basePath = '/fetch', resourceType = 'string', @@ -875,4 +1138,13 @@ module.exports = function (name, version, isLatest) { expect(span.data.http.header).to.not.exist; } } + + function verifyHttpExitEc({ spans, expectedEc, expectedStatus }) { + return expectExactlyOneMatching(spans, [ + span => expect(span.n).to.equal('node.http.client'), + span => expect(span.k).to.equal(constants.EXIT), + span => expect(span.data.http.status).to.equal(expectedStatus), + span => expect(span.ec).to.equal(expectedEc) + ]); + } }; diff --git a/packages/collector/test/unit/src/announceCycle/unannounced.test.js b/packages/collector/test/unit/src/announceCycle/unannounced.test.js index ebf518041d..d73c13ea63 100644 --- a/packages/collector/test/unit/src/announceCycle/unannounced.test.js +++ b/packages/collector/test/unit/src/announceCycle/unannounced.test.js @@ -1062,6 +1062,215 @@ describe('unannounced state', () => { }); }); + describe('HTTP exit configuration', () => { + it('should apply classify-all-4xx-as-errors configuration', done => { + prepareAnnounceResponse({ + tracing: { + http: { + exit: { + 'classify-all-4xx-as-errors': true + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: true + } + } + } + }); + done(); + } + }); + }); + + it('should apply classify-as-errors configuration', done => { + prepareAnnounceResponse({ + tracing: { + http: { + exit: { + 'classify-as-errors': [401, 403] + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + exit: { + classifyAsErrors: [401, 403] + } + } + } + }); + done(); + } + }); + }); + + it('should apply both HTTP exit configuration options', done => { + prepareAnnounceResponse({ + tracing: { + http: { + exit: { + 'classify-all-4xx-as-errors': true, + 'classify-as-errors': [401, 403] + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: true, + classifyAsErrors: [401, 403] + } + } + } + }); + done(); + } + }); + }); + + it('should preserve extra HTTP header configuration when applying HTTP exit configuration', done => { + prepareAnnounceResponse({ + tracing: { + 'extra-http-headers': ['X-Test'], + http: { + exit: { + 'classify-all-4xx-as-errors': true, + 'classify-as-errors': [401, 403] + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + extraHttpHeadersToCapture: ['x-test'], + exit: { + classifyAll4xxAsErrors: true, + classifyAsErrors: [401, 403] + } + } + } + }); + done(); + } + }); + }); + + it('should not apply HTTP exit configuration when tracing.http is missing', done => { + prepareAnnounceResponse({ + tracing: {} + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + + it('should ignore invalid classify-as-errors values', done => { + prepareAnnounceResponse({ + tracing: { + http: { + exit: { + 'classify-as-errors': ['abc', 200, 401, 500] + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + exit: { + classifyAsErrors: [401] + } + } + } + }); + done(); + } + }); + }); + + it('should preserve existing HTTP exit configuration when only classify-all-4xx-as-errors is provided', done => { + agentOptsStub.config = { + tracing: { + http: { + exit: { + classifyAsErrors: [401] + } + } + } + }; + + prepareAnnounceResponse({ + tracing: { + http: { + exit: { + 'classify-all-4xx-as-errors': true + } + } + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({ + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: true, + classifyAsErrors: [401] + } + } + } + }); + done(); + } + }); + }); + + it('should ignore HTTP exit configuration when exit is missing', done => { + prepareAnnounceResponse({ + tracing: { + http: {} + } + }); + + unannouncedState.enter({ + transitionTo: () => { + expect(agentOptsStub.config).to.deep.equal({}); + done(); + } + }); + }); + }); + describe('OTLP exporter configuration', () => { it('should apply OTLP exporter configuration when enabled is true', done => { prepareAnnounceResponse({ diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index db9c4248b0..cc502d72bf 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -9,8 +9,8 @@ const normalizers = require('./normalizers'); const deepMerge = require('../util/deepMerge'); const { DEFAULT_STACK_TRACE_LENGTH, DEFAULT_STACK_TRACE_MODE, CONFIG_SOURCES } = require('../util/constants'); const util = require('./util'); -const validate = require('./validator'); -const { validateStackTraceMode, validateStackTraceLength } = validate; +const validators = require('./validator'); +const { validateStackTraceMode, validateStackTraceLength } = validators; // @typedef {{ [x: string]: any }} configMeta /** @type {configMeta} */ @@ -79,6 +79,13 @@ let currentConfig; /** * @typedef {Object} HTTPTracingOptions * @property {Array} [extraHttpHeadersToCapture] + * @property {HTTPExitTracingOptions} [exit] + */ + +/** + * @typedef {Object} HTTPExitTracingOptions + * @property {boolean} [classifyAll4xxAsErrors] + * @property {Array} [classifyAsErrors] */ /** @@ -156,7 +163,11 @@ let defaults = { transmissionDelay: 1000, initialTransmissionDelay: 1000, http: { - extraHttpHeadersToCapture: [] + extraHttpHeadersToCapture: [], + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } }, stackTrace: DEFAULT_STACK_TRACE_MODE, stackTraceLength: DEFAULT_STACK_TRACE_LENGTH, @@ -188,7 +199,7 @@ let defaults = { const validSecretsMatcherModes = ['equals-ignore-case', 'equals', 'contains-ignore-case', 'contains', 'regex', 'none']; module.exports.normalizers = normalizers; -module.exports.validate = validate; +module.exports.validators = validators; /** * @param {import('../core').GenericLogger} [_logger] @@ -197,6 +208,7 @@ module.exports.init = _logger => { logger = _logger; normalizers.init({ logger }); util.init(logger); + validators.init(logger); }; /** @@ -248,7 +260,7 @@ function normalizeServiceName({ userConfig = {}, defaultConfig = {}, finalConfig inCodeValue: userConfig.serviceName, defaultValue: defaultConfig.serviceName }, - [validate.stringValidator] + [validators.stringValidator] ); configStore.set('config.serviceName', { source }); @@ -266,7 +278,7 @@ function normalizePackageJsonPath({ userConfig = {}, defaultConfig = {}, finalCo inCodeValue: userConfig.packageJsonPath, defaultValue: defaultConfig.packageJsonPath }, - [validate.stringValidator] + [validators.stringValidator] ); configStore.set('config.packageJsonPath', { source }); @@ -293,7 +305,7 @@ function normalizeMetricsConfig({ userConfig = {}, defaultConfig = {}, finalConf inCodeValue: userMetrics?.transmissionDelay, defaultValue: defaultConfig.metrics.transmissionDelay }, - [validate.numberValidator] + [validators.numberValidator] ); finalConfig.metrics.transmissionDelay = transmissionDelay; @@ -320,7 +332,7 @@ function normalizeMetricsConfig({ userConfig = {}, defaultConfig = {}, finalConf inCodeValue: userMetrics?.timeBetweenHealthcheckCalls, defaultValue: defaultConfig.metrics.timeBetweenHealthcheckCalls }, - [validate.numberValidator] + [validators.numberValidator] ); finalConfig.metrics.timeBetweenHealthcheckCalls = healthcheckInterval; @@ -378,7 +390,7 @@ function normalizeTracingEnabled({ userConfig = {}, defaultConfig = {}, finalCon inCodeValue: userConfig.tracing.enabled, defaultValue: defaultConfig.tracing.enabled }, - [validate.booleanValidator] + [validators.booleanValidator] ); // The env var is TRACING_DISABLE, so we need to invert it when it comes from env @@ -405,7 +417,7 @@ function normalizeAllowRootExitSpan({ userConfig = {}, defaultConfig = {}, final inCodeValue: userConfig.tracing.allowRootExitSpan, defaultValue: defaultConfig.tracing.allowRootExitSpan }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.allowRootExitSpan', { source }); @@ -428,7 +440,7 @@ function normalizeUseOpentelemetry({ userConfig = {}, defaultConfig = {}, finalC inCodeValue: userConfig.tracing.useOpentelemetry, defaultValue: defaultConfig.tracing.useOpentelemetry }, - [validate.booleanValidator] + [validators.booleanValidator] ); // The env var is DISABLE_USE_OPENTELEMETRY, so we need to invert it when it comes from env @@ -460,7 +472,7 @@ function normalizeAutomaticTracingEnabled({ userConfig = {}, defaultConfig = {}, inCodeValue: userConfig.tracing.automaticTracingEnabled, defaultValue: defaultConfig.tracing.automaticTracingEnabled }, - [validate.booleanValidator] + [validators.booleanValidator] ); // The env var is DISABLE_AUTO_INSTR, so we need to invert it when it comes from env @@ -492,7 +504,7 @@ function normalizeActivateImmediately({ userConfig = {}, defaultConfig = {}, fin inCodeValue: userConfig.tracing.activateImmediately, defaultValue: defaultConfig.tracing.activateImmediately }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.activateImmediately', { source }); @@ -528,7 +540,7 @@ function normalizeTracingTransmission({ userConfig = {}, defaultConfig = {}, fin inCodeValue: userConfig.tracing.transmissionDelay, defaultValue: defaultConfig.tracing.transmissionDelay }, - [validate.numberValidator] + [validators.numberValidator] ); configStore.set('config.tracing.transmissionDelay', { source: tracingTransmissionDelaySource }); @@ -546,7 +558,7 @@ function normalizeTracingTransmission({ userConfig = {}, defaultConfig = {}, fin inCodeValue: userConfig.tracing.forceTransmissionStartingAt, defaultValue: defaultConfig.tracing.forceTransmissionStartingAt }, - [validate.numberValidator] + [validators.numberValidator] ); configStore.set('config.tracing.forceTransmissionStartingAt', { source: forceTransmissionStartingAtSource }); @@ -564,7 +576,7 @@ function normalizeTracingTransmission({ userConfig = {}, defaultConfig = {}, fin inCodeValue: userConfig.tracing.initialTransmissionDelay, defaultValue: defaultConfig.tracing.initialTransmissionDelay }, - [validate.numberValidator] + [validators.numberValidator] ); configStore.set('config.tracing.initialTransmissionDelay', { source: initialTransmissionDelaySource }); @@ -588,6 +600,18 @@ function normalizeTracingHttp({ userConfig = {}, defaultConfig = {}, finalConfig const userHttp = userConfig.tracing.http; finalConfig.tracing.http = {}; + normalizeExtraHttpHeadersToCapture({ userHttp, defaultConfig, finalConfig }); + normalizeHttpExitConfig({ userHttp, defaultConfig, finalConfig }); +} + +/** + * @param {{ + * userHttp: HTTPTracingOptions | undefined, + * defaultConfig: InstanaConfig, + * finalConfig: InstanaConfig + * }} options + */ +function normalizeExtraHttpHeadersToCapture({ userHttp, defaultConfig, finalConfig }) { const userHeaders = userHttp?.extraHttpHeadersToCapture; // 1. Check environment variable @@ -630,6 +654,78 @@ function normalizeTracingHttp({ userConfig = {}, defaultConfig = {}, finalConfig finalConfig.tracing.http.extraHttpHeadersToCapture = defaultConfig.tracing.http.extraHttpHeadersToCapture; configStore.set('config.tracing.http.extraHttpHeadersToCapture', { source: CONFIG_SOURCES.DEFAULT }); } + +/** + * @param {{ + * userHttp: HTTPTracingOptions | undefined, + * defaultConfig: InstanaConfig, + * finalConfig: InstanaConfig + * }} options + */ +function normalizeHttpExitConfig({ userHttp, defaultConfig, finalConfig }) { + finalConfig.tracing.http.exit = {}; + + normalizeClassifyAll4xxAsErrors({ userHttp, defaultConfig, finalConfig }); + normalizeClassifyAsErrors({ userHttp, defaultConfig, finalConfig }); +} + +/** + * @param {{ + * userHttp: HTTPTracingOptions | undefined, + * defaultConfig: InstanaConfig, + * finalConfig: InstanaConfig + * }} options + */ +function normalizeClassifyAll4xxAsErrors({ userHttp, defaultConfig, finalConfig }) { + const { value, source } = util.resolve( + { + envValue: 'INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS', + inCodeValue: userHttp?.exit?.classifyAll4xxAsErrors, + defaultValue: defaultConfig.tracing.http.exit.classifyAll4xxAsErrors + }, + [validators.booleanValidator] + ); + + finalConfig.tracing.http.exit.classifyAll4xxAsErrors = value; + + configStore.set('config.tracing.http.exit.classifyAll4xxAsErrors', { source }); + + util.log({ + configPath: 'config.tracing.http.exit.classifyAll4xxAsErrors', + source, + value, + envVarName: 'INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS' + }); +} + +/** + * @param {{ + * userHttp: HTTPTracingOptions | undefined, + * defaultConfig: InstanaConfig, + * finalConfig: InstanaConfig + * }} options + */ +function normalizeClassifyAsErrors({ userHttp, defaultConfig, finalConfig }) { + const { value, source } = util.resolve( + { + envValue: 'INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS', + inCodeValue: userHttp?.exit?.classifyAsErrors, + defaultValue: defaultConfig.tracing.http.exit.classifyAsErrors + }, + [validators.httpExitErrorCodeValidator] + ); + + finalConfig.tracing.http.exit.classifyAsErrors = value; + + configStore.set('config.tracing.http.exit.classifyAsErrors', { source }); + + util.log({ + configPath: 'config.tracing.http.exit.classifyAsErrors', + source, + value, + envVarName: 'INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS' + }); +} /** * @param {string} envVarValue * @returns {Array} @@ -795,7 +891,7 @@ function normalizeSpanBatchingEnabled({ userConfig = {}, defaultConfig = {}, fin inCodeValue: userConfig.tracing.spanBatchingEnabled, defaultValue: defaultConfig.tracing.spanBatchingEnabled }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.spanBatchingEnabled', { source }); @@ -818,7 +914,7 @@ function normalizeDisableW3cPropagation({ userConfig = {}, defaultConfig = {}, f inCodeValue: userConfig.tracing.disableW3cPropagation, defaultValue: defaultConfig.tracing.disableW3cPropagation }, - [validate.validateTruthyBoolean] + [validators.validateTruthyBoolean] ); configStore.set('config.tracing.disableW3cPropagation', { source }); @@ -841,7 +937,7 @@ function normalizeDisableW3cTraceCorrelation({ userConfig = {}, defaultConfig = inCodeValue: userConfig.tracing.disableW3cTraceCorrelation, defaultValue: defaultConfig.tracing.disableW3cTraceCorrelation }, - [validate.validateTruthyBoolean] + [validators.validateTruthyBoolean] ); configStore.set('config.tracing.disableW3cTraceCorrelation', { source }); @@ -868,7 +964,7 @@ function normalizeTracingKafka({ userConfig = {}, defaultConfig = {}, finalConfi inCodeValue: userKafka.traceCorrelation, defaultValue: defaultConfig.tracing.kafka.traceCorrelation }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.kafka.traceCorrelation', { source }); @@ -1077,7 +1173,7 @@ function normalizeIgnoreEndpointsDisableSuppression({ userConfig = {}, defaultCo inCodeValue: userConfig.tracing.ignoreEndpointsDisableSuppression, defaultValue: defaultConfig.tracing.ignoreEndpointsDisableSuppression }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.ignoreEndpointsDisableSuppression', { source }); @@ -1100,7 +1196,7 @@ function normalizeDisableEOLEvents({ userConfig = {}, defaultConfig = {}, finalC inCodeValue: userConfig.tracing.disableEOLEvents, defaultValue: defaultConfig.tracing.disableEOLEvents }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.disableEOLEvents', { source }); @@ -1122,7 +1218,7 @@ function normalizePreloadOpentelemetry({ userConfig = {}, defaultConfig = {}, fi inCodeValue: userConfig.preloadOpentelemetry, defaultValue: defaultConfig.preloadOpentelemetry }, - [validate.booleanValidator] + [validators.booleanValidator] ); finalConfig.preloadOpentelemetry = value; @@ -1148,7 +1244,7 @@ function normalizeOtlpExporter({ userConfig = {}, defaultConfig = {}, finalConfi inCodeValue: userOtlp.enabled, defaultValue: defaultConfig.tracing?.otlp?.enabled }, - [validate.booleanValidator] + [validators.booleanValidator] ); configStore.set('config.tracing.otlp.enabled', { source }); diff --git a/packages/core/src/config/validator.js b/packages/core/src/config/validator.js index d0e5837919..6fb13ba9f7 100644 --- a/packages/core/src/config/validator.js +++ b/packages/core/src/config/validator.js @@ -6,6 +6,16 @@ const { validStackTraceModes } = require('../util/constants'); +/** @type {import('../core').GenericLogger} */ +let logger; + +/** + * @param {import('../core').GenericLogger} [_logger] + */ +exports.init = _logger => { + logger = _logger; +}; + /** * @param {any} value * @returns {number|undefined} @@ -122,3 +132,29 @@ exports.validateStackTraceLength = function validateStackTraceLength(value) { return { isValid: true, error: null }; }; + +/** + * @param {any} value + * @returns {Array|undefined} + */ +exports.httpExitErrorCodeValidator = function httpExitErrorCodeValidator(value) { + if (typeof value === 'string') { + value = value.split(','); + } + + if (!Array.isArray(value)) { + return undefined; + } + + return value.reduce((result, v) => { + const code = Number(v); + + if (Number.isInteger(code) && code >= 400 && code <= 499) { + result.push(code); + } else { + logger?.debug(`Ignoring invalid HTTP exit status code "${v}". Expected an integer between 400 and 499.`); + } + + return result; + }, []); +}; diff --git a/packages/core/src/tracing/instrumentation/protocols/http2Client.js b/packages/core/src/tracing/instrumentation/protocols/http2Client.js index 9567f23aef..4fe996028c 100644 --- a/packages/core/src/tracing/instrumentation/protocols/http2Client.js +++ b/packages/core/src/tracing/instrumentation/protocols/http2Client.js @@ -135,7 +135,7 @@ function instrumentClientHttp2Session(clientHttp2Session) { stream.on('end', () => { span.d = Date.now() - span.ts; - span.ec = status >= 500 ? 1 : 0; + span.ec = tracingUtil.shouldMarkAsError(status) ? 1 : 0; span.data.http.status = status; if (capturedHeaders) { span.data.http.header = capturedHeaders; diff --git a/packages/core/src/tracing/instrumentation/protocols/httpClient.js b/packages/core/src/tracing/instrumentation/protocols/httpClient.js index 9d9dae8a57..2f5519bd4f 100644 --- a/packages/core/src/tracing/instrumentation/protocols/httpClient.js +++ b/packages/core/src/tracing/instrumentation/protocols/httpClient.js @@ -238,7 +238,7 @@ function instrument(coreModule, forceHttps) { } span.d = Date.now() - span.ts; - span.ec = res.statusCode >= 500 ? 1 : 0; + span.ec = tracingUtil.shouldMarkAsError(res.statusCode) ? 1 : 0; span.transmit(); diff --git a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js index c402033405..c857f55bfe 100644 --- a/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js +++ b/packages/core/src/tracing/instrumentation/protocols/nativeFetch.js @@ -170,7 +170,7 @@ function instrument() { fetchPromise .then(response => { span.data.http.status = response.status; - span.ec = response.status >= 500 ? 1 : 0; + span.ec = tracingUtil.shouldMarkAsError(response.status) ? 1 : 0; capturedHeaders = mergeExtraHeadersFromFetchHeaders( capturedHeaders, response.headers, diff --git a/packages/core/src/tracing/tracingUtil.js b/packages/core/src/tracing/tracingUtil.js index 57b713f6a3..8f35d39135 100644 --- a/packages/core/src/tracing/tracingUtil.js +++ b/packages/core/src/tracing/tracingUtil.js @@ -24,6 +24,11 @@ let stackTraceLength; */ // eslint-disable-next-line no-unused-vars let stackTraceMode; + +/** + * @type {import("../config").HTTPExitTracingOptions | undefined} + */ +let httpExitConfig; /** * @param {import('../config').InstanaConfig} config */ @@ -31,6 +36,7 @@ exports.init = function (config) { logger = config.logger; stackTraceLength = config?.tracing?.stackTraceLength; stackTraceMode = config?.tracing?.stackTrace; + httpExitConfig = config.tracing.http.exit; }; /** @@ -39,6 +45,7 @@ exports.init = function (config) { exports.activate = function activate(_config) { stackTraceLength = _config.tracing.stackTraceLength; stackTraceMode = _config.tracing.stackTrace; + httpExitConfig = _config.tracing.http.exit; }; /** @@ -397,6 +404,32 @@ exports.setErrorDetails = function setErrorDetails(span, error, technology) { } }; +/** + * 5xx status codes are always considered errors. + * 4xx status codes are considered errors if `classifyAll4xxAsErrors` is + * set to true or the status code is listed in `classifyAsErrors`. + * + * @param {number} statusCode + * @returns {boolean} + */ +exports.shouldMarkAsError = function shouldMarkAsError(statusCode) { + if (statusCode >= 500) { + return true; + } + + if (statusCode < 400 || statusCode > 499) { + return false; + } + + const classifyAsErrors = httpExitConfig?.classifyAsErrors ?? []; + + if (classifyAsErrors.length > 0) { + return classifyAsErrors.includes(statusCode); + } + + return httpExitConfig?.classifyAll4xxAsErrors === true; +}; + /** * Handles and logs a trace message when the instrumented function returns an unexpected value(not a promise) * diff --git a/packages/core/test/config/normalizeConfig_test.js b/packages/core/test/config/normalizeConfig_test.js index 88ad975a2c..a29788bb1a 100644 --- a/packages/core/test/config/normalizeConfig_test.js +++ b/packages/core/test/config/normalizeConfig_test.js @@ -47,6 +47,8 @@ describe('config.normalizeConfig', () => { delete process.env.INSTANA_IGNORE_ENDPOINTS_PATH; delete process.env.INSTANA_IGNORE_ENDPOINTS_DISABLE_SUPPRESSION; delete process.env.INSTANA_TRACING_OTLP_ENABLED; + delete process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS; + delete process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS; } describe('default configuration', () => { @@ -403,6 +405,177 @@ describe('config.normalizeConfig', () => { }); }); + describe('HTTP exit 4xx error classification configuration', () => { + describe('classifyAll4xxAsErrors', () => { + it('should default to false', () => { + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(false); + }); + + it('should accept in-code configuration (true)', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: true + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(true); + }); + + it('should accept in-code configuration (false)', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: false + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(false); + }); + + it('should accept env var "true"', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS = 'true'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(true); + }); + + it('should accept env var "false"', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS = 'false'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(false); + }); + + it('should fall back to default when env var is invalid', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS = 'not-a-boolean'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(false); + }); + + it('should give env var precedence over in-code configuration', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_ALL_4XX_AS_ERRORS = 'true'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAll4xxAsErrors: false + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(true); + }); + }); + + describe('classifyAsErrors', () => { + it('should default to an empty array', () => { + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([]); + }); + + it('should accept an in-code array of 4xx status codes', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAsErrors: [401, 403] + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401, 403]); + }); + + it('should parse status codes from env var (401,403)', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = '401,403'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401, 403]); + }); + + it('should handle whitespace around tokens in env var (401, 403)', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = '401, 403'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401, 403]); + }); + + it('should silently ignore invalid non-integer tokens (401,abc,403)', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = '401,abc,403'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401, 403]); + }); + + it('should silently ignore values outside the 400–499 range (399,500,600)', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = '399,401,500,600'; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401]); + }); + + it('should resolve to empty array for an empty env var string', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = ''; + const config = coreConfig.normalize(); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([]); + }); + + it('should preserve duplicate values from in-code configuration', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAsErrors: [401, 401, 403] + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([401, 401, 403]); + }); + + it('should give env var precedence over in-code configuration', () => { + process.env.INSTANA_TRACING_HTTP_EXIT_CLASSIFY_AS_ERRORS = '403'; + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAsErrors: [401] + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([403]); + }); + + it('should fall back to in-code configuration when env var is not set', () => { + const config = coreConfig.normalize({ + userConfig: { + tracing: { + http: { + exit: { + classifyAsErrors: [422, 429] + } + } + } + } + }); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([422, 429]); + }); + }); + }); + describe('stack trace configuration', () => { it('should accept numerical custom stack trace length', () => { const config = coreConfig.normalize({ userConfig: { tracing: { stackTraceLength: 666 } } }); @@ -2430,6 +2603,9 @@ describe('config.normalizeConfig', () => { expect(config.tracing.http.extraHttpHeadersToCapture).to.be.an('array'); expect(config.tracing.http.extraHttpHeadersToCapture).to.be.empty; expect(config.tracing.http.extraHttpHeadersToCapture).to.be.empty; + expect(config.tracing.http.exit).to.be.an('object'); + expect(config.tracing.http.exit.classifyAll4xxAsErrors).to.equal(false); + expect(config.tracing.http.exit.classifyAsErrors).to.deep.equal([]); expect(config.tracing.stackTrace).to.equal('all'); expect(config.tracing.stackTraceLength).to.equal(10); expect(config.tracing.spanBatchingEnabled).to.be.false; diff --git a/packages/core/test/config/validator_test.js b/packages/core/test/config/validator_test.js index 13ef8e506d..8925490827 100644 --- a/packages/core/test/config/validator_test.js +++ b/packages/core/test/config/validator_test.js @@ -418,4 +418,103 @@ describe('config.validator', () => { }); }); }); + + describe('httpExitErrorCodeValidator', () => { + it('should return an array for a valid comma-separated string', () => { + expect(validator.httpExitErrorCodeValidator('401,403')).to.deep.equal([401, 403]); + }); + + it('should return an array for a valid array input', () => { + expect(validator.httpExitErrorCodeValidator([401, 403])).to.deep.equal([401, 403]); + }); + + it('should trim whitespace around tokens', () => { + expect(validator.httpExitErrorCodeValidator('401, 403')).to.deep.equal([401, 403]); + expect(validator.httpExitErrorCodeValidator(' 401 , 403 ')).to.deep.equal([401, 403]); + }); + + it('should ignore non-integer tokens', () => { + expect(validator.httpExitErrorCodeValidator('401,abc,403')).to.deep.equal([401, 403]); + expect(validator.httpExitErrorCodeValidator([401, 'abc', 403])).to.deep.equal([401, 403]); + }); + + it('should ignore values below 400', () => { + expect(validator.httpExitErrorCodeValidator('399,401')).to.deep.equal([401]); + expect(validator.httpExitErrorCodeValidator([200, 301, 401])).to.deep.equal([401]); + }); + + it('should ignore values above 499', () => { + expect(validator.httpExitErrorCodeValidator('401,500,600')).to.deep.equal([401]); + expect(validator.httpExitErrorCodeValidator([401, 500, 503])).to.deep.equal([401]); + }); + + it('should return empty array when all values are out of range', () => { + expect(validator.httpExitErrorCodeValidator('200,301,500')).to.deep.equal([]); + }); + + it('should return empty array for an empty comma-separated string', () => { + expect(validator.httpExitErrorCodeValidator('')).to.deep.equal([]); + }); + + it('should return empty array for an empty array', () => { + expect(validator.httpExitErrorCodeValidator([])).to.deep.equal([]); + }); + + it('should return undefined for null', () => { + expect(validator.httpExitErrorCodeValidator(null)).to.be.undefined; + }); + + it('should return undefined for undefined', () => { + expect(validator.httpExitErrorCodeValidator(undefined)).to.be.undefined; + }); + + it('should return undefined for a number', () => { + expect(validator.httpExitErrorCodeValidator(401)).to.be.undefined; + }); + + it('should return undefined for a plain object', () => { + expect(validator.httpExitErrorCodeValidator({ code: 401 })).to.be.undefined; + }); + + it('should preserve all valid 4xx codes including boundary values', () => { + expect(validator.httpExitErrorCodeValidator('400,499')).to.deep.equal([400, 499]); + }); + + it('should preserve duplicate values', () => { + expect(validator.httpExitErrorCodeValidator('401,401,403')).to.deep.equal([401, 401, 403]); + }); + + it('should trim whitespace in array values', () => { + expect(validator.httpExitErrorCodeValidator([' 401 ', '403 '])).to.deep.equal([401, 403]); + }); + + it('should accept a mix of string and number values in an array', () => { + expect(validator.httpExitErrorCodeValidator(['401', 403])).to.deep.equal([401, 403]); + }); + + it('should ignore empty tokens in comma-separated strings', () => { + expect(validator.httpExitErrorCodeValidator('401,,403,')).to.deep.equal([401, 403]); + }); + + it('should ignore empty string values in arrays', () => { + expect(validator.httpExitErrorCodeValidator(['401', '', '403'])).to.deep.equal([401, 403]); + }); + + it('should ignore decimal values', () => { + expect(validator.httpExitErrorCodeValidator('401.5,403')).to.deep.equal([403]); + expect(validator.httpExitErrorCodeValidator([401.5, 403])).to.deep.equal([403]); + }); + + it('should ignore boolean and null values in arrays', () => { + expect(validator.httpExitErrorCodeValidator([401, true, null, false, 403])).to.deep.equal([401, 403]); + }); + + it('should return an empty array when all comma-separated values are invalid', () => { + expect(validator.httpExitErrorCodeValidator('abc,,500,399')).to.deep.equal([]); + }); + + it('should return an empty array when all array values are invalid', () => { + expect(validator.httpExitErrorCodeValidator(['abc', {}, [], 500, 399])).to.deep.equal([]); + }); + }); }); diff --git a/packages/core/test/tracing/tracingUtil_test.js b/packages/core/test/tracing/tracingUtil_test.js index 1732168c6b..d1c788789a 100644 --- a/packages/core/test/tracing/tracingUtil_test.js +++ b/packages/core/test/tracing/tracingUtil_test.js @@ -170,28 +170,28 @@ describe('tracing/tracingUtil', () => { expect(readTraceContextFromBuffer( Buffer.from( [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff // + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff // ]) - ) + ) ).to.deep.equal({ t: '8000000000000000', s: 'ffffffffffffffff' }); // prettier-ignore expect(readTraceContextFromBuffer( Buffer.from( [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02 // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02 // ]) - ) + ) ).to.deep.equal({ t: '0000000000000001', s: '0000000000000002' }); // prettier-ignore expect(readTraceContextFromBuffer( Buffer.from( [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // - 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f // + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // + 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f // ]) - ) + ) ).to.deep.equal({ t: '7fffffffffffffff', s: '0f0f0f0f0f0f0f0f' }); }); @@ -200,28 +200,28 @@ describe('tracing/tracingUtil', () => { expect(readTraceContextFromBuffer( Buffer.from( [0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, // - 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff // + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff // ]) - ) + ) ).to.deep.equal({ t: 'f0f0f0f0f0f0f0f0' + '8000000000000000', s: 'ffffffffffffffff' }); // prettier-ignore expect(readTraceContextFromBuffer( Buffer.from( [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, // - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03 // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03 // ]) - ) + ) ).to.deep.equal({ t: '0000000000000001' + '0000000000000002', s: '0000000000000003' }); // prettier-ignore expect(readTraceContextFromBuffer( Buffer.from( [0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, // - 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // - 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f // + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // + 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f, 0x0f // ]) - ) + ) ).to.deep.equal({ t: 'f0f0f0f0f0f0f0f0' + '7fffffffffffffff', s: '0f0f0f0f0f0f0f0f' }); }); }); @@ -294,8 +294,8 @@ describe('tracing/tracingUtil', () => { // prettier-ignore verifyBuffer2HexStrings2Buffer( [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x00, 0x01 // + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x00, 0x01 // ] ); }); @@ -304,8 +304,8 @@ describe('tracing/tracingUtil', () => { // prettier-ignore verifyBuffer2HexStrings2Buffer( [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x00, 0x01 // + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, // + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x00, 0x01 // ] ); }); @@ -434,7 +434,13 @@ describe('tracing/tracingUtil', () => { tracingUtil.init({ logger: createFakeLogger(), tracing: { - stackTraceLength: 10 + stackTraceLength: 10, + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -908,7 +914,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'none' + stackTrace: 'none', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -948,7 +960,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'error' + stackTrace: 'error', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -1005,7 +1023,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'all' + stackTrace: 'all', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -1207,7 +1231,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'all' + stackTrace: 'all', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -1257,7 +1287,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'none' + stackTrace: 'none', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -1297,7 +1333,13 @@ describe('tracing/tracingUtil', () => { logger: createFakeLogger(), tracing: { stackTraceLength: 10, - stackTrace: 'error' + stackTrace: 'error', + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } } }); }); @@ -1336,4 +1378,130 @@ describe('tracing/tracingUtil', () => { expect(ids.size).to.equal(1000); }); }); + + describe('shouldMarkAsError', () => { + const { shouldMarkAsError } = tracingUtil; + + describe('default config (classifyAll4xxAsErrors=false, classifyAsErrors=[])', () => { + const defaultConfig = { classifyAll4xxAsErrors: false, classifyAsErrors: [] }; + + it('should return false for 404', () => { + expect(shouldMarkAsError(404, defaultConfig)).to.equal(false); + }); + + it('should return false for 401', () => { + expect(shouldMarkAsError(401, defaultConfig)).to.equal(false); + }); + + it('should return true for 500', () => { + expect(shouldMarkAsError(500, defaultConfig)).to.equal(true); + }); + + it('should return true for 503', () => { + expect(shouldMarkAsError(503, defaultConfig)).to.equal(true); + }); + }); + + describe('classifyAll4xxAsErrors=true, classifyAsErrors=[]', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + http: { + exit: { + classifyAll4xxAsErrors: true, + classifyAsErrors: [] + } + } + } + }); + }); + it('should return true for 400', () => { + expect(shouldMarkAsError(400)).to.equal(true); + }); + + it('should return true for 401', () => { + expect(shouldMarkAsError(401)).to.equal(true); + }); + + it('should return true for 404', () => { + expect(shouldMarkAsError(404)).to.equal(true); + }); + + it('should return true for 499', () => { + expect(shouldMarkAsError(499)).to.equal(true); + }); + + it('should return true for 500', () => { + expect(shouldMarkAsError(500)).to.equal(true); + }); + }); + + describe('classifyAsErrors=[401,403] takes precedence over classifyAll4xxAsErrors', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + http: { + exit: { + classifyAll4xxAsErrors: true, + classifyAsErrors: [401, 403] + } + } + } + }); + }); + + it('should return true for 401 (in list)', () => { + expect(shouldMarkAsError(401)).to.equal(true); + }); + + it('should return true for 403 (in list)', () => { + expect(shouldMarkAsError(403)).to.equal(true); + }); + + it('should return false for 404 (not in list, even though classifyAll4xxAsErrors=true)', () => { + expect(shouldMarkAsError(404)).to.equal(false); + }); + + it('should return false for 400 (not in list, even though classifyAll4xxAsErrors=true)', () => { + expect(shouldMarkAsError(400)).to.equal(false); + }); + + it('should return true for 500', () => { + expect(shouldMarkAsError(500)).to.equal(true); + }); + }); + + describe('non-error status codes', () => { + before(() => { + tracingUtil.init({ + logger: createFakeLogger(), + tracing: { + stackTraceLength: 10, + http: { + exit: { + classifyAll4xxAsErrors: false, + classifyAsErrors: [] + } + } + } + }); + }); + + it('should return false for 200', () => { + expect(shouldMarkAsError(200)).to.equal(false); + }); + + it('should return false for 302', () => { + expect(shouldMarkAsError(302)).to.equal(false); + }); + + it('should return false for 399', () => { + expect(shouldMarkAsError(399)).to.equal(false); + }); + }); + }); });